Compare commits

...
Author SHA1 Message Date
chichuan b3fdab44c9 Merge latest main into codex/sync-wukong-drive-list-latest
main 已封版 v1.0.57-beta.3 与 v1.0.57,[Unreleased] 被清空,与本分支的
--latest 条目冲突。解法:CHANGELOG 取 main 整份(已封版小节逐字节不动,
满足 check-changelog-pr.sh 的不可变约束),再把 --latest 条目补回
[Unreleased]。其余文件全部自动合并,main 未动过 internal/helpers/drive.go。
2026-08-06 16:07:22 +08:00
chichuan c5b537c8cf fix(drive): 修正 --latest 不完整扫描建议的自相矛盾并补齐叶子 Selection
P2-1 LATEST_SCAN_INCOMPLETE 的 Suggestion 与示例自相矛盾

问题:正文让用户「去掉 --latest 重跑」看失败明细,紧跟的唯一示例却是
dws drive list --folder <子目录ID> --latest N —— 照抄必然复现同一个错误。
根因:两条互斥的出路(缩范围后仍取 Top-N / 去掉 --latest 看明细)挤在一句里,
却只给了一条命令。
修法:每个「;」子句自带与该子句正文一致的命令 —— 重取 Top-N 那条保留 --latest,
去掉 --latest 那条给 --depth 版本。同时把 partial + errors[] 的承诺显式限定在
--depth>1:单层(含知识库 maxDepth=1)去掉 --latest 会路由回普通单层 list,
本就没有 errors[] 契约,无条件承诺只是把矛盾换成另一种不准确。
LATEST_SCAN_TRUNCATED 那条只有一个子句且命令保留 --latest,本身自洽,不动。

测试:新增 assertDriveLatestSuggestion 逐子句校验自洽性,替换 5 处只断言
Suggestion != "" 的弱断言。该断言对修改前的文案是红的(3 个 INCOMPLETE 用例
精确报出矛盾子句),对未改动的 TRUNCATED 文案是绿的 —— 证明它不是为新文案量身定做。

P2-2 drive list / drive recent 的 Selection 没跟上 Agent 路由目标

问题:上一轮改了 skill 文档与 flag help,但叶子 Contract 一字未动 ——
drive list 的 UseWhen 无 --latest 正选、Examples 两条都是 --limit 分页、
AvoidWhen 仍无条件让路给 drive recent(与 skill 里的三者路由矛盾);
drive recent 也没有反向指回 drive list --latest。
修法:drive list 追加 --latest 正选,AvoidWhen 第 3 条改写成「个人访问记录 →
recent / 文件修改时间 → 本命令 --latest」的分流;drive recent 追加对应反选。
措辞与 skills/**/drive.md 的三者路由一致。

Examples 是替换而非新增:schema_agent_examples.go 硬限最多 2 条
(has %d examples; maximum is 2),加第 3 条直接挂门禁。替换第 2 条不丢覆盖面 ——
--limit 仍由第 1 条示范、--folder 由第 2 条继续示范,第 2 条顺带示范 --latest,
且无 --limit(运行时互斥)、无 --yes(门禁禁止)。

删掉 docs/drive-list-latest-design.md

设计细节收敛到 skills/**/drive.md 单一出处。本轮 P2-2 正是「文档写对了、
Contract 没跟上」造成的,两份文档并存只会让下一次再跑偏。CHANGELOG 里唯一
指向它的链接行一并删除(第 23 行本身以句号结尾,bullet 依然完整)。
2026-08-06 15:29:14 +08:00
chichuan bc56373ef2 test(drive): 补齐 --latest 拒绝产出错误的目录名回落分支覆盖
CI Coverage 门禁要求改动代码 100% 覆盖,d1c8c903 的 changed-code 覆盖率
99.1379%(232 条语句缺 2 条)。缺口是 driveLatestIncompleteError 里
folderName 为空时的两级回落:先落 folderId,再落 <root>。

这两条不是防御性死代码:BFS 的根目录入队时是 driveDepthFolder{id, depth:0},
从不携带 name,所以「根目录首页有收获、后续页读取失败」这一真实路径产出的
driveDepthError 的 folderName 必然为空 —— 若不回落,用户拿到的就是
"folder=" 空洞提示。原有的 folder-failure 测试用的是有 name 的子目录 dirA,
正好绕过了这两条。

补两个测试(共用新 fixture driveLatestRootPageFailureCaller:根目录首页 1 个
文件 + nextToken,第 2 页 403):
- 未指定 --folder:folderId 也为空,消息回落到 folder=<root>
- 指定 --folder rootFolder:回落到 folder=rootFolder 而不是 <root>

无生产代码改动。本地复现并验证:改动代码覆盖率 99.1379% → 100.0000%
(232/232),internal/helpers 包整体 99.9% → 100.0%。
2026-08-06 14:05:51 +08:00
chichuan d1c8c903f6 fix(drive): 修复 --latest 的字段泄露、不完整扫描误报与别名互斥缺口
修复 PR #885 review 提出的三个问题,均已实机复现确认真实存在。

1. [P1] 内部排序字段 sortTime 泄露进输出契约
sortTime 无条件写入每个 BFS 项,却只在 latest>0 && depth==1 时随装饰字段一起剥除,
于是普通 --depth 2 与 depth>1 --latest 的输出都带上它,违反设计文档「不新增业务字段」。
改为只在 latest>0 时写入,并在 emitDriveDepthResult 发射前无条件删除(含 SIGINT 路径),
与 depth==1 的整体剥除解耦。

2. [P1] 扫描不完整仍以退出码 0 产出「全局 Top-N」
拒绝产出的防线只看 truncated,递归途中目录读取失败只进 errors[] 并继续执行。BFS 序与
修改时间无关,未读全的区域完全可能含更新文件,这种集合上的 Top-N 不是全局最新。改为
latest>0 时 truncated 或 errors[] 非空一律拒绝产出(新 token LATEST_SCAN_INCOMPLETE,
与 LATEST_SCAN_TRUNCATED 同 code,便于消费方区分「范围太大」与「读不到」);errors[]
不再进 stdout,失败目录数与首个失败详情因此搬进错误消息。不可恢复错误(token 过期 /
网络不可达)在 latest 下不吐 partial,原样上抛根因错误。不带 --latest 的路径行为不变
(partial + truncated:true + errors[] + 退出码 0),有独立护栏测试;SIGINT 路径不动
(退出码 130 已显式标记结果不完整)。

3. [P2] RegisterCrossProductAliases 自动注册的隐藏别名逃过互斥校验
校验手写枚举 limit/max 与 cursor/next-token,漏掉自动注册的 --page-size(limit 语义组)
与 --page-token(cursor 语义组):--latest 3 --page-size 1 此前退出 0 并静默忽略该 flag。
改为从注册表按语义组取别名(新增只读查询 crossProductAliasPeers),报错点名用户实际传入
的那个 flag 而非语义组主名。string 型按值判定(保住 Agent 的 --cursor "$CURSOR" 首页空值
模板)、其余按 Changed 判定,与改造前 flagOrFallback / Changed 逐字等价,只把覆盖面扩到
全部别名。

本 PR 之外的一处主动扩围(单列说明,便于单独判断):validateDriveListDepth 有完全相同的
枚举缺口。--depth 2 --limit 50 今天已报错,而 --depth 2 --page-size 50 静默忽略、
--depth 2 --page-token X 更糟(用户以为在续页,游标实际被丢掉)。共享 helper 后是一行
改动;已确认无代码以编程方式 Set 这些别名、无文档示例这样组合,行为变化面只是「静默忽略
→ 明确报错」。

测试:sortTime 不泄露(带/不带 latest 两侧)、目录失败与不可恢复错误下拒绝产出且 stdout
为空、对应的 latest=0 partial 护栏、用真实 newDriveCommand 构造的别名互斥回归(--latest
与 --depth 两侧共 14 组)、空值游标模板不误伤、crossProductAliasPeers 单测。
文档同步:设计文档 §3.2/§4/§5/§6/§7、两份 skill 镜像、flag 帮助文案与 CHANGELOG。
2026-08-06 12:14:55 +08:00
chichuan 432b8742c4 Merge latest main into codex/sync-wukong-drive-list-latest 2026-08-06 09:26:19 +08:00
chichuan a9af027ed4 feat: drive list 支持 --latest 按修改时间取最新 N 个文件
同步 dws-wukong CR 28929931(feature/drive-list-latest),按本仓结构重新落地。

一条命令直达「最近改的那份 XX」,替代「拉全量再自己比时间」的既有做法:

- 钉盘单层走服务端 orderBy=modifyTime&order=desc,凑够 N 即停(无 pattern 时
  通常一次请求),最多扫描 1000 条
- 知识库(--workspace)与递归(--depth>1)复用现有 BFS 遍历 + 共享后置处理器
  客户端排序,零新增请求
- 文件夹不进 Top-N;排序 tie-break 链 sortTime desc → rel_path asc → ID asc
- 与 --order-by/--order/--limit/--cursor/--versions 互斥,报错均附等效改写
- 递归触 2000 条上限时报 LATEST_SCAN_TRUNCATED 且 stdout 为空(截断集的 Top-N
  不是全局最新);凑不满 N 不算失败,退出码 0 + stderr 提示

本仓侧的三处结构性适配:
- 时间归一化新写为独立 helper internal/helpers/drive_time.go(wukong 侧已有)
- wukong 的 pytest e2e 套件折叠进 Go 测试,全部 TestCrossPlatformCoverage* 前缀
- envelope 契约由 test/fixtures/cli-interface-baseline.txt 承接

