Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3fdab44c9 | ||
|
|
c5b537c8cf | ||
|
|
bc56373ef2 | ||
|
|
d1c8c903f6 | ||
|
|
432b8742c4 | ||
|
|
a9af027ed4 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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))。本命令仍可用,仅作兼容保留。
|
||||
|
||||
@@ -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(待办任务)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
|
||||
Reference in New Issue
Block a user