顺带补齐遗漏已久的 --depth / --pattern / --quiet 文档(mono + multi 双份)。
2026-08-05 23:05:12 +08:00
18 changed files with 1518 additions and 53 deletions
+16
View File
@@ -6,6 +6,22 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **`drive list --latest N`** — returns the N most recently modified files (1–50) in one command,
replacing the "fetch everything and sort client-side" workaround. Combines with `--pattern`
("newest N among name matches"), `--folder` / `--space-id`, `--workspace` (Knowledge Base), and
`--depth`. The DingTalk Drive single-layer route uses server-side `orderBy=modifyTime&order=desc`
and stops as soon as N is filled (typically one request); the Knowledge Base and recursive routes
reuse the existing BFS walk and sort client-side. Folders never enter the Top-N. Mutually
exclusive with `--order-by` / `--order` / `--limit` / `--cursor` / `--versions` — including the
auto-registered hidden aliases `--page-size` / `--page-token` / `--next-token` / `--max`, and the
error names the flag you actually passed — and every exclusivity error carries the equivalent
rewrite. When a recursive scan hits the 2000-item cap (`LATEST_SCAN_TRUNCATED`) or any folder in
the walk fails to read (`LATEST_SCAN_INCOMPLETE`) the command **fails** with empty stdout rather
than emitting a Top-N that may not be globally newest; falling short of N is not a failure
(exit 0 plus a stderr hint). Internal sort state never reaches the output contract.
## [1.0.57] - 2026-08-06
This stable release promotes the fully delivered `v1.0.57-beta.3` baseline.
+22
View File
@@ -48,6 +48,28 @@ var crossProductAliases = []crossProductAliasGroup{
{semantic: "内容文件路径", names: []string{"content-file", "content-path"}},
}
// crossProductAliasPeers 返回与 name 同语义组的其余 flag 名(不含 name 自身)。
// 互斥/冲突校验用它枚举 RegisterCrossProductAliases 自动注册的隐藏别名,
// 避免手写枚举漏掉自动别名(如 --limit 语义组的 --page-size)。
// name 不属于任何语义组时返回 nil。
func crossProductAliasPeers(name string) []string {
for _, group := range crossProductAliases {
for _, candidate := range group.names {
if candidate != name {
continue
}
peers := make([]string, 0, len(group.names)-1)
for _, peer := range group.names {
if peer != name {
peers = append(peers, peer)
}
}
return peers
}
}
return nil
}
// RegisterCrossProductAliases automatically registers hidden alias flags for
// cross-product compatibility. Call this once after all primary flags are registered.
//
@@ -0,0 +1,42 @@
package helpers
import (
"slices"
"testing"
)
func TestCrossPlatformCoverageCrossProductAliasPeers(t *testing.T) {
// 组内任一成员都能查到其余成员,且结果不含自身。
for _, group := range crossProductAliases {
for _, name := range group.names {
peers := crossProductAliasPeers(name)
if len(peers) != len(group.names)-1 {
t.Fatalf("peers(%q) = %#v, want %d entries", name, peers, len(group.names)-1)
}
if slices.Contains(peers, name) {
t.Fatalf("peers(%q) contains itself: %#v", name, peers)
}
for _, peer := range peers {
if !slices.Contains(group.names, peer) {
t.Fatalf("peers(%q) leaked %q from another group", name, peer)
}
}
}
}
// drive list 的分页互斥(driveListLimitConflict / driveListCursorConflict)依赖这两组:
// 改动注册表若挪走这些别名,会在此失败而不是静默丢失互斥覆盖。
if peers := crossProductAliasPeers("limit"); !slices.Contains(peers, "page-size") {
t.Fatalf("limit peers = %#v, want page-size", peers)
}
for _, want := range []string{"page-token", "next-token"} {
if peers := crossProductAliasPeers("cursor"); !slices.Contains(peers, want) {
t.Fatalf("cursor peers = %#v, want %s", peers, want)
}
}
// 不属于任何语义组的 flag 返回 nil。
if peers := crossProductAliasPeers("latest"); peers != nil {
t.Fatalf("peers(latest) = %#v, want nil", peers)
}
}
+52 -9
View File
@@ -316,14 +316,17 @@ func newDriveCommand() *cobra.Command {
Example: ` dws drive list --limit 20
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>`,
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --pattern "*日报*" --latest 3
dws drive list --workspace <workspaceId> --latest 5`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 先于 --depth/--latest 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
if cmd.Flags().Changed("versions") {
if cmd.Flags().Changed("depth") && depth > 1 {
@@ -332,6 +335,12 @@ func newDriveCommand() *cobra.Command {
Message: "--versions 与 --depth 不能同时使用",
}
}
if cmd.Flags().Changed("latest") {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --latest 不能同时使用:版本列表按版本序返回,无跨文件 Top-N 语义",
}
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
@@ -358,12 +367,21 @@ func newDriveCommand() *cobra.Command {
}
}
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
}
// 如果指定了 --workspace,路由到文档空间(doc MCP server)
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
if depth > 1 {
// latest>0 且 depth==1:知识库无服务端排序须拉全量,复用 BFS maxDepth=1
//(限流补偿/页数防线现成),后置处理器在发射函数内取 Top-N 并剥装饰字段;
// pattern 同样在 BFS 发射函数内过滤,不再拒绝。
if depth > 1 || latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
rootFolder := docFolderFlag(cmd, "node", "file-id")
@@ -372,7 +390,7 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
if pattern != "" {
return &CLIError{
@@ -417,7 +435,27 @@ func newDriveCommand() *cobra.Command {
return err
}
}
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
if latest > 0 {
// 钉盘单层 latest:服务端支持 orderBy=modifyTime&order=desc,凑够 N 即停,
// 不复用 BFS(否则最高频路径从 O(1) 退化为 O(目录大小)),走独立扫描循环。
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
baseArgs["spaceId"] = v
}
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
baseArgs["withThumbnail"] = true
}
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
if rootFolder != "" {
if err := validateDriveParentID(rootFolder); err != nil {
return err
}
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
// 默认路由:钉盘文件列表
@@ -483,15 +521,18 @@ func newDriveCommand() *cobra.Command {
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
"范围已知(当前目录/--folder/--workspace 知识库)、要按文件修改时间取最新 N 个文件时用 --latest N(可叠加 --pattern 限名、--depth 递归)",
},
AvoidWhen: []string{
"只记得关键词、不知道所在目录时改用 dws drive search",
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
"要找最近打开/编辑过的文档改用 dws drive recent",
"要看「我最近看过/改过的」跨空间个人访问记录改用 dws drive recent;范围已知、按文件修改时间取最新用本命令 --latest",
},
// 门禁限定最多 2 条示例(schema_agent_examples.go),--latest 只能替换掉
// 原第 2 条的 --limit:--limit 仍由第 1 条示范,--folder 由第 2 条继续示范。
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --folder <dentryUuid> --limit 20 --format json",
"dws drive list --folder <dentryUuid> --latest 5 --format json",
},
},
},
@@ -1051,8 +1092,9 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().Bool("versions", false, "列出文件历史版本而非文件列表 (需配合 --node)")
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit(含隐藏别名)互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示\"名称匹配的文件中最新 N 个\";可与 --workspace/--depth 组合(扫描触 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N);与 --order-by/--order/--limit/--cursor(含隐藏别名)互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
@@ -2822,6 +2864,7 @@ func newDriveCommand() *cobra.Command {
AvoidWhen: []string{
"按关键词全局搜文件改用 dws drive search",
"浏览某目录内容改用 dws drive list",
"范围已知(某文件夹/知识库)、要按文件修改时间取最新 N 个改用 dws drive list --latest N(本命令按个人访问记录、跨空间,与文件修改时间不是一回事)",
},
Examples: []string{
"dws drive recent --format json",
+281 -22
View File
@@ -32,6 +32,11 @@ const (
// API 调用数随目录宽度指数增长,超过报错不 clamp。
driveDepthMax = 5
driveDepthMaxItems = 2000
// --latest 上限与 --limit 服务端每页硬上限 50 对齐;「定位最新」场景 N 天然小,
// 百/千级属「导出清单」场景,应由 --order-by 翻页或 --depth 承接。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页):服务端有序,已扫描即最新 1000 条,提前终止不影响 Top-N 正确性。
driveLatestScanMax = 1000
// Sentinel 限流以 HTTP 200 + body errorCode 返回,transport 层不会自动重试,补偿只能在 BFS 层做。
driveDepthRateLimitedCode = "invalidRequest.rateLimited"
driveDepthCancelledMsg = "cancelled by user, partial result emitted"
@@ -82,15 +87,52 @@ func validateDriveListDepth(cmd *cobra.Command, depth int) error {
return nil
}
// --workspace 不互斥:depth>1 时作为路由开关切到知识库 BFS。
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveDepthExclusiveError("cursor", "depth>1 时多文件夹合成一份清单,无连续游标")
if name := driveListCursorConflict(cmd); name != "" {
return driveDepthExclusiveError(name, "depth>1 时多文件夹合成一份清单,无连续游标")
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveDepthExclusiveError("limit", "递归模式数据量由全局上限 2000 与 --depth 层数控制")
if name := driveListLimitConflict(cmd); name != "" {
return driveDepthExclusiveError(name, "递归模式数据量由全局上限 2000 与 --depth 层数控制")
}
return nil
}
// driveListLimitConflict / driveListCursorConflict 返回用户实际传入的那个分页 flag 名
// (含 RegisterCrossProductAliases 自动注册的隐藏别名,如 --page-size / --page-token),
// 无冲突返回 ""。报错点名用户真正传的名字而非语义组主名,否则传 --page-size 却被
// 告知「不能与 --limit 同时使用」会让人无从下手。
// max 手写保留:它由 drive 单独注册,不在 crossProductAliases 表内。
func driveListLimitConflict(cmd *cobra.Command) string {
return driveListFirstProvidedFlag(cmd, append([]string{"limit", "max"}, crossProductAliasPeers("limit")...))
}
func driveListCursorConflict(cmd *cobra.Command) string {
return driveListFirstProvidedFlag(cmd, append([]string{"cursor"}, crossProductAliasPeers("cursor")...))
}
func driveListFirstProvidedFlag(cmd *cobra.Command, names []string) string {
for _, name := range names {
if driveListFlagProvided(cmd, name) {
return name
}
}
return ""
}
// driveListFlagProvided 判定用户是否真的传了该 flag:string 型按值判定(空串视为未传,
// 兼容 Agent 的 --cursor "$CURSOR" 首页空值模板),其余类型按 Changed 判定
// (--limit 是 int,显式传入默认值 20 也算冲突)。这两条规则与改造前
// flagOrFallback / Changed 的既有行为逐字等价,只是覆盖面扩到了全部别名。
func driveListFlagProvided(cmd *cobra.Command, name string) bool {
flag := cmd.Flags().Lookup(name)
if flag == nil {
return false
}
if flag.Value.Type() == "string" {
return flag.Value.String() != ""
}
return flag.Changed
}
func driveDepthExclusiveError(flag, reason string) error {
return &CLIError{
Code: CodeInvalidParam,
@@ -98,6 +140,36 @@ func driveDepthExclusiveError(flag, reason string) error {
}
}
// validateDriveListLatest --latest 边界与互斥校验;互斥报错附等效写法引导,
// 便于用户/Agent 改用 --order-by 原语翻「更早的」。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if name := driveListLimitConflict(cmd); name != "" {
return driveLatestExclusiveError(name, latest)
}
if name := driveListCursorConflict(cmd); name != "" {
return driveLatestExclusiveError(name, latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
// 路由绑定差异全部收敛在此,BFS 主循环保持路由无关。
type driveDepthRoute struct {
serverID string // ""=钉盘自动路由 / "doc"=知识库
@@ -178,7 +250,7 @@ func newDocDepthRoute() driveDepthRoute {
}
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
if deps.Caller.DryRun() {
return printDriveDepthDryRun(route, baseArgs, maxDepth)
}
@@ -209,7 +281,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
bfs:
for len(queue) > 0 {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
folder := queue[0]
queue = queue[1:]
@@ -220,7 +292,7 @@ bfs:
pages := 0
for {
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
pages++
if pages > maxPagesPerFolder {
@@ -233,7 +305,7 @@ bfs:
args := route.buildArgs(baseArgs, folder.id, pageToken)
text, err := route.fetchPage(ctx, args)
if ctx.Err() != nil {
return emitDriveDepthCancelled(collected, errs, pattern)
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
}
if err != nil {
folderErr = err
@@ -249,6 +321,13 @@ bfs:
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
if latest > 0 {
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为内部 sortTime
// (毫秒 int64,无时间字段填 0 排末尾),仅供 --latest 后置处理器跨路由
// 排序;emit 前一律删除,不进输出契约。
ms, _ := driveModifiedMillis(item)
item["sortTime"] = ms
}
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
@@ -289,9 +368,14 @@ bfs:
if folderErr != nil {
if driveDepthUnrecoverable(folderErr) {
if latest > 0 {
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
return folderErr
}
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
return emitErr
}
return folderErr
@@ -324,18 +408,57 @@ bfs:
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern)
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
// SIGINT 部分结果路径不在此拦截(退出码 130 已显式标记不完整)。
if latest > 0 && (truncated || len(errs) > 0) {
return driveLatestIncompleteError(latest, truncated, errs)
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
// driveLatestIncompleteError 排序基不完整时的拒绝产出错误。截断与目录失败共用
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」。
// 拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,否则用户完全瞎。
// Suggestion 的每个子句把可照抄的命令写在句尾,且必须与该子句正文一致——让用户
// 「去掉 --latest」的子句若示例仍带 --latest,照抄就复现同一个错误。
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError) error {
// len(errs)==0 只可能来自截断分支:调用点已保证二者至少一真。
if truncated || len(errs) == 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
first := errs[0]
folder := first.FolderName
if folder == "" {
folder = first.FolderID
}
if folder == "" {
folder = "<root>"
}
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s),未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
len(errs), folder, first.Depth, first.Reason, first.Message, latest),
// partial + errors[] 的承诺限定在 --depth>1:单层(含知识库 maxDepth=1)去掉 --latest
// 会路由回普通单层 list,本就没有 errors[] 契约,无条件承诺即是另一种不准确。
Suggestion: fmt.Sprintf("确认目录权限后重试;或用 --folder 缩小到可读子目录后重取 Top-%d:dws drive list --folder <可读子目录ID> --latest %d;需要看失败明细请去掉 --latest 按原范围重跑(--depth>1 时同时输出已扫到的 partial 与 errors[] 明细):dws drive list --folder <目录ID> --depth <原层数>", latest, latest),
}
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
return err
}
return &driveDepthCancelledError{}
}
// depth>1 不输出 nextToken。
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
if pattern != "" {
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
filtered := make([]map[string]any, 0, len(items))
@@ -350,21 +473,35 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
}
items = filtered
}
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
if latest > 0 {
// latest 排列按 sortTime desc(第 1 条最新);rel_path 树序仅在不带 latest 时保持。
items = applyDriveListLatest(items, latest)
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
sort.SliceStable(items, func(i, j int) bool {
ri, _ := items[i]["rel_path"].(string)
rj, _ := items[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
})
}
maxDepth := 0
for _, item := range items {
if d, ok := item["depth"].(int); ok && d > maxDepth {
maxDepth = d
}
}
// sortTime 是内部排序字段(applyDriveListLatest 已用完),任何输出路径都不得泄露;
// depth / parentId / rel_path 按 depth>1 的既有契约保留。
for _, item := range items {
delete(item, "sortTime")
}
if latest > 0 && reqDepth == 1 {
// 知识库单层 latest:输出与普通单层 list 对齐,剥 BFS 装饰字段(maxDepth 已先统计)。
stripDriveDepthDecorations(items)
}
if items == nil {
items = []map[string]any{}
}
@@ -492,6 +629,128 @@ func driveDepthItemID(item map[string]any) string {
return ""
}
// applyDriveListLatest --latest 后置处理器(知识库单层 / 钉盘与知识库 depth>1 三处共享):
// 仅文件参与 Top-N(文件夹只用于下钻与组树)→ sortTime desc 排序(tie-break:
// rel_path → fileId/nodeId,保证输出完全确定、CI 断言稳定)→ 截断 N。
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
// 单层 latest 输出与普通 list 对齐:剥掉 BFS 装饰字段。
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
// runDriveListLatest 钉盘单层 --latest 扫描:服务端按 modifyTime desc 排序,客户端
// 逐页剔除文件夹 + pattern 过滤,凑够 N / 扫满 driveLatestScanMax / 翻到底即停。
// 知识库路由无服务端排序须拉全量,走 BFS maxDepth=1 + 后置处理器,不走本函数。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": fmt.Sprintf("dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 %d 条", driveLatestScanMax),
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
// 兜底服务端「空页 + 非空游标」异常,页数上限与扫描上限同源。
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
// hasMore 异常形态(hasMore=true 但 token 空)静默停:服务端有序,截断只影响
// 「凑不满 N」路径,已有 stderr 提示兜底,不复制 BFS 的 pagination anomaly 报错。
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
// 凑不满 N 不是失败(退出码 0);提示附可直接执行的后续命令,便于 Agent 自主继续。
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
func driveDepthUnrecoverable(err error) bool {
var cliErr *CLIError
if errors.As(err, &cliErr) {
+31 -18
View File
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 3); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 3); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 3); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 3); err == nil {
t.Fatal("failing writer returned nil")
}
}
@@ -378,11 +378,24 @@ func useDriveDepthArgs(t *testing.T) {
t.Cleanup(func() { os.Args = old })
}
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
// runDepthBFSRaw 返回原始 stdout 缓冲,供「必须无输出」的用例断言;
// 需要解析 JSON 的用例改用下面两个包装。
func runDepthBFSRaw(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string, latest int) (*bytes.Buffer, error) {
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, latest)
return out, err
}
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
t.Helper()
return runDepthBFSLatest(t, caller, route, root, maxDepth, pattern, 0)
}
func runDepthBFSLatest(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string, latest int) (map[string]any, error) {
t.Helper()
out, err := runDepthBFSRaw(t, caller, route, root, maxDepth, pattern, latest)
return decodeDepthResult(t, out), err
}
@@ -390,7 +403,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
@@ -413,7 +426,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
t.Fatal(err)
}
if caller.calls != 2 {
@@ -548,7 +561,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
deps.Out.w = out
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 3 {
@@ -593,7 +606,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil {
t.Fatal("root failure returned nil")
}
@@ -610,7 +623,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil {
t.Fatal("unrecoverable returned nil")
}
@@ -635,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -648,7 +661,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
}}
installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
t.Fatalf("err = %v, want pagination anomaly", err)
}
@@ -690,7 +703,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -710,7 +723,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
cancel()
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -724,7 +737,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
installDepthCaller(t, caller)
deps.Out.w = failingWriter{}
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err = %v, want emit failure", err)
}
@@ -772,7 +785,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
@@ -801,7 +814,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
deps.Out.errW = io.Discard
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(ctx)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
var cancelErr *driveDepthCancelledError
if !errors.As(err, &cancelErr) {
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
+620
View File
@@ -0,0 +1,620 @@
package helpers
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
)
func driveLatestTestCmd() *cobra.Command {
cmd := driveDepthTestCmd()
cmd.Flags().String("order-by", "", "")
cmd.Flags().String("order", "", "")
return cmd
}
func useDriveLatestArgs(t *testing.T) {
t.Helper()
old := os.Args
os.Args = []string{"dws", "drive", "list", "--latest", "5"}
t.Cleanup(func() { os.Args = old })
}
// captureDriveLatestStderr 抓取直写 os.Stderr 的进度/提示行(不经 deps.Out.errW)。
func captureDriveLatestStderr(t *testing.T, run func()) string {
t.Helper()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("create stderr pipe: %v", err)
}
previous := os.Stderr
os.Stderr = writer
defer func() {
os.Stderr = previous
_ = reader.Close()
}()
run()
os.Stderr = previous
if err := writer.Close(); err != nil {
t.Fatalf("close stderr writer: %v", err)
}
output, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read stderr: %v", err)
}
return string(output)
}
// drivePanPage 造一页钉盘响应:首条为文件夹(供 BFS 下钻),其余为文件。
func drivePanPage(prefix string, files int, nextToken string) string {
entries := []string{fmt.Sprintf(`{"fileId":%q,"name":"%s-dir","type":"FOLDER"}`, prefix+"-dir", prefix)}
for i := 0; i < files; i++ {
entries = append(entries, fmt.Sprintf(
`{"fileId":"%s-f%d","name":"%s-f%d.txt","type":"FILE","modifyTime":%d}`,
prefix, i, prefix, i, 1700000000000+i))
}
body := fmt.Sprintf(`{"items":[%s]`, strings.Join(entries, ","))
if nextToken != "" {
body += fmt.Sprintf(`,"nextToken":%q`, nextToken)
}
return body + "}"
}
func docPage(prefix string, nodes int, nextToken string) string {
entries := make([]string, 0, nodes)
for i := 0; i < nodes; i++ {
entries = append(entries, fmt.Sprintf(
`{"nodeId":"%s-n%d","name":"%s-n%d","nodeType":"doc","updateTime":%d}`,
prefix, i, prefix, i, 1700000000000+i))
}
body := fmt.Sprintf(`{"nodes":[%s],"hasMore":true`, strings.Join(entries, ","))
if nextToken != "" {
body += fmt.Sprintf(`,"nextPageToken":%q`, nextToken)
}
return body + "}"
}
func TestCrossPlatformCoverageValidateDriveListLatest(t *testing.T) {
if err := validateDriveListLatest(driveLatestTestCmd(), 0); err == nil {
t.Fatal("latest 0 returned nil")
}
if err := validateDriveListLatest(driveLatestTestCmd(), driveLatestMax+1); err == nil {
t.Fatal("latest above max returned nil")
}
if err := validateDriveListLatest(driveLatestTestCmd(), 1); err != nil {
t.Fatalf("latest 1 returned %v", err)
}
if err := validateDriveListLatest(driveLatestTestCmd(), driveLatestMax); err != nil {
t.Fatalf("latest max returned %v", err)
}
for _, tc := range []struct{ flag, value string }{
{"order-by", "name"},
{"order", "asc"},
{"limit", "5"},
{"max", "5"},
{"cursor", "c1"},
{"next-token", "t1"},
} {
cmd := driveLatestTestCmd()
if err := cmd.Flags().Set(tc.flag, tc.value); err != nil {
t.Fatalf("set --%s: %v", tc.flag, err)
}
err := validateDriveListLatest(cmd, 3)
if err == nil {
t.Fatalf("--latest with --%s returned nil", tc.flag)
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeInvalidParam {
t.Fatalf("--%s error = %#v", tc.flag, err)
}
// 互斥报错必须给出等效改写,否则 Agent 只能盲试。
if !strings.Contains(cliErr.Message, "--order-by modifyTime --order desc --limit 3") {
t.Fatalf("--%s message lacks rewrite hint: %s", tc.flag, cliErr.Message)
}
}
}
func TestCrossPlatformCoverageApplyDriveListLatest(t *testing.T) {
items := []map[string]any{
{"fileId": "dir", "name": "dir", "type": "FOLDER", "sortTime": int64(9999)},
{"nodeId": "docdir", "name": "docdir", "nodeType": "folder", "sortTime": int64(9999)},
{"fileId": "old", "rel_path": "a/old.txt", "sortTime": int64(100)},
{"fileId": "new", "rel_path": "a/new.txt", "sortTime": int64(300)},
{"fileId": "mid", "rel_path": "a/mid.txt", "sortTime": int64(200)},
{"fileId": "notime", "rel_path": "a/notime.txt", "sortTime": int64(0)},
}
got := applyDriveListLatest(items, 2)
if len(got) != 2 {
t.Fatalf("truncate to 2 = %#v", got)
}
if got[0]["fileId"] != "new" || got[1]["fileId"] != "mid" {
t.Fatalf("sortTime desc order = %#v", got)
}
all := applyDriveListLatest(items, 10)
if len(all) != 4 {
t.Fatalf("folders should be dropped: %#v", all)
}
if all[3]["fileId"] != "notime" {
t.Fatalf("missing timestamp should sort last: %#v", all)
}
}
func TestCrossPlatformCoverageApplyDriveListLatestTieBreak(t *testing.T) {
same := []map[string]any{
{"fileId": "z9", "rel_path": "b/dup.txt", "sortTime": int64(500)},
{"fileId": "a1", "rel_path": "b/dup.txt", "sortTime": int64(500)},
{"fileId": "m5", "rel_path": "a/dup.txt", "sortTime": int64(500)},
}
got := applyDriveListLatest(same, 3)
// 同 sortTime → rel_path 升序 → id 升序,输出完全确定。
if got[0]["fileId"] != "m5" || got[1]["fileId"] != "a1" || got[2]["fileId"] != "z9" {
t.Fatalf("tie-break order = %#v", got)
}
}
func TestCrossPlatformCoverageStripDriveDepthDecorations(t *testing.T) {
items := []map[string]any{
{"fileId": "f1", "depth": 1, "parentId": "p", "rel_path": "a", "sortTime": int64(1), "name": "keep"},
}
stripDriveDepthDecorations(items)
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
if _, ok := items[0][key]; ok {
t.Fatalf("%s not stripped: %#v", key, items[0])
}
}
if items[0]["name"] != "keep" || items[0]["fileId"] != "f1" {
t.Fatalf("business fields lost: %#v", items[0])
}
}
// assertDriveLatestSuggestion 逐子句校验拒绝产出建议的自洽性:约定每个「;」子句把
// 可照抄的命令写在句尾,于是「让用户去掉 --latest」的子句里,命令本身就不能再带 --latest
// ——否则用户照抄示例必然复现同一个错误。只断言 Suggestion != "" 盖不住这种矛盾。
func assertDriveLatestSuggestion(t *testing.T, suggestion string, latest int) {
t.Helper()
if suggestion == "" {
t.Fatal("latest refusal must carry a suggestion")
}
sawLatestExample := false
for _, clause := range strings.Split(suggestion, ";") {
idx := strings.Index(clause, "dws ")
if idx < 0 {
continue
}
prose, command := clause[:idx], clause[idx:]
if !strings.HasPrefix(command, "dws drive list") {
t.Fatalf("clause command must be a dws drive list invocation: %q", command)
}
keepsLatest := strings.Contains(command, "--latest")
if strings.Contains(command, fmt.Sprintf("--latest %d", latest)) {
sawLatestExample = true
}
if strings.Contains(prose, "去掉 --latest") && keepsLatest {
t.Fatalf("clause tells the user to drop --latest but its command keeps it: %q", clause)
}
}
// 缩小范围后仍能取 Top-N 是首选出路,不能只剩「去掉 --latest」这条退路。
if !sawLatestExample {
t.Fatalf("suggestion lost the --latest %d retry example: %q", latest, suggestion)
}
}
func TestCrossPlatformCoverageDriveLatestTruncatedRefusesTopN(t *testing.T) {
useDriveLatestArgs(t)
// 每页 50 条(1 文件夹 + 49 文件)且始终有 nextToken:40 页打满全局 2000 上限。
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: drivePanPage("p", driveDepthPageSize-1, "next")},
}}
out, err := runDepthBFSRaw(t, caller, newDrivePanDepthRoute(), "", 3, "", 5)
if err == nil {
t.Fatal("truncated latest returned nil error")
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("message = %q", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
// 拒绝即不产出:截断集上的 Top-N 不是全局最新,stdout 必须为空。
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
if caller.calls != driveDepthMaxItems/driveDepthPageSize {
t.Fatalf("calls = %d, want %d", caller.calls, driveDepthMaxItems/driveDepthPageSize)
}
}
func TestCrossPlatformCoverageDocLatestTruncatedRefusesTopN(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: docPage("d", docDepthPageSize, "next")},
}}
out, err := runDepthBFSRaw(t, caller, newDocDepthRoute(), "root1", 1, "", 5)
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
}
func TestCrossPlatformCoverageDriveDepthTruncatedWithoutLatestKeepsPartial(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: drivePanPage("p", driveDepthPageSize-1, "next")},
}}
// 回归护栏:不带 latest 时截断仍以 truncated:true 输出满额部分结果。
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if result["truncated"] != true {
t.Fatalf("truncated = %#v", result["truncated"])
}
if items := result["items"].([]any); len(items) != driveDepthMaxItems {
t.Fatalf("items = %d, want %d", len(items), driveDepthMaxItems)
}
}
func TestCrossPlatformCoverageDocLatestSingleLayerStripsDecorations(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"nDir","name":"folder","nodeType":"folder","updateTime":1700000009000},
{"nodeId":"nOld","name":"old.doc","nodeType":"doc","updateTime":1700000001000},
{"nodeId":"nNew","name":"new.doc","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
result, err := runDepthBFSLatest(t, caller, newDocDepthRoute(), "root1", 1, "", 2)
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 2 {
t.Fatalf("folders should be excluded from Top-N: %#v", items)
}
first := items[0].(map[string]any)
if first["nodeId"] != "nNew" {
t.Fatalf("newest first = %#v", first)
}
// 单层输出与普通 list 对齐:不带 BFS 装饰字段。
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
if _, ok := first[key]; ok {
t.Fatalf("%s leaked into single-layer output: %#v", key, first)
}
}
}
func TestCrossPlatformCoverageDocLatestMultiLayerKeepsDecorations(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"nDir","name":"folder","nodeType":"folder","updateTime":1700000009000}],"hasMore":false}`},
{text: `{"nodes":[{"nodeId":"nInner","name":"inner.doc","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
result, err := runDepthBFSLatest(t, caller, newDocDepthRoute(), "root1", 2, "", 3)
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 1 {
t.Fatalf("items = %#v", items)
}
// depth>1 时保留 rel_path,用户需要知道命中文件在哪一层。
if items[0].(map[string]any)["rel_path"] != "folder/inner.doc" {
t.Fatalf("rel_path = %#v", items[0])
}
// sortTime 是内部排序字段,depth>1 保留装饰字段也不得把它带出去。
if _, ok := items[0].(map[string]any)["sortTime"]; ok {
t.Fatalf("sortTime leaked into multi-layer output: %#v", items[0])
}
}
// 不带 latest 的普通递归:装饰字段照旧输出,内部 sortTime 一个都不能漏。
func TestCrossPlatformCoverageDriveDepthNeverLeaksSortTime(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 2, "")
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 1 {
t.Fatalf("items = %#v", items)
}
first := items[0].(map[string]any)
if _, ok := first["sortTime"]; ok {
t.Fatalf("sortTime leaked into plain depth output: %#v", first)
}
// depth/parentId/rel_path 是 depth>1 的公开契约,不能被一起删掉。
for _, key := range []string{"depth", "parentId", "rel_path"} {
if _, ok := first[key]; !ok {
t.Fatalf("%s missing from depth output: %#v", key, first)
}
}
if first["modifyTime"] == nil || first["name"] != "a.txt" {
t.Fatalf("business fields lost: %#v", first)
}
}
// 递归途中目录读失败 → 排序基不完整,与截断同一条防线:非零退出 + 不产出 Top-N。
func TestCrossPlatformCoverageDriveLatestFolderFailureRefusesTopN(t *testing.T) {
useDriveLatestArgs(t)
out, err := runDepthBFSRaw(t, driveLatestFolderFailureCaller(), newDrivePanDepthRoute(), "", 3, "", 5)
if err == nil {
t.Fatal("incomplete scan returned nil error")
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("message = %q", cliErr.Message)
}
// errors[] 不再进 stdout,失败详情必须落在消息里,否则用户完全瞎。
for _, want := range []string{"dirA", "permission_denied", "denied"} {
if !strings.Contains(cliErr.Message, want) {
t.Fatalf("message lacks %q: %s", want, cliErr.Message)
}
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
}
// 根目录读取失败的 folder 名回落:根入队时不带 name(driveDepthFolder{id, depth:0}),
// 拒绝产出的消息若直接用 folderName 就成了 "folder=" 空洞提示。未指定 --folder 时
// id 也为空,须一路回落到 <root>。
func TestCrossPlatformCoverageDriveLatestRootPageFailureNamesRoot(t *testing.T) {
useDriveLatestArgs(t)
_, err := runDepthBFSRaw(t, driveLatestRootPageFailureCaller(), newDrivePanDepthRoute(), "", 2, "", 5)
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "folder=<root>") {
t.Fatalf("message should name the root folder: %s", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
}
// 指定了 --folder:folderName 仍为空,回落到 folderId 而不是 <root>,
// 否则用户无法知道是哪个目录读不到。
func TestCrossPlatformCoverageDriveLatestRootPageFailureFallsBackToFolderID(t *testing.T) {
useDriveLatestArgs(t)
_, err := runDepthBFSRaw(t, driveLatestRootPageFailureCaller(), newDrivePanDepthRoute(), "rootFolder", 2, "", 5)
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "folder=rootFolder") {
t.Fatalf("message should fall back to the folder id: %s", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
}
// 护栏:同一场景不带 latest 时行为不变(partial + errors[] + 退出码 0)。
func TestCrossPlatformCoverageDriveDepthFolderFailureWithoutLatestKeepsPartial(t *testing.T) {
useDriveDepthArgs(t)
result, err := runDepthBFSLatest(t, driveLatestFolderFailureCaller(), newDrivePanDepthRoute(), "", 3, "", 0)
if err != nil {
t.Fatal(err)
}
if items := result["items"].([]any); len(items) != 2 {
t.Fatalf("partial items = %#v", items)
}
errs := result["errors"].([]any)
if len(errs) != 1 || errs[0].(map[string]any)["folderId"] != "fA" {
t.Fatalf("errors = %#v", errs)
}
}
// 不可恢复错误(auth/网络)+ latest:不吐 partial,直接回根因错误码。
func TestCrossPlatformCoverageDriveLatestUnrecoverableRefusesTopN(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fA","name":"dirA","type":"FOLDER"},
{"fileId":"fX","name":"x.txt","type":"FILE","modifyTime":1700000001000}]}`},
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
out, err := runDepthBFSRaw(t, caller, newDrivePanDepthRoute(), "", 3, "", 5)
// 根因(token 过期)比通用截断 token 更可操作,原样上抛。
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeAuthTokenExpired {
t.Fatalf("error = %T %v", err, err)
}
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
// 对应的 latest=0 partial 护栏见 TestCrossPlatformCoverageRunDriveListDepthUnrecoverable。
}
// 根目录成功(1 文件夹 + 1 文件),子目录 403:可恢复失败进 errors[]。
func driveLatestFolderFailureCaller() *scriptedToolCaller {
return &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fA","name":"dirA","type":"FOLDER","modifyTime":1700000009000},
{"fileId":"fX","name":"x.txt","type":"FILE","modifyTime":1700000001000}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
}
// 根目录首页有收获、第 2 页 403:depth==0 不触发「零收获直返」,失败落进 errors[],
// 于是 latest 下由 driveLatestIncompleteError 报告一个 folderName 为空的失败目录。
func driveLatestRootPageFailureCaller() *scriptedToolCaller {
return &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE","modifyTime":1700000001000}],"nextToken":"page2"}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
}
func TestCrossPlatformCoverageRunDriveListLatestDryRun(t *testing.T) {
out := installDepthCaller(t, &scriptedToolCaller{format: "json", dry: true})
cmd := driveLatestTestCmd()
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s1"}, "folder1", 3, "", true); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output: %v\n%s", err, out.String())
}
if payload["tool"] != "list_files" || payload["latest"] != float64(3) {
t.Fatalf("payload = %#v", payload)
}
args := payload["args"].(map[string]any)
if args["orderBy"] != "modifyTime" || args["order"] != "desc" ||
args["spaceId"] != "s1" || args["parentId"] != "folder1" {
t.Fatalf("dry-run args = %#v", args)
}
}
func TestCrossPlatformCoverageRunDriveListLatestPanSingleLayer(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fDir","name":"日报归档","type":"FOLDER","modifyTime":1700000009000},
{"fileId":"f1","name":"日报-0801.docx","type":"FILE","modifyTime":1700000005000},
{"fileId":"f2","name":"周报-0801.docx","type":"FILE","modifyTime":1700000004000},
{"fileId":"f3","name":"日报-0731.docx","type":"FILE","modifyTime":1700000003000}],"nextToken":"more"}`},
{text: `{"items":[{"fileId":"f4","name":"日报-0730.docx","type":"FILE","modifyTime":1700000002000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
if err := runDriveListLatest(cmd, map[string]any{}, "", 2, "*日报*", true); err != nil {
t.Fatal(err)
}
// 服务端已按 modifyTime desc 返回,首页凑够 2 条即停,不该翻第二页。
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
items := decodeDepthResult(t, out)["items"].([]any)
if len(items) != 2 {
t.Fatalf("items = %#v", items)
}
if items[0].(map[string]any)["fileId"] != "f1" || items[1].(map[string]any)["fileId"] != "f3" {
t.Fatalf("folder/pattern filtering failed: %#v", items)
}
}
// 上游对文件名字段有 name / fileName 两种形态,pattern 匹配必须两种都认。
func TestCrossPlatformCoverageRunDriveListLatestFallsBackToFileName(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"f1","fileName":"日报-0801.docx","type":"FILE","modifyTime":1700000005000},
{"fileId":"f2","fileName":"周报-0801.docx","type":"FILE","modifyTime":1700000004000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
if err := runDriveListLatest(cmd, map[string]any{}, "", 5, "*日报*", true); err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, out)["items"].([]any)
// 只命中 f1 才说明 pattern 匹配用的是 fileName 回落值,而非空串。
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
t.Fatalf("fileName fallback not used for pattern match: %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestPagesUntilFilled(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fDir","name":"dir","type":"FOLDER"}],"nextToken":"p2"}`},
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
// quiet=false 覆盖翻页进度输出分支。
stderr := captureDriveLatestStderr(t, func() {
if err := runDriveListLatest(cmd, map[string]any{}, "", 1, "", false); err != nil {
t.Fatal(err)
}
})
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2", caller.calls)
}
if !strings.Contains(stderr, "latest 扫描中") {
t.Fatalf("progress line missing: %q", stderr)
}
if items := decodeDepthResult(t, out)["items"].([]any); len(items) != 1 {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestShortfall(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
var runErr error
stderr := captureDriveLatestStderr(t, func() {
runErr = runDriveListLatest(cmd, map[string]any{}, "", 5, "*.txt", true)
})
// 凑不满 N 不是失败:照常输出已找到的部分,提示走 stderr。
if runErr != nil {
t.Fatalf("shortfall returned error: %v", runErr)
}
if !strings.Contains(stderr, "找到 1/5 条") || !strings.Contains(stderr, `--pattern "*.txt"`) {
t.Fatalf("shortfall hint = %q", stderr)
}
if items := decodeDepthResult(t, out)["items"].([]any); len(items) != 1 {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestStopsAtScanCap(t *testing.T) {
useDriveLatestArgs(t)
// 每页 50 条全是文件夹:永远凑不够,靠 driveLatestScanMax 兜底停机。
folders := make([]string, 0, driveDepthPageSize)
for i := 0; i < driveDepthPageSize; i++ {
folders = append(folders, fmt.Sprintf(`{"fileId":"d%d","name":"d%d","type":"FOLDER"}`, i, i))
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[%s],"nextToken":"loop"}`, strings.Join(folders, ","))},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
stderr := captureDriveLatestStderr(t, func() {
if err := runDriveListLatest(cmd, map[string]any{}, "", 3, "", true); err != nil {
t.Fatal(err)
}
})
if caller.calls != driveLatestScanMax/driveDepthPageSize {
t.Fatalf("calls = %d, want %d", caller.calls, driveLatestScanMax/driveDepthPageSize)
}
if !strings.Contains(stderr, fmt.Sprintf("已扫描 %d 条", driveLatestScanMax)) {
t.Fatalf("scan cap hint = %q", stderr)
}
if items := decodeDepthResult(t, out)["items"].([]any); len(items) != 0 {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestFetchError(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
err := runDriveListLatest(cmd, map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("fetch error returned nil")
}
if !strings.Contains(err.Error(), "latest 扫描第 1 页失败") {
t.Fatalf("error = %v", err)
}
}
@@ -0,0 +1,209 @@
package helpers
import (
"strings"
"testing"
)
// 命令层路由:--latest 的校验时机、三条落点(钉盘单层 / 钉盘 BFS / 知识库 BFS)。
// 后置处理器与扫描循环的行为断言在 drive_latest_test.go。
func TestCrossPlatformCoverageDriveListLatestRejectedWithVersions(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--latest", "3")
if err == nil || !strings.Contains(err.Error(), "--versions 与 --latest 不能同时使用") {
t.Fatalf("err = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
// versions 合法使用 --limit:--latest 的互斥校验不得在此路径上误伤。
func TestCrossPlatformCoverageDriveListVersionsWithLimitStillWorks(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--limit", "5")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_file_versions" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListLatestRejectsOutOfRange(t *testing.T) {
for _, value := range []string{"0", "51"} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, "list", "--latest", value)
if err == nil || !strings.Contains(err.Error(), "--latest 必须为 1~50 的整数") {
t.Fatalf("--latest %s err = %v", value, err)
}
if len(caller.calls) != 0 {
t.Fatalf("--latest %s calls = %#v", value, caller.calls)
}
}
}
func TestCrossPlatformCoverageDriveListLatestRejectsExclusiveFlags(t *testing.T) {
// 用真实 newDriveCommand 构造:RegisterCrossProductAliases 自动注册的隐藏别名
// (--page-size / --page-token / --next-token)必须同样被互斥校验拦住。
for _, extra := range [][]string{
{"--limit", "5"},
{"--max", "5"},
{"--page-size", "5"},
{"--order-by", "name"},
{"--order", "asc"},
{"--cursor", "c1"},
{"--page-token", "t1"},
{"--next-token", "t1"},
} {
caller := &guardedMutationCaller{}
args := append([]string{"list", "--latest", "3"}, extra...)
err := executeGuardedMutationCommand(t, caller, newDriveCommand, args...)
// 报错点名用户实际传的那个 flag,而不是语义组主名。
want := "--latest 不能与 " + extra[0] + " 同时使用"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("%v err = %v, want %q", extra, err, want)
}
if len(caller.calls) != 0 {
t.Fatalf("%v calls = %#v", extra, caller.calls)
}
}
}
// Agent 常用 --cursor "$CURSOR" 模板,首页展开为空串:空值不算「传了」,不得误伤。
func TestCrossPlatformCoverageDriveListLatestAllowsEmptyCursorTemplate(t *testing.T) {
useDriveLatestArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
err := executeDriveCommand(t, caller,
"list", "--latest", "1", "--cursor", "", "--page-token", "", "--next-token", "")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
}
// --depth 的分页互斥同样按语义组覆盖隐藏别名(此前 --page-size/--page-token 被静默忽略)。
func TestCrossPlatformCoverageDriveListDepthRejectsPaginationAliases(t *testing.T) {
for _, extra := range [][]string{
{"--limit", "10"},
{"--max", "10"},
{"--page-size", "10"},
{"--cursor", "c1"},
{"--page-token", "t1"},
{"--next-token", "t1"},
} {
caller := &guardedMutationCaller{}
args := append([]string{"list", "--depth", "2"}, extra...)
err := executeGuardedMutationCommand(t, caller, newDriveCommand, args...)
want := "--depth 不能与 " + extra[0] + " 同时使用"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("%v err = %v, want %q", extra, err, want)
}
if len(caller.calls) != 0 {
t.Fatalf("%v calls = %#v", extra, caller.calls)
}
}
}
func TestCrossPlatformCoverageDriveListLatestPanRoute(t *testing.T) {
useDriveLatestArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000002000},
{"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
err := executeDriveCommand(t, caller,
"list", "--latest", "2", "--space-id", "sp-1", "--thumbnail", "--folder", "root-folder")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
// 钉盘单层 latest 借服务端排序:orderBy/order 由 CLI 强制注入。
if args["orderBy"] != "modifyTime" || args["order"] != "desc" {
t.Fatalf("server-side ordering missing: %#v", args)
}
if args["spaceId"] != "sp-1" || args["withThumbnail"] != true ||
args["parentId"] != "root-folder" || args["maxResults"] != float64(driveDepthPageSize) {
t.Fatalf("pan latest args = %#v", args)
}
}
func TestCrossPlatformCoverageDriveListLatestPanRejectsNumericFolder(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--latest", "3", "--folder", "12345")
if err == nil {
t.Fatal("numeric drive folder returned nil")
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListLatestPanDepthRoutesBFS(t *testing.T) {
useDriveLatestArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000002000}]}`},
}}
err := executeDriveCommand(t, caller, "list", "--latest", "2", "--depth", "2")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
// depth>1 走 BFS,不注入服务端排序(多目录合并后由后置处理器统一排序)。
if _, ok := caller.calls[0]["orderBy"]; ok {
t.Fatalf("BFS route should not force orderBy: %#v", caller.calls[0])
}
}
func TestCrossPlatformCoverageDriveListLatestWorkspaceRoutesDocBFS(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"n1","name":"old.doc","nodeType":"doc","updateTime":1700000001000},
{"nodeId":"n2","name":"new.doc","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
err := executeDriveCommand(t, caller, "list", "--workspace", "ws-1", "--latest", "2")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["workspaceId"] != "ws-1" || args["pageSize"] != float64(docDepthPageSize) {
t.Fatalf("doc latest args = %#v", args)
}
}
// --pattern 在知识库单层原本被拒;latest 打开 BFS 路径后应放行(先递归后过滤)。
func TestCrossPlatformCoverageDriveListLatestWorkspaceAllowsPattern(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"n1","name":"日报-0801","nodeType":"doc","updateTime":1700000001000},
{"nodeId":"n2","name":"周报-0801","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
if err := executeDriveCommand(t, caller,
"list", "--workspace", "ws-1", "--latest", "2", "--pattern", "*日报*"); err != nil {
t.Fatal(err)
}
// 不带 latest 的知识库单层仍然拒绝 --pattern。
rejected := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, rejected, newDriveCommand,
"list", "--workspace", "ws-1", "--pattern", "*日报*")
if err == nil || !strings.Contains(err.Error(), "--pattern 仅适用于钉盘文件列表") {
t.Fatalf("err = %v", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package helpers
import (
"encoding/json"
"strconv"
"strings"
"time"
)
// ──────────────────────────────────────────────────────────
// 钉盘 / 知识库条目的修改时间归一化
//
// 两条路由的时间字段名与类型都不统一:钉盘返回 modifyTime,知识库返回 updateTime,
// 值可能是毫秒数字、数字字符串或 RFC3339 字符串。--latest 需要跨路由排序,
// 因此在此处收敛为统一的 Unix 毫秒。纯函数,不修改入参。
// ──────────────────────────────────────────────────────────
// 探测顺序即优先级:先精确字段名,再回落到各服务端的历史别名。
var driveModifiedTimeKeys = []string{
"modifiedTime",
"modifyTime",
"modified_time",
"gmtModified",
"lastModifiedTime",
"updateTime",
}
// driveModifiedMillis 按 driveModifiedTimeKeys 顺序探测条目的修改时间。
// 首个可解析出正毫秒值的字段胜出;全部缺失或不可解析时返回 (0, false)。
func driveModifiedMillis(item map[string]any) (int64, bool) {
for _, key := range driveModifiedTimeKeys {
v, ok := item[key]
if !ok {
continue
}
if ms, ok := driveToMillis(v); ok {
return ms, true
}
}
return 0, false
}
// driveToMillis 把时间字段归一化为 Unix 毫秒。支持毫秒时间戳(数字或数字字符串)
// 与 RFC3339 字符串;非正值与无法识别的形态一律返回 (0, false),由调用方决定降级策略。
func driveToMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
+75
View File
@@ -0,0 +1,75 @@
package helpers
import (
"encoding/json"
"testing"
"time"
)
func TestCrossPlatformCoverageDriveToMillis(t *testing.T) {
rfc := "2023-11-14T22:13:20Z"
want := func() int64 {
tm, err := time.Parse(time.RFC3339, rfc)
if err != nil {
t.Fatalf("parse fixture: %v", err)
}
return tm.UnixMilli()
}()
for _, tc := range []struct {
name string
in any
ms int64
valid bool
}{
{"float64", float64(1700000000000), 1700000000000, true},
{"float64 non-positive", float64(0), 0, false},
{"json.Number", json.Number("1700000000000"), 1700000000000, true},
{"json.Number non-numeric", json.Number("abc"), 0, false},
{"json.Number non-positive", json.Number("-1"), 0, false},
{"numeric string", "1700000000000", 1700000000000, true},
{"rfc3339 string", rfc, want, true},
{"blank string", " ", 0, false},
{"garbage string", "not-a-time", 0, false},
{"unsupported type", true, 0, false},
{"nil", nil, 0, false},
} {
ms, ok := driveToMillis(tc.in)
if ok != tc.valid || ms != tc.ms {
t.Fatalf("%s: driveToMillis(%#v) = (%d, %v), want (%d, %v)", tc.name, tc.in, ms, ok, tc.ms, tc.valid)
}
}
}
func TestCrossPlatformCoverageDriveModifiedMillis(t *testing.T) {
if _, ok := driveModifiedMillis(map[string]any{"name": "no time"}); ok {
t.Fatal("item without any time key reported a timestamp")
}
// 探测顺序即优先级:精确字段名胜出于历史别名。
priority := map[string]any{
"updateTime": float64(1),
"gmtModified": float64(2),
"modifyTime": float64(3),
"modifiedTime": float64(4),
}
if ms, ok := driveModifiedMillis(priority); !ok || ms != 4 {
t.Fatalf("priority = (%d, %v), want (4, true)", ms, ok)
}
// 靠前的键不可解析时继续回落,不能提前判定为无时间。
fallback := map[string]any{
"modifiedTime": "",
"modifyTime": float64(0),
"updateTime": float64(1700000000000),
}
if ms, ok := driveModifiedMillis(fallback); !ok || ms != 1700000000000 {
t.Fatalf("fallback = (%d, %v)", ms, ok)
}
for _, key := range driveModifiedTimeKeys {
if ms, ok := driveModifiedMillis(map[string]any{key: float64(1234)}); !ok || ms != 1234 {
t.Fatalf("key %s = (%d, %v)", key, ms, ok)
}
}
}
@@ -64,6 +64,19 @@
`doc list --workspace <WS_ID>` 或 `--folder <FOLDER_ID>`
### find-latest-file
「最近改的那份 XX」——范围已知时用 `--latest` 一步到位,不要拉全量自己比时间。
1. 框定范围:钉盘用 `--space-id` / `--folder`;知识库用 `--workspace <WS_ID>`
2. 取最新:`dws drive list --pattern "*日报*" --latest 1 --format json`
(知识库:`dws drive list --workspace <WS_ID> --latest 5 --format json`)
3. 结果按修改时间倒序,第 1 条即最新;文件夹不计入
4. 找不到足够条数时 CLI 仍返回 0,stderr 会提示放宽 `--pattern` 或换范围
5. 报 `LATEST_SCAN_TRUNCATED` 说明扫描触到 2000 条上限、Top-N 不可信:用 `--folder` 缩小范围或降低 `--depth` 重试
> 只记得名字、不知道在哪 → 改用 `drive search`;"我最近看过/改过的" → 改用 `drive recent`。
## #5 工作汇报
### query-report-list
+10 -2
View File
@@ -34,6 +34,7 @@
| "给张三发一条机器人单聊消息" | 机器人单聊 | `chat +messages-send --as bot --users` | `chat message send-by-bot --users` | 机器人批量单聊,先查 userId |
| "CPU 超过 90% 自动告警" | Webhook 告警 | `chat +messages-send --as webhook` | `chat message send-by-webhook` | 系统告警场景,需自定义 Webhook token |
| "帮我看看收到的日报" | 收到的日志 | `report` | `doc` | 钉钉日志系统(日报/周报),不是文档 |
| "钉盘里最新的日报是哪份" | 范围内按修改时间取最新 | `drive list --pattern "*日报*" --latest 1` | `report inbox list` | 出现"钉盘/知识库/文件"等载体词时,"日报"是文件名而非钉钉日志模版 |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "拉取一下上周项目群的聊天记录" | 拉取会话消息 | `chat +chat-messages` | `chat message list` | Shortcut 投影发言人/文本/时间并可处理资源 |
| "看看张三发给我的消息" | 按发送者查询消息 | `chat message list-by-sender` | `chat message list --user` | 用户未明确说"单聊"时优先用 list-by-sender(跨单聊/群聊) |
@@ -250,6 +251,9 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
- "列出钉盘文件" → `drive list`
- "列出知识库里的文件" → `drive list --workspace <id>` 或 `wiki node list --workspace <id>`
- "列出所有空间" → `wiki space list`
- "钉盘里最新的日报是哪份" → `drive list --pattern "*日报*" --latest 1`(范围已知 + 按修改时间取最新)
- "这个知识库最近改过的 5 个文档" → `drive list --workspace <id> --latest 5`
- "我最近看过/改过哪些文档" → `drive recent`(按个人访问记录跨空间,不是按范围取最新)
---
@@ -338,16 +342,20 @@ dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type
- "有什么日志模版" — 查看模版 (`report template list`)
- "看看这个日志的已读统计" — 阅读状态 (`report entry stats`)
- "我发过的日志有哪些" — 发件箱列表 (`report outbox list`)
- 用户提到"日报"、"周报"、"日志"
- 用户提到"日报"、"周报"、"日志",**且未指向具体文件载体**(没提钉盘/知识库/文件/文档/附件,也没给文件名后缀)
**用 `doc` 的场景**:
- "帮我写个项目总结文档" — 长文本创作(钉钉在线文档,非日志模版)
**用 `drive` 的场景**("日报"是文件名而非日志模版):
- "钉盘里最新的日报是哪份" / "知识库里最近改的那份周报" — `drive list --pattern "*日报*" --latest 1`
- 判别信号:出现"钉盘/知识库/文件夹/文件/附件/`.xlsx`/`.docx`"等载体词,或"最新/最近改的**那个文件**"
**用 `todo` 的场景**:
- "记一下这周要做的事" — 个人任务管理
- "创建一个待办提醒" — 仍归 `todo`:先创建待办,再用 `task add-reminder` 写入;需说明上游不支持读取 reminderRules,不能写后读回核验
**判断关键**:钉钉日志系统(日报/周报模版,含按模版创建汇报)→ `report`;文档/知识库长文→ `doc`;任务清单→ `todo`
**判断关键**:钉钉日志系统(日报/周报模版,含按模版创建汇报)→ `report`;作为**文件**存在的日报/周报(钉盘或知识库里的)→ `drive list --latest`;文档/知识库长文→ `doc`;任务清单→ `todo`
---
+30
View File
@@ -34,6 +34,9 @@ Usage:
Example:
dws drive list --limit 20
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
dws drive list --pattern "*日报*" --latest 3
dws drive list --workspace <workspaceId> --latest 5
dws drive list --depth 3 --pattern "*.xlsx"
Flags:
--limit int 每页返回数量,默认 20,最大 50 (可选)
--cursor string 分页游标,首次不传 (可选)
@@ -43,10 +46,37 @@ Flags:
--space-id string 钉盘空间 ID (纯数字),不传则使用「我的文件」对应 spaceId (可选)
--workspace string 文档空间/知识库 ID (加密 string 或 URL),传入则路由到文档空间 (可选)
--thumbnail 是否返回缩略图信息 (可选,仅钉盘)
--depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选)
--pattern string 按名称通配过滤结果,如 "*日报*" (客户端过滤) (可选)
--latest int 按修改时间取最新 N 个文件(1~50);与 --order-by/--order/--limit/--cursor 互斥 (可选)
--quiet 关闭递归/扫描进度输出(stderr),不影响 stdout JSON (可选)
```
> 统一入口:默认列钉盘空间(`--space-id` 纯数字);传 `--workspace` 时路由到文档空间/知识库列表。
> **`--depth` 递归语义**:`--depth N` 逐层 BFS 展开子目录,返回项附带 `depth`(层级,根级为 1)、
> `parentId`、`rel_path`(相对根的路径)三个字段,输出按 `rel_path` 树序排列。
> 全局最多返回 2000 条,触顶时返回 `truncated: true`;单个目录读取失败不中断整体,
> 失败记录进 `errors[]`(含 `folderId` / `reason` / `message`)。`--pattern` 是**先递归后过滤**:
> 过滤只作用于输出项,不阻止文件夹继续下钻。递归途中 Ctrl-C 会输出已收集的部分结果并以退出码 130 结束。
> **`--latest` 语义**:取「按修改时间排序后最新的 N 个**文件**」,文件夹不参与排序也不出现在结果里。
> 输出按修改时间倒序(第 1 条最新),不再保持 `rel_path` 树序。
> - 与 `--pattern` 组合 = 「名称匹配的文件中最新 N 个」,例如 `--pattern "*日报*" --latest 3`
> - 与 `--workspace` / `--depth` 均可组合;`--depth 1`(默认)时输出不带 `depth`/`rel_path` 装饰字段,与普通 `list` 一致
> - 与 `--order-by` / `--order` / `--limit` / `--cursor` **互斥**(含 `--page-size` / `--page-token` /
> `--next-token` / `--max` 等隐藏别名,报错点名实际传入的那个):Top-N 的排序语义由 `--latest` 独占。
> 需要自定义排序或翻更早的页时改用原语:`--order-by modifyTime --order desc --limit N`
> - 钉盘单层借服务端排序,凑够 N 条即停,最多扫描 1000 条;凑不满 N 不算失败(退出码 0),
> stderr 会给出缩小范围的建议
> - 递归扫描触到 2000 条全局上限(`LATEST_SCAN_TRUNCATED`)、或递归途中有目录读取失败
> (`LATEST_SCAN_INCOMPLETE`,失败明细在错误消息里)时**直接报错**而不是返回部分结果:
> 未扫描区域可能含更新的文件,此时的 Top-N 不是全局最新。按提示用 `--folder` 缩小范围或降低
> `--depth`;确实需要部分结果 + `errors[]` 明细时去掉 `--latest` 重跑
> **三者路由**:只记得名字关键词、不知在哪 → `drive search`;「我最近看过/改过的文档」(跨空间、按个人
> 访问记录) → `drive recent`;范围已知(某空间/文件夹/知识库)、要按修改时间取最新 → `drive list --latest`。
### 获取钉盘空间列表
> **Deprecated**:推荐改用 `dws wiki space list --type orgSpace` / `--type mySpace`(见 [wiki.md](./wiki.md))。本命令仍可用,仅作兼容保留。
+2 -1
View File
@@ -304,8 +304,9 @@ Flags:
用户说"有什么日志模版" → `template list` 或 `template get`
用户说"我发过的日志/我创建的日志" → `outbox list`
用户说"别人发给我的日志/我收到的日志" → `inbox list`
用户说"钉盘/知识库里最新的日报(文件)" → **不是 report**,走 `drive list --pattern "*日报*" --latest 1`
关键区分: report(钉钉日志模版汇报,含提交) vs doc(文档编辑) vs todo(待办任务)
关键区分: report(钉钉日志模版汇报,含提交) vs drive(作为文件存在的日报/周报,用 `drive list --latest`) vs doc(文档编辑) vs todo(待办任务)
## 核心工作流
@@ -30,6 +30,9 @@ Usage:
Example:
dws drive list --limit 20
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
dws drive list --pattern "*日报*" --latest 3
dws drive list --workspace <workspaceId> --latest 5
dws drive list --depth 3 --pattern "*.xlsx"
Flags:
--limit int 每页返回数量,默认 20,最大 50 (可选)
--cursor string 分页游标,首次不传 (可选)
@@ -37,9 +40,34 @@ Flags:
--order-by string 排序字段: createTime|modifyTime|name (可选)
--folder string 父节点 ID (dentryUuid),不传则列出空间根目录 (可选)
--space-id string 空间 ID,不传则使用「我的文件」对应 spaceId (可选)
--workspace string 知识库/文档空间 ID,传入则路由到知识库列表 (可选)
--thumbnail 是否返回缩略图信息 (可选)
--depth int 递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥 (可选)
--pattern string 按名称通配过滤结果,如 "*日报*" (客户端过滤) (可选)
--latest int 按修改时间取最新 N 个文件(1~50);与 --order-by/--order/--limit/--cursor 互斥 (可选)
--quiet 关闭递归/扫描进度输出(stderr),不影响 stdout JSON (可选)
```
> **`--depth` 递归语义**:逐层 BFS 展开子目录,返回项附带 `depth`(根级为 1)、`parentId`、
> `rel_path`(相对根的路径),输出按 `rel_path` 树序排列。全局最多 2000 条,触顶返回 `truncated: true`;
> 单个目录读取失败不中断整体,失败记录进 `errors[]`。`--pattern` 是**先递归后过滤**:只过滤输出项,
> 不阻止文件夹继续下钻。递归途中 Ctrl-C 输出已收集的部分结果,退出码 130。
> **`--latest` 语义**:取「按修改时间排序后最新的 N 个**文件**」,文件夹不参与排序也不出现在结果里,
> 输出按修改时间倒序(第 1 条最新)。
> - 与 `--pattern` 组合 = 「名称匹配的文件中最新 N 个」,如 `--pattern "*日报*" --latest 3`
> - 与 `--workspace` / `--depth` 均可组合;`--depth 1`(默认)时输出不带 `depth`/`rel_path` 装饰字段
> - 与 `--order-by` / `--order` / `--limit` / `--cursor` **互斥**(含 `--page-size` / `--page-token` /
> `--next-token` / `--max` 等隐藏别名);需要自定义排序或翻更早的页时改用原语
> `--order-by modifyTime --order desc --limit N`
> - 钉盘单层借服务端排序,凑够 N 条即停,最多扫描 1000 条;凑不满 N 不算失败(退出码 0),stderr 给出建议
> - 递归扫描触到 2000 条上限(`LATEST_SCAN_TRUNCATED`)、或递归途中目录读取失败
> (`LATEST_SCAN_INCOMPLETE`)时**直接报错**而不返回部分结果:未扫描区域可能含更新的文件,此时的
> Top-N 不是全局最新。按提示用 `--folder` 缩小范围或降低 `--depth`;需要部分结果时去掉 `--latest` 重跑
> **三者路由**:只记得名字关键词、不知在哪 → `drive search`;「我最近看过/改过的文档」(跨空间、按个人
> 访问记录) → `drive recent`;范围已知(某空间/文件夹/知识库)、要按修改时间取最新 → `drive list --latest`。
### 获取钉盘空间列表
```
@@ -14,4 +14,7 @@
| "列出钉盘团队空间" | 列出钉盘空间 | `wiki space list --type orgSpace` | `drive list-spaces` | 空间管理归 wiki,drive list-spaces 已 deprecated |
| "在知识库里搜方案" | 空间内搜索 | `wiki node search --workspace` | `drive search` | 指定了空间上下文 → wiki node search |
| "搜一下有没有叫XX的文件" | 全局搜索 | `drive search` | `wiki node search` | 未指定空间 → drive search 全局聚合搜索 |
| "钉盘里最新的日报是哪份" | 范围内按修改时间取最新 | `drive list --pattern "*日报*" --latest 1` | `report inbox list` | 出现"钉盘/知识库/文件"等载体词时,"日报"是文件名而非钉钉日志模版 |
| "这个知识库最近改过的 5 个文档" | 知识库按修改时间取最新 | `drive list --workspace <id> --latest 5` | `drive list --limit 50` 后自行排序 | 知识库无服务端排序,`--latest` 已内建全量扫描 + Top-N,自行排序会漏掉未翻到的页 |
| "我最近看过/改过哪些文档" | 个人访问记录 | `drive recent` | `drive list --latest` | recent 按个人访问记录跨空间;`--latest` 需要先框定范围 |
| "整理一下XX项目的所有讨论" | 跨源主题归档 | #5 generate-topic-report | #4 write-doc | #4 侧重单篇文档创作;按主题跨听记/群消息汇总属于工作汇报 |
@@ -14,6 +14,19 @@
`drive list --workspace <WS_ID>` 或 `wiki node list --workspace <WS_ID>`
### find-latest-file
「最近改的那份 XX」——范围已知时用 `--latest` 一步到位,不要拉全量自己比时间。
1. 框定范围:钉盘用 `--space-id` / `--folder`;知识库用 `--workspace <WS_ID>`
2. 取最新:`dws drive list --pattern "*日报*" --latest 1 --format json`
(知识库:`dws drive list --workspace <WS_ID> --latest 5 --format json`)
3. 结果按修改时间倒序,第 1 条即最新;文件夹不计入
4. 找不到足够条数时 CLI 仍返回 0,stderr 会提示放宽 `--pattern` 或换范围
5. 报 `LATEST_SCAN_TRUNCATED` 说明扫描触到 2000 条上限、Top-N 不可信:用 `--folder` 缩小范围或降低 `--depth` 重试
> 只记得名字、不知道在哪 → 改用 `drive search`;"我最近看过/改过的" → 改用 `drive recent`。
### import-file
将本地文件导入为钉钉在线文档。**一条命令完成上传+格式转换+创建**,无需先读取文件内容。
@@ -330,8 +330,9 @@ Flags:
用户说"有什么日志模版" → `template list` 或 `template get`
用户说"我发过的日志/我创建的日志" → `outbox list`
用户说"别人发给我的日志/我收到的日志" → `inbox list`
用户说"钉盘/知识库里最新的日报(文件)" → **不是 report**,走 `drive list --pattern "*日报*" --latest 1`
关键区分: report(钉钉日志模版汇报,含提交) vs doc(文档编辑) vs todo(待办任务)
关键区分: report(钉钉日志模版汇报,含提交) vs drive(作为文件存在的日报/周报,用 `drive list --latest`) vs doc(文档编辑) vs todo(待办任务)
## 核心工作流