Compare commits

...

6 Commits

Author SHA1 Message Date
fangshuyu
15263efe30 fix: normalize batch delete block IDs 2026-07-22 19:09:46 +08:00
fangshuyu
5b67085b32 fix: preserve docs title compatibility 2026-07-22 19:07:04 +08:00
fangshuyu
da149e66ba fix: validate unsafe docs write inputs 2026-07-22 18:52:59 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
17 changed files with 1640 additions and 31 deletions

View File

@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
{
name: "XML str_replace rejects multiline pattern",
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
wantParam: "--pattern",
},
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
{
name: "block_delete rejects empty ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
wantParam: "--block-id",
},
{
name: "block_delete rejects duplicate ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
wantParam: "--block-id",
},
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},

View File

@@ -17,6 +17,46 @@ import (
// ── V2 (OpenAPI) tests ──
func TestStripTopLevelXMLTitles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want string
}{
{
name: "single title",
content: "<title>Content title</title><p>body</p>",
want: "<p>body</p>",
},
{
name: "multiple titles",
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
want: "<p>body</p>",
},
{
name: "nested title is preserved",
content: "<callout><title>Nested</title></callout><p>body</p>",
want: "<callout><title>Nested</title></callout><p>body</p>",
},
{
name: "malformed XML is preserved",
content: "<title>Content title</title><p>A & B</p>",
want: "<title>Content title</title><p>A & B</p>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
}
})
}
}
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()

View File

@@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"strings"
"github.com/larksuite/cli/errs"
@@ -16,7 +18,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
if title == "" {
return content
}
if runtime.Str("doc-format") == "xml" {
content = stripTopLevelXMLTitles(content)
}
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
if content == "" {
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
return titleTag + "\n" + content
}
type docContentRange struct {
start int64
end int64
}
// stripTopLevelXMLTitles preserves the established --title-wins contract while
// avoiding duplicate-title warnings from XML content. If the fragment is not
// well-formed XML, it is left untouched for the service to diagnose.
func stripTopLevelXMLTitles(content string) string {
const wrapperStart = "<root>"
wrapped := wrapperStart + content + "</root>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
wrapperLen := int64(len(wrapperStart))
depth := 0
activeStart := int64(-1)
ranges := make([]docContentRange, 0, 1)
for {
tokenStart := decoder.InputOffset()
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return content
}
switch value := token.(type) {
case xml.StartElement:
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
activeStart = tokenStart - wrapperLen
}
depth++
case xml.EndElement:
depth--
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
activeStart = -1
}
}
}
if len(ranges) == 0 {
return content
}
var result strings.Builder
cursor := int64(0)
for _, item := range ranges {
result.WriteString(content[int(cursor):int(item.start)])
cursor = item.end
}
result.WriteString(content[int(cursor):])
return strings.TrimSpace(result.String())
}
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))

View File

@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if pattern == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
if err := validateBlockDeleteIDs(blockID); err != nil {
return err
}
case "block_insert_after":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
func validateBlockDeleteIDs(raw string) error {
seen := make(map[string]struct{})
for _, part := range strings.Split(raw, ",") {
blockID := strings.TrimSpace(part)
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
}
if _, ok := seen[blockID]; ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
}
seen[blockID] = struct{}{}
}
return nil
}
func normalizeBlockDeleteIDs(raw string) string {
parts := strings.Split(raw, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, ",")
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
body["pattern"] = v
}
if blockID != "" {
if cmd == "block_delete" {
blockID = normalizeBlockDeleteIDs(blockID)
}
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {

View File

@@ -41,7 +41,7 @@
### 内容限制
- HTML 总长度上限为 900000 字符。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
## OKR block

View File

@@ -26,7 +26,10 @@ metadata:
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search``drive +move` 开始。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps``file_type=apps`),裸 token 调 shortcut 时传 `--type apps`

View File

@@ -0,0 +1,273 @@
# 主题资料收集工作流:执行
由状态 `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE` 加载。
本文档负责最终写操作确认、目标创建、资源移动、验证、恢复行为、`RollbackSnapshotItem` 和执行日志。不得修改搜索、召回、分类规则或计划 schema。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理写操作确认、高风险操作、身份、认证和权限。
2. 按 [`lark-drive-create-folder.md`](lark-drive-create-folder.md) 创建 Drive 文件夹。
3. 按 [`lark-drive-move.md`](lark-drive-move.md) 执行 Drive 移动。
4. 按 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) 创建 Wiki 节点。
5. 按 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 执行 Wiki 移动和 Drive 文档移动到 Wiki。
6. 按 [`../../lark-wiki/references/lark-wiki-move-to-drive.md`](../../lark-wiki/references/lark-wiki-move-to-drive.md) 将 Wiki 节点移出到 Drive 文件夹。
7. 按 [`lark-drive-delete.md`](lark-drive-delete.md) 删除本次 workflow 新建的 Drive 文件夹。
8. 按 [`../../lark-wiki/references/lark-wiki-node-delete.md`](../../lark-wiki/references/lark-wiki-node-delete.md) 删除本次 workflow 新建的 Wiki 节点。
9. 需要轮询异步任务时,按 [`lark-drive-task-result.md`](lark-drive-task-result.md) 执行。
10. `MovePlanItem` schema 由 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) 定义,本文件只消费已确认计划。
## 状态:`CONFIRM_EXECUTION`
进入条件:移动计划已准备,且用户要求执行。
必须:
1. 执行前展示所有写操作类别。
2. 将目标创建和资源移动分开展示。
3. 展示默认纳入的高相关资源。
4. 如有用户选择的中相关资源,也要展示。
5. 展示跳过分组和原因。
6. 明确展示跨容器移动。
7. 展示无移动权限和移动权限未知的资源数量。
8. 请求用户明确确认。
9. 确认前校验每个 `move_resource` 项都包含完整 `command_family``command_args`、权限快照和 `rollback_input`;缺失时必须返回 `PLAN_MOVE` 重新生成计划,不得在执行阶段补猜。
10. 只有 `move_permission_state=movable``target_write_state=confirmed` 的计划项可以列入“将移动”。
11. 对每个 `rollback_supported=false` 的计划项逐项展示标题、当前位置、目标位置、不可恢复原因和影响,不得只展示数量。
### 确认 UI
```text
请确认是否执行以下写操作:
本次搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
将创建:
- 目标名称|父级位置|目标类型
将移动:
- 标题|类型|当前位置|目标位置|原因
不会移动:
- 中相关未选择N 项
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
风险提示:
- 不可自动恢复N 项
- 标题|当前位置|目标位置|不可恢复原因|影响:移动成功后 workflow 无法自动搬回原位置,需要手动处理
- 如果搜索范围是所有当前身份可见资源,移动权限未知项不会移动。
确认后才会创建目标和移动资源。
如果不存在不可自动恢复项,请回复“确认执行”开始写操作。
如果存在不可自动恢复项,请回复“确认执行,包括不可自动恢复项”;普通“确认执行”不满足本次风险确认。
也可以回复“调整计划”返回选择资源,或回复“取消”结束流程。
```
如果用户修改选择或相关性分组,废弃当前 `move_plan_items` 并返回 `PLAN_MOVE` 重新生成计划;不得在 `CONFIRM_EXECUTION` 直接局部改写计划。
## 状态:`EXECUTE`
进入条件:用户明确确认写操作;存在 `rollback_supported=false` 的计划项时,用户已明确确认包括不可自动恢复项。
必须:
1. 只执行已确认 `MovePlanItem.command_family``command_args`;不得回查 `ResourceItem` 补齐或改写命令参数。
2. 当存在 `action_type=create_target``MovePlanItem` 时,先创建目标。
3. 目标创建后记录返回 token只允许把 `created_by_plan:<create_target plan_id>` 引用解析为该 token并把解析后的实际参数写入 `execution_journal`。不得重新搜索或猜测目标。
4. 目标 token 引用解析成功后再移动依赖该目标的资源;解析失败时停止依赖该创建目标的移动并记录 blocker不得替换为其他目标。
5. 执行任何写操作前,基于每个已确认计划项的 `rollback_input` 生成 `rollback_snapshot``rollback_supported=false` 且已有明确 `rollback_blocker` 的快照视为完整风险快照,不阻塞其他项。
6. 执行任何写操作前,初始化 `execution_journal`
7. 每次写操作尝试后记录 `execution_journal`
8. 单项失败后可继续执行相互独立的移动;目标创建失败时必须停止。
9. 不得移动 `permission_denied``no_move_permission``move_permission_unknown``unverifiable``low``unsupported_move_target` 项。
10. 不得移动 `move_permission_state!=movable``target_write_state!=confirmed` 的资源。
11. 如果移动命令返回权限错误,记录失败原因,不自动申请权限,不自动重试同一移动。
12. 如果 `rollback_supported=true``rollback_input` 缺少恢复所需字段,将该计划项标记为 `failed` / `plan_snapshot_incomplete` 并跳过;不得在未重新确认风险的情况下把它静默降级为不可恢复项,也不得阻塞其他独立项。
### 移动方式选择
| 来源 -> 目标 | 移动方式 |
|------------------|-------------|
| Drive resource -> Drive folder | `drive +move` |
| Drive document-like resource -> Wiki target | `wiki +move` 的 docs-to-wiki 模式;默认不可自动恢复 |
| Wiki node -> Wiki target | `wiki +move --node-token` |
| Wiki node -> Drive folder | `wiki +move-to-drive` |
### 执行顺序
1. 如有 `create_target` 项,先执行。
2. 按确认计划顺序执行 `move_resource` 项。
3. 如果命令返回 task ID执行异步任务轮询。
4. 输出写操作执行摘要。
### 进度 UI
批量较大时,按计数汇报进度:
```text
执行进度:已完成 <done_count>/<total_count>,成功 <success_count>,失败 <failed_count>。
当前操作:<title>
继续执行中,不需要你操作;如遇到需要确认的失败会单独提示。
```
## 状态:`VERIFY`
进入条件:执行完成。
必须:
1. 如果创建了目标,验证目标存在。
2. 能力支持时,验证已移动资源在目标位置可见。
3. 对比实际位置和 `move_plan_items`
4. 为每一项标记验证状态。
5. 只有当已有移动成功且存在严重不一致或失败时,才提供恢复选项。
6. 输出验证结果时,必须说明用户下一步可以结束流程、查看失败项,或在可恢复时选择恢复。
7. 如果出现 `async_pending`,先使用 `drive +task_result` 轮询确认;超过轮询限制后再报告 pending blocker。
### 验证结果
| 状态值 | 说明 |
|--------|------|
| `verified` | 资源已在目标位置可见。 |
| `not_found` | 目标位置未找到资源。 |
| `permission_unknown` | 当前身份无法确认结果。 |
| `async_pending` | 异步任务尚未完成,需要继续轮询。 |
| `failed` | 移动命令失败或结果不符合计划。 |
## 状态:`RESTORE`
进入条件:失败、不一致或用户明确要求恢复。
必须:
1. 只基于 `rollback_snapshot``execution_journal` 生成恢复计划。
2. 展示可恢复项和不可恢复项。
3. 执行恢复写操作前请求明确确认;确认内容必须包含反向移动和删除本次 workflow 新建目标。
4. 只恢复本次 workflow 移动过的资源。
5. 只恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
6. Drive / Wiki 跨容器移动、原父级 token 缺失等 `rollback_supported=false` 的项不得反向移动,也不得删除迁入后的文档。
7. 本次 workflow 成功创建的目标文件夹或 Wiki 节点必须纳入清理计划。
8. 删除 workflow 新建的 Wiki 目标节点时,必须使用 `wiki +node-delete --include-children=false --yes`,让已迁入的直接子文档保留到该节点父级层级。
9. 删除 workflow 新建的 Drive 文件夹前,必须先恢复或移出其中由本次 workflow 放入的资源;如果无法确认文件夹已安全可删,报告清理阻塞,不得用删除文件夹来删除用户资源。
### 恢复顺序
1. 先恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
2. 对全部 `rollback_supported=false` 的项,只记录“保留在当前目标位置,不回迁、不删除”和对应 blocker。
3. 再清理 `created_by_workflow=true` 的目标容器。
4. Wiki 新建目标清理使用 `--include-children=false`Drive 新建目标清理只在不会删除用户资源时执行。
### 恢复 UI
```text
可以尝试恢复本次已移动的资源:
可恢复:
- 标题|当前位置|原位置
不可自动恢复:
- 标题|当前位置|原位置|原因|影响:需要手动恢复
将清理本次新建目标:
- 名称|类型|清理方式
将保留在当前目标位置的跨容器迁入文档:
- 标题|当前位置|保留结果
是否执行恢复?
```
## RollbackSnapshotItem
```json
{
"snapshot_id": "稳定快照行 ID",
"plan_id": "对应 MovePlanItem.plan_id",
"resource_id": "对应 MovePlanItem.resource_id",
"source_kind": "drive|wiki",
"title": "资源标题",
"resource_type": "Drive 恢复命令需要的资源类型",
"original_token": "原始 Drive token",
"original_node_token": "原始 Wiki node token",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径",
"planned_target_parent_token": "计划目标父级 token",
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因"
}
```
| 字段 | 说明 |
|-------|------|
| `snapshot_id` | 稳定快照行 ID。 |
| `plan_id` | 对应 `MovePlanItem.plan_id`,用于连接计划、快照和执行日志。 |
| `resource_id` | 对应稳定资源 ID用于审计计划来源。 |
| `resource_type` | `drive +move` 恢复时必须传入的 `--type`;非 Drive 恢复也保留原始资源类型。 |
| `original_token` / `original_node_token` | 执行前源资源身份。 |
| `original_parent_kind` / `original_parent_token` | 执行前父级位置。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因。 |
## 执行日志
每次写操作尝试都必须追加一条内部日志:
```json
{
"journal_id": "稳定日志行 ID",
"plan_id": "对应 MovePlanItem 的 plan_id",
"time": "ISO-8601",
"action_type": "create_target|move_resource|restore_resource|cleanup_target",
"operation": "create_folder|create_node|move_drive|move_wiki_node|move_wiki_to_drive|restore_drive|restore_wiki_node|delete_folder|delete_wiki_node",
"command_family": "drive +move|wiki +move|wiki +move-to-drive|drive +create-folder|wiki +node-create|drive +delete|wiki +node-delete",
"resolved_command_args": {"<arg>": "实际发送的参数"},
"title": "资源或目标名称",
"resource_type": "资源类型",
"input_token": "命令输入 token",
"input_node_token": "命令输入 Wiki node token",
"input_parent_token": "已知源父级 token",
"target_parent_token": "目标父级 token",
"returned_token": "命令返回 token",
"returned_node_token": "命令返回 Wiki node token",
"returned_parent_token": "返回父级 token",
"task_id": "异步任务 ID",
"next_command": "异步继续命令",
"created_by_workflow": "是否由本次 workflow 创建",
"rollback_eligible": "是否可进入自动恢复计划",
"status": "success|failed|pending",
"error": "失败原因"
}
```
字段说明:
| 字段 | 说明 |
|------|------|
| `journal_id` | 稳定日志行 ID。 |
| `plan_id` | 对应 `MovePlanItem`,用于把日志项匹配回原计划。 |
| `operation` | 细分操作类型,用于区分创建、移动和恢复。 |
| `resolved_command_args` | 从确认计划解析出的实际发送参数;用于审计 `created_by_plan:<plan_id>` 的唯一运行时替换。 |
| `resource_type` | 实际移动 / 恢复使用的资源类型。 |
| `input_token` / `input_node_token` | 命令实际输入的资源 token。 |
| `input_parent_token` | 执行前已知源父级 token。 |
| `target_parent_token` | 命令输入的目标父级 token。 |
| `returned_token` / `returned_node_token` | 命令返回的资源 token恢复时作为当前源。 |
| `returned_parent_token` | 命令返回的当前父级 token。 |
| `task_id` / `next_command` | 异步任务跟踪信息。 |
| `created_by_workflow` | 是否由本次 workflow 创建,用于后续清理判断。 |
| `rollback_eligible` | 是否可进入自动恢复计划。 |
| `status` | 写操作状态,异步未完成时为 `pending`。 |
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流:召回
由状态 `SEARCH_RECALL``RECALL_ENHANCE` 加载。
本文档负责基础搜索召回、覆盖增强、query 证据、去重和 `CandidateItem`。不得解析目标移动 token、读取完整文档内容、判断相关性或执行写操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-search.md`](lark-drive-search.md) 处理 `drive +search` 语法、过滤条件、单批最多 5 页和身份语义;本 workflow 的全量续批规则见下文。
## 搜索原则
1. 默认使用 `drive +search --mine` 召回当前用户 owner / 负责的 Workspace 资源。
2. 除非用户本来就要求限定范围,否则不要要求用户指定文件夹或 Wiki 范围。
3. `SEARCH_RECALL``RECALL_ENHANCE` 必须保持为独立状态。
4. `SEARCH_RECALL` 使用用户原始关键词、`owner_scope` 和显式限制。
5. `RECALL_ENHANCE` 可以基于基础召回证据增加扩展 query且必须继承同一个 `owner_scope`
6. 每个候选项必须保留 query 证据,方便后续解释来源。
7. 单页或单个最多 5 页的 query 批次不代表完整覆盖;`has_more=true` 时必须保存 `next_page_token` 并自动开始下一批,直到 `has_more=false` 或出现阻塞。
8. 召回和增强召回可能耗时较长,执行超过 60 秒时必须输出进度提示,之后约每 60 秒提示一次。
9. 只有用户在 `CONFIRM_CONTEXT` 明确确认 `owner_scope=all_visible` 时,才允许移除 `--mine`
### 分页优先级与完成语义
1. 用户确认进入 `topic_move_collector` 即表示同意为本次收集任务执行完整召回;无需再要求用户额外说“全部 / 全量 / 继续翻”。本规则覆盖 `lark-drive-search.md` 的默认首屏交互规则。
2. 仍遵守 `lark-drive-search.md` 的单轮最多 5 页限制。每读取最多 5 页形成一个批次;批次结束且 `has_more=true` 时,保存 checkpoint并使用原 query、原过滤条件和返回的 `next_page_token` 自动开始下一批。
3. 自动续批不改变 workflow 状态,也不触发用户确认。执行超过约 60 秒时只输出进度。
4. 一个 query 只有在 `has_more=false` 时才是 `complete`。单批结束、达到 5 页或已有部分候选都不代表完成。
5. 当前状态的全部 query 都为 `complete` 后,才能进入下一状态。认证、权限、无效分页 token、连续重试失败或工具预算不足属于 blocker必须保留 checkpoint、报告部分召回并停在当前状态不得把部分结果当成完整召回继续分类。
### QueryRecallState
每个基础 / 增强 query 必须维护:
```json
{
"query_id": "稳定 query ID",
"query": "完整 query",
"recall_stage": "search_recall|recall_enhance",
"page_count": 0,
"batch_count": 0,
"next_page_token": "下一批起点",
"has_more": true,
"status": "pending|running|complete|blocked",
"blocker": "阻塞原因"
}
```
## 状态:`SEARCH_RECALL`
进入条件:用户已确认 `CONFIRM_CONTEXT`
必须:
1. 基于已确认的 `topic` 构造基础 query。
2. 应用默认 `owner_scope=mine``constraints` 中的显式限制。
3. 不隐式添加 `--folder-tokens``--space-ids`
4.`owner_scope=mine` 时,所有基础 query 必须带 `--mine`
5.`owner_scope=all_visible` 时,不带 `--mine`,并记录扩展召回风险。
6. 除非命令限制要求更低值,否则使用 `--page-size 20`
7. 每个基础 query 按每批最多 5 页执行;批次结束仍有更多结果时自动续批,并合并所有页面。
8. 记录基础统计query、搜索范围、页数、批次数、收集数量、重复数量、阻塞项。
9. 只有全部基础 query 的 `status=complete``has_more=false` 时,才进入 `RECALL_ENHANCE`;出现阻塞时保持在 `SEARCH_RECALL`
### 召回进度 UI
`SEARCH_RECALL``RECALL_ENHANCE` 持续超过约 60 秒时,输出当前进度:
```text
搜索进度:当前阶段 <SEARCH_RECALL|RECALL_ENHANCE>,已执行 <query_count> 个 query已读取 <page_count> 页,收集候选 <raw_count> 项,去重后 <unique_count> 项。继续搜索,不会创建或移动资源。
```
如果正在执行具体 query可补充
```text
当前 query<query>
```
### 基础 Query 规则
| 用户输入 | 基础 Query |
|------------|----------------|
| 单个关键词 | 直接作为 `--query`。 |
| 多个关键词组成一个短语 | 优先按用户输入的短语执行。 |
| 明确精确短语 | 保留引号。 |
| 明确排除词 | 保留负向词。 |
| 没有真实关键词,只有过滤条件 | 使用 `--query ""` 搭配过滤条件。 |
`SEARCH_RECALL` 中不得添加同义词、仅标题搜索、仅评论搜索或 OR 扩展。
### 基础召回输出
```text
基础召回完成:
- 使用 query
- 搜索范围:
- 应用限制:
- 收集候选:
- 去重后候选:
- 阻塞项:
下一步:继续执行覆盖增强,不需要你操作;不会创建或移动资源。
```
## 状态:`RECALL_ENHANCE`
进入条件:基础召回完成。
必须:
1. 基于已确认主题和基础召回证据生成增强 query。
2. 确保增强 query 可解释且不引入明显污染。
3. 每个增强 query 都必须继承 `owner_scope``owner_scope=mine` 时必须带 `--mine`
4. 每个 query 都必须按每批最多 5 页处理分页,并自动续批直到 `has_more=false`
5. 有稳定去重键时,按稳定去重键合并候选项。
6. 为每个候选项保留 `source_queries` 和命中证据。
7. 当 query 不再产生新候选,或出现工具预算 / API 阻塞时,停止增强。
### 召回阶段退出门禁
`RECALL_ENHANCE` 完成后,必须:
1. 确认全部基础和增强 query 的 `status=complete``has_more=false`,再固化完整 `candidate_items`,包含去重结果、`source_queries``match_channels``snippets``dedupe_status`
2.`current_state` 设置为 `RESOURCE_RESOLVE`
3. 加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md)。
4. 把完整 `candidate_items` 交给 `RESOURCE_RESOLVE`
5. 不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE` 或展示相关性结果。
6. 不得用搜索标题、摘要或 query 命中直接生成高 / 中 / 低相关分组。
### 增强策略
| 策略 | 说明 |
|----------|------|
| 精确短语 | 对明确短语使用 `"..."` 提高精确命中。 |
| `intitle:` | 对项目名、客户名、制度名、报表名等标题特征强的主题执行标题召回。 |
| `--only-title` | 当标题命中更可信时使用。 |
| `--only-comment` | 当主题可能只出现在评论讨论中时使用。 |
| 类型拆分 | 对 `docx``sheet``bitable``slides``file` 等分类型搜索,减少服务端排序偏差。 |
| 同义词 / 别名 | 使用业务上明确的同义词、简称、英文名、中文名。 |
| OR 扩展 | 对同一实体的别名做 OR 扩展。 |
| 负向词 | 对明显噪声使用 `-term`,但不能排除可能相关的主题词。 |
### Query 证据
每个候选项都要记录:
| 字段 | 说明 |
|-------|------|
| `source_queries` | 命中过该资源的 query 列表。 |
| `match_channels` | 命中位置,如 title、body、comment、metadata。 |
| `snippets` | 搜索返回的摘要或片段。 |
| `query_rank` | 资源在各 query 中的相对位置。 |
| `recall_stage` | `search_recall``recall_enhance`。 |
## 去重规则
必须:
1. 搜索响应提供 canonical token 时,优先使用 canonical token。
2. 对 Wiki 结果,不得只按 object token 去重;同一对象可能出现在多个 Wiki 节点中。
3. token 缺失时,使用 URL 作为 fallback。
4. 合并重复项时保留所有 query 证据。
5. 如果无法确定去重是否稳定,保留该项并设置 `dedupe_status=uncertain`
## CandidateItem
```json
{
"title": "资源标题",
"url": "资源链接",
"raw_type": "搜索返回类型",
"source_queries": ["query"],
"match_channels": ["title|body|comment|metadata"],
"snippets": ["命中片段"],
"page_rank": 1,
"dedupe_key": "候选去重键",
"dedupe_status": "stable|fallback|uncertain",
"recall_stage": "search_recall|recall_enhance"
}
```
| 字段 | 说明 |
|-------|------|
| `title` | 搜索结果标题。 |
| `url` | 资源访问链接。 |
| `raw_type` | 搜索返回的原始类型。 |
| `source_queries` | 命中过该资源的搜索 query。 |
| `match_channels` | 命中位置。 |
| `snippets` | 摘要或命中片段。 |
| `page_rank` | 当前 query 下的排序位置。 |
| `dedupe_key` | 候选去重键。 |
| `dedupe_status` | 去重可信度。 |
| `recall_stage` | 资源首次进入候选集的召回阶段。 |
## 阻塞项
缺少认证 / scope、`drive +search` 返回权限或策略阻塞、分页 token 无效、分页重试后仍无法继续,或工具预算不足以完成全部页面时,必须把对应 `QueryRecallState.status` 设置为 `blocked`,保留累计候选、页数和 `next_page_token`,停止并报告。阻塞解除后从 checkpoint 续跑;在全部 query 完成前不得进入资源解析或分类阶段。

View File

@@ -0,0 +1,231 @@
# 主题资料收集工作流:资源解析与内容验证
由状态 `RESOURCE_RESOLVE``CONTENT_VERIFY` 加载。
本文档负责资源解析、结构化父级、移动资格、内容验证和 `ResourceItem`。不得判断相关性、生成移动计划、创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-inspect.md`](lark-drive-inspect.md) 处理 URL / token 解析。
3. 使用 `drive metas batch_query` 补齐 Drive 资源 owner、标题和 URL。
4. 必要时使用 `drive permission.members auth` 读取权限信号;该接口不提供 `full_access` / 移动权限的直接判定,不能把 `manage_public` 等同为可移动。
5. 按 [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 处理 Wiki 节点解析。
6. 按 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) 读取文档内容。
7. 需要验证 Sheet 内容时,按 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md) 执行。
## 进入解析与验证阶段前校验
进入本文档后,如果 `resource_items` 还不存在,当前状态必须是 `RESOURCE_RESOLVE`
禁止从 `candidate_items` 直接进入 `CONTENT_VERIFY``RELEVANCE_CLASSIFY`,也禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使候选项已有标题、URL、摘要或 token也必须依次执行 `RESOURCE_RESOLVE``CONTENT_VERIFY`;两个状态不得合并。
## 状态:`RESOURCE_RESOLVE`
进入条件:候选列表已准备。
必须:
1. 为每个 `CandidateItem` 生成稳定 `resource_id`,并转换为标准化 `ResourceItem`
2. 解析 canonical token、资源类型、URL、结构化当前父级、Wiki 节点身份和读取权限状态。
3. 对 Wiki 资源同时保留 `wiki_node_token``wiki_obj_token`
4.`move_method` 补齐 `owner_id``is_owner``source_move_state``source_parent_write_state``target_write_state``move_permission_state``move_permission_basis`
5. 基于 `target_location` 检测不支持的移动方向。
6. 未解析成功的资源仍保留在审核分组中,不得静默丢弃。
7. 即使搜索结果已经包含标题、URL 或 token也必须经过本状态生成 `ResourceItem`;不得从召回结果直接进入相关性分级。
8. 只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源,才能进入后续默认移动链路。
9. 解析耗时超过约 60 秒时,必须输出进度提示,之后约每 60 秒提示一次。
### 解析规则
| 候选类型 | agent 必须执行 |
|----------------|---------------|
| Drive URL / token | token 或类型不确定时,使用 `drive +inspect`。 |
| Wiki URL / token | 使用 `drive +inspect``wiki +node-get`;保留节点身份和对象身份。 |
| 文件夹候选 | 标记为容器;不要当作普通文档做内容验证。 |
| 快捷方式候选 | 能解析源资源时解析源资源;同时保留快捷方式身份。 |
| 无读取权限 | 保留可见元数据,并设置 `permission_state=denied`。 |
| 无移动权限或移动权限未知 | 保留可见元数据和召回证据,并设置对应 `move_permission_state`。 |
| 无法解析当前父级 | 设置 `current_parent_kind=unknown`,保留已知路径,后续计划项设置 `rollback_supported=false` 和明确 blocker不得编造父级 token。 |
### 资源解析进度 UI
`RESOURCE_RESOLVE` 持续超过约 60 秒时,输出当前进度:
```text
资源解析进度:已解析 <resolved_count>/<total_count> 项,已确认可移动 <movable_count> 项,无移动权限 <denied_count> 项,移动权限未知 <unknown_count> 项,解析失败 <failed_count> 项。
当前资源:<title>
继续解析中,不会创建或移动资源。
```
如果正在处理权限或 owner 元数据,可补充:
```text
当前步骤:解析 owner / 当前父级 / 移动资格。
```
`RESOURCE_RESOLVE` 完成后,输出摘要:
```text
资源解析完成:
- 候选总数N 项
- 可进入内容验证N 项
- 无移动权限N 项
- 移动权限未知N 项
- 解析失败或无读取权限N 项
下一步会对可移动资源做内容验证;不会创建或移动资源。
```
### 资源解析出口门禁
`RESOURCE_RESOLVE` 完成后必须:
1.`content_verify_completed` 重置为 `false`
2. 将下一状态设置为 `CONTENT_VERIFY`,不得设置为 `RELEVANCE_CLASSIFY``PLAN_MOVE`
3. 不得在本状态生成 `relevance``relevance_groups` 或移动计划。
4. 即使可读取正文的资源数量为 0也必须进入 `CONTENT_VERIFY`,为每项记录跳过验证原因并输出验证摘要。
### 移动资格判定
`owner` 只能作为部分权限证据,不得单独把资源判为 `movable``RESOURCE_RESOLVE` 必须先按 `move_method` 记录以下独立状态:
| 字段 | 说明 |
|------|------|
| `source_move_state` | 当前身份是否确认可以对源资源执行对应移动Drive owner 只可作为 Drive 源资源可管理的证据Wiki 底层资源 owner 不能证明 Wiki 节点可移动。 |
| `source_parent_write_state` | 当前身份是否确认可编辑源位置;仅 `drive_move` 必须确认,其他移动方式为 `not_required`。 |
| `target_write_state` | 当前身份是否确认可写目标位置;待创建目标以父级位置的创建 / 写入权限为准。 |
#### 按移动方式的权限矩阵
| `move_method` | `source_move_state=confirmed` 的证据 | `source_parent_write_state` | `target_write_state` |
|---------------|--------------------------------------|-----------------------------|----------------------|
| `drive_move` | 当前用户是可靠解析出的 Drive 资源 owner或有明确资源可管理证据 | 必须为 `confirmed` | 必须为 `confirmed` |
| `wiki_move_docs_to_wiki` | 有明确的 Drive 文档直接迁入权限;仅 owner 元数据不足以证明可直接迁入 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_node` | 有明确的 Wiki 节点 / 源空间移动权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_to_drive` | 有明确的 Wiki 节点移出权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Drive 文件夹可写 |
#### 聚合顺序
1. 目标方向或资源类型不支持时,设置 `move_permission_state=denied``move_permission_basis=["unsupported_direction"]`
2. 任一必需状态为 `denied` 时,设置 `move_permission_state=denied`,并在 `move_permission_basis` 记录 `source_denied``source_parent_denied``target_denied`
3. 任一必需状态为 `unknown` 时,设置 `move_permission_state=unknown`,并记录对应的 `source_unknown``source_parent_unknown``target_unknown`
4. 只有权限矩阵中的全部必需状态都为 `confirmed` 时,才能设置 `move_permission_state=movable``move_permission_basis=["permission_matrix_confirmed"]`
注意:
1. `drive permission.members auth` 不提供 `full_access``move` action不能用 `view``edit``share``manage_public` 结果推断源位置或目标位置可写。
2. `target_write_state=unknown|denied` 的资源不得进入高 / 中相关可执行分组或移动计划。
3. `move_permission_state=unknown` 的资源默认不进入内容验证、相关性高 / 中分组或移动计划。
4.`owner_scope=mine` 但解析出的 owner 不是当前用户时,将该资源视为异常候选,设置 `source_move_state=unknown``move_permission_state=unknown`,不得加入移动计划。
## 状态:`CONTENT_VERIFY`
进入条件:资源列表已准备。
必须:
1. 本状态不可跳过,也不得与 `RESOURCE_RESOLVE``RELEVANCE_CLASSIFY` 合并;没有可读取正文的资源时仍须执行。
2. 只在资源解析后读取支持的内容。
3. 按数量、大小和类型能力限制读取范围。
4. 结合搜索证据和内容证据;除非标题精确且足够强,否则不要仅凭标题判为高相关。
5. 将不可读取资源标记为 `unverifiable``permission_denied`
6. 不得自动申请权限。
7. 为每个资源写入验证状态:已读取内容证据、仅可使用搜索证据、无权限、无移动权限、移动权限未知、无法验证或不支持内容验证。
8.`move_permission_state=denied|unknown` 的资源,不再读取正文内容,写入跳过验证原因并保留召回证据;写入跳过原因属于执行本状态,不等于跳过本状态。
9. 所有资源都有验证状态或跳过原因后,将 `content_verify_completed` 设置为 `true` 并输出验证摘要。
10. `content_verify_completed=true` 前不得进入 `RELEVANCE_CLASSIFY`
### 验证方式
| 资源类型 | 验证方式 |
|---------------|---------------------|
| `docx` / `doc` | 允许时使用 `docs +fetch --api-version v2`。 |
| `sheet` | 使用 `sheets +find` 查关键词证据,或用 `sheets +read` 读取有界范围。 |
| `bitable` | 只有必要且已加载 Base 能力时验证。 |
| `slides` | 除非具备幻灯片读取能力,否则使用元数据 / 预览 / 标题证据。 |
| `file` | 仅在支持时使用标题、元数据、预览或导出文本。 |
| `wiki` 节点 | 按 `obj_type` 验证底层对象;节点本身不是内容 token。 |
| `folder` | 除非用户明确要移动容器,否则通常不作为主题证据移动。 |
### 内容验证完成 UI
完成 `CONTENT_VERIFY` 后必须输出:
```text
内容验证完成:
- 已读取内容证据N 项
- 仅复用搜索证据N 项
- 因无权限或移动资格跳过N 项
- 无法验证或不支持验证N 项
下一步会基于以上证据进行相关性分组;不会创建或移动资源。
```
如果没有任何资源可以读取正文,仍须输出该摘要,并明确说明所有资源采用的搜索证据或跳过原因。
### 内容验证出口门禁
`CONTENT_VERIFY` 完成后必须:
1. 确认 `content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过原因。
2. 将下一状态设置为 `RELEVANCE_CLASSIFY`
3. 加载 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md)。
4. 不得直接进入 `PLAN_MOVE`
## ResourceItem
```json
{
"resource_id": "稳定资源 ID",
"title": "资源标题",
"resource_type": "doc|docx|sheet|bitable|file|folder|wiki|slides|shortcut",
"url": "资源链接",
"canonical_token": "标准资源 token",
"wiki_node_token": "Wiki 节点 token",
"wiki_obj_token": "Wiki 底层对象 token",
"wiki_obj_type": "Wiki 底层对象类型",
"space_id": "知识空间 ID",
"current_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"current_parent_token": "当前父级 token",
"current_parent_space_id": "当前父级 Wiki space_id",
"current_path": "用于展示的当前位置",
"owner_id": "资源 owner open_id",
"is_owner": "true|false|unknown",
"permission_state": "readable|denied|unknown",
"source_move_state": "confirmed|unknown|denied",
"source_parent_write_state": "confirmed|unknown|denied|not_required",
"move_permission_state": "movable|denied|unknown",
"move_permission_basis": ["权限矩阵证据或阻塞原因"],
"target_write_state": "confirmed|unknown|denied",
"item_resolve_status": "resolved|partial|failed",
"content_verify_state": "verified|search_evidence_only|skipped_by_move_permission|permission_denied|unverifiable|unsupported",
"content_evidence": ["证据"],
"relevance": "high|medium|low|permission_denied|no_move_permission|move_permission_unknown|unverifiable|unsupported_move_target"
}
```
| 字段 | 说明 |
|-------|------|
| `canonical_token` | 内容读取、Drive 对象操作或底层对象操作使用的标准 tokenWiki 节点移动不得使用该字段。 |
| `resource_id` | 资源解析时生成的稳定 ID用于连接 `ResourceItem``MovePlanItem`。 |
| `wiki_node_token` | Wiki 节点身份,用于 Wiki 节点移动。 |
| `wiki_obj_token` | Wiki 节点背后的真实文档 token。 |
| `current_parent_kind` / `current_parent_token` / `current_parent_space_id` | 结构化执行前父级,用于 `already_at_target` 判断和恢复;未知值不得猜测。 |
| `current_path` | 仅用于用户展示的当前位置,不得代替父级 token。 |
| `owner_id` | 资源 ownerDrive 资源优先来自 `drive metas batch_query`Wiki 节点优先来自 `wiki +node-get`。 |
| `is_owner` | 当前用户是否为资源 owner。 |
| `permission_state` | 当前身份下的读取权限状态。 |
| `source_move_state` | 当前身份是否确认能对源资源执行所选 `move_method`;必须按权限矩阵判断。 |
| `source_parent_write_state` | Drive 内移动所需的源位置编辑状态;非 `drive_move``not_required`。 |
| `move_permission_state` | 权限矩阵聚合结果;只有 `movable` 且目标写入状态为 `confirmed` 才可进入默认移动链路。 |
| `move_permission_basis` | 移动资格判断依据,用于解释为什么纳入或排除。 |
| `target_write_state` | 目标位置是否确认可写。 |
| `item_resolve_status` | 资源项解析状态;不要和 `TargetLocation.target_resolve_status` 混用。 |
| `content_verify_state` | 内容验证状态或跳过验证原因。 |
| `content_evidence` | 支撑相关性判断的命中证据。 |
| `relevance` | 相关性和可执行性分组。 |

View File

@@ -0,0 +1,248 @@
# 主题资料收集工作流:审核与计划
由状态 `RELEVANCE_CLASSIFY``PLAN_MOVE` 加载。
本文档负责相关性分级、审核 UI、移动计划生成和 `MovePlanItem`。不得重新执行资源解析或内容验证,也不得创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 输入契约
进入本文档前必须已有:
1. `resource_items`,且每个 `ResourceItem` 已包含稳定 `resource_id`、资源类型、移动所需 token、结构化当前父级、权限状态、内容验证状态和证据。
2. `content_verify_completed=true`
3. 每个资源都有内容证据、搜索证据复用说明或明确跳过原因。
`ResourceItem` schema 和字段生成规则由 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 负责。只要上述输入契约完整,本状态不得为重复读取 schema 而重新加载或执行前一阶段文档。
如果输入字段缺失、资源需要重新解析或用户要求重新读取证据,废弃受影响的相关性和计划结果,返回 `RESOURCE_RESOLVE``CONTENT_VERIFY`,并加载资源解析与内容验证文档;不得在本状态补猜。
## 状态:`RELEVANCE_CLASSIFY`
进入条件:`CONTENT_VERIFY` 已完成,`content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过验证原因。
禁止条件:
1. 只有 `candidate_items`,没有 `resource_items`
2. 资源未经过 `RESOURCE_RESOLVE`
3. 资源没有 `RESOURCE_RESOLVE` 写入的移动资格状态。
4. 资源没有 `CONTENT_VERIFY` 写入的验证状态或跳过验证原因。
5. 上一完成状态是 `RESOURCE_RESOLVE`,或 `content_verify_completed` 不为 `true`
必须将每个资源归入且只归入一个分组:
| 分组 | 说明 | 默认移动 |
|-------|------|--------------|
| `high` | 可移动资源,且主题或内容直接命中,有明确标题 / 正文 / 表格 / 评论证据。 | 是 |
| `medium` | 可移动资源,可能相关,但证据不足或只命中弱相关片段。 | 否,需用户选择 |
| `low` | 可移动资源,弱相关或噪声,保留展示但不建议移动。 | 否 |
| `permission_denied` | 当前身份无权读取或解析,不能验证内容。 | 否 |
| `no_move_permission` | 已确认当前身份不具备移动资格。 | 否 |
| `move_permission_unknown` | 无法确认当前身份是否具备移动资格。 | 否 |
| `unverifiable` | 类型或工具限制导致无法验证内容。 | 否 |
| `unsupported_move_target` | 目标方向或资源类型不支持移动。 | 否 |
`high``medium``low` 只能包含 `move_permission_state=movable``target_write_state=confirmed` 的资源。
判为高相关至少需要一个强证据:
1. 标题或内容中出现精确主题短语。
2. 多个主题词在相关上下文中同时出现。
3. Sheet / 表格单元格明确匹配用户主题。
4. 用户明确提供的文档名或项目别名命中。
中相关示例:
1. 标题包含一个主题词,但内容无法确认。
2. 搜索摘要看起来相关,但无法完整读取。
3. 别名命中合理但证据不够强。
## 审核 UI
必须展示每个分组中的资源名称。
默认展示规则:
1. 展开 `high``medium`
2. 折叠 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`,但展示数量并允许展开。
3. 每个可见资源展示标题、类型、当前位置、证据和默认动作。
4. 除非用户要求技术细节,否则不展示原始 token。
示例:
```text
筛选结果:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关(默认移动):
- 标题|类型|证据|当前位置
中相关(需你勾选后才移动):
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
你可以选择:
1. 确认按默认规则生成移动计划。
2. 勾选要加入计划的中相关资源。
3. 要求把某些资源移到其他分组或从计划中移除。
4. 展开低相关 / 无权限 / 无移动权限 / 移动权限未知 / 无法验证 / 不支持移动分组查看名称。
```
### 用户调整规则
如果用户不同意相关性结果,必须基于用户要求更新 `relevance_groups`,再重新展示分组结果并重新生成后续移动计划。
典型调整包括:
1.`high` 中移除某个资源。
2.`medium` 中某个资源提升为 `high`
3. 将某个资源标为 `low` 或不移动。
4. 要求重新读取证据或重新判断一批资源。
5. 要求重新确认某些资源的移动权限。
用户调整后:
1. 旧的 `move_plan_items` 立即失效。
2. 必须先输出“调整后相关性结果”,展示被调整项、各分组数量和高 / 中相关资源名称。
3. 不得只回复“已调整”,也不得直接跳到 `CONFIRM_EXECUTION`
4. 必须基于新的 `relevance_groups` 重新执行 `PLAN_MOVE`
5. 不得把 `no_move_permission``move_permission_unknown` 资源直接提升到 `high` / `medium`;必须先回到 `RESOURCE_RESOLVE`,加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 取得可移动证据。
### 调整后结果 UI
```text
已按你的要求调整相关性结果:
- <标题><原分组> -> <新分组>
调整后分组:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关默认移动N 项
- 标题|类型|证据|当前位置
中相关需你勾选后才移动N 项
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
接下来会基于这个调整后的结果重新生成移动计划;你也可以继续调整。
```
## 状态:`PLAN_MOVE`
进入条件:相关性分组已准备。
必须:
1.`target_location.create_required=true` 时,纳入目标创建计划。
2. 生成移动计划前,比较规范化的当前父级与目标父级;已在目标位置的资源生成 `skip_resource`,设置 `skip_reason=already_at_target`,不得生成移动命令。
3. 默认纳入全部 `high``move_permission_state=movable``target_write_state=confirmed` 的资源。
4. 只有用户明确选择时,才纳入 `medium``move_permission_state=movable``target_write_state=confirmed` 的资源。
5. 默认排除 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`
6. 为每个跳过项生成 `skip_reason`
7. 为每个计划项生成稳定 `plan_id`,并使用 `resource_id` 连接对应资源;不得按标题或临时 token 猜测关联。
8.`command_family` 保存完整、不可变的 `command_args`;不得把 Wiki 底层对象 token 当作 Wiki 节点移动 token。
9. 为每个 `move_resource` 项复制执行前恢复所需的完整 `rollback_input`,使确认计划不依赖运行时回查 `ResourceItem`
10. 当前父级无法结构化解析或属于 Drive / Wiki 跨容器移动时,设置 `rollback_supported=false` 和明确 `rollback_blocker`;该单项仍可进入确认,但必须逐项展示不可恢复风险,不得阻塞其他独立项。
11. 停止并等待用户选择或执行意图。
12. 不得为 `move_permission_state!=movable``target_write_state!=confirmed` 的资源生成 `move_resource` 计划项。
### 已在目标位置判定
1. `drive_move` 比较 `current_parent_kind` 和目标 Drive 父级,并比较规范化后的 `current_parent_token` / root 标识。
2. `wiki_move_node` 比较 `current_parent_space_id``current_parent_kind``current_parent_token`Wiki 空间根节点使用明确的 root 标识,不得用空字符串和未知状态混淆。
3. 只有父级类型、space ID适用时和 token 都已解析且相等时,才能设置 `skip_reason=already_at_target`;父级未知时不得猜测为相等。
### 移动 token 选择
| `command_family` | `command_args` 必须包含 |
|------------------|---------------------------|
| `drive +move` | `file_token``type``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空且目标类型为 root。 |
| `wiki +move`node | `node_token`,以及 `target_space_id``target_parent_token`;可选 `source_space_id`。不得使用 `wiki_obj_token` 代替 `node_token`。 |
| `wiki +move`docs-to-wiki | `obj_type``obj_token``target_space_id`、可选 `target_parent_token`,并显式保存 `apply=false`。 |
| `wiki +move-to-drive` | `node_token``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空。 |
| `drive +create-folder` | `name`、父级 `folder_token`;创建在 Drive root 时显式记录父级为空。 |
| `wiki +node-create` | `space_id``title``obj_type`、可选 `parent_node_token`。 |
| `none` | 不执行命令,保留 `skip_reason`。 |
目标由本次 workflow 创建时,对应目标参数保存 `created_by_plan:<create_target plan_id>` 引用。`EXECUTE` 只允许把该引用替换为对应创建计划返回的 token不得重新搜索或猜测目标。
### 计划 UI
```text
移动计划已生成:
- 默认将移动高相关N 项
- 你已选择中相关N 项
- 其中不可自动恢复N 项
- 已在目标位置N 项
- 不会移动N 项
- 无移动权限N 项
- 移动权限未知N 项
你可以回复“确认执行”,也可以继续调整分组、增减中相关资源,或取消本次移动。
```
## MovePlanItem
```json
{
"plan_id": "稳定计划项 ID",
"resource_id": "对应 ResourceItem.resource_idcreate_target 为空",
"action_type": "create_target|move_resource|skip_resource|unsupported",
"title": "资源或目标名称",
"resource_type": "源资源类型",
"move_method": "drive_move|wiki_move_node|wiki_move_docs_to_wiki|wiki_move_to_drive|none",
"command_family": "具体 shortcut 命令或 none",
"command_args": {
"<arg>": "按 command_family 参数表保存的完整、类型明确的参数"
},
"source_path": "用户确认时展示的源位置",
"target_path": "用户确认时展示的目标位置",
"move_permission_state": "movable|denied|unknown|not_required",
"target_write_state": "confirmed|unknown|denied",
"reason": "纳入或跳过原因",
"skip_reason": "already_at_target 或其他跳过原因",
"rollback_input": {
"source_kind": "drive|wiki",
"original_token": "原始 Drive / obj token",
"original_node_token": "原始 Wiki node token",
"resource_type": "恢复命令需要的资源类型",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径"
},
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因",
"execution_status": "pending|success|failed|skipped"
}
```
| 字段 | 说明 |
|-------|------|
| `plan_id` | 稳定计划项 ID用于连接计划、快照和执行日志。 |
| `resource_id` | 稳定资源 ID用于连接确认计划和解析结果`create_target` 为空。执行阶段不得依赖该关联回查可变参数。 |
| `action_type` | 计划动作类型。 |
| `move_method` | 实际使用的移动方式。 |
| `command_family` / `command_args` | 用户确认的完整写命令及参数快照;确认后保持不可变。目标待创建时只允许使用 `created_by_plan:<plan_id>` 引用。 |
| `move_permission_state` / `target_write_state` | 用户确认时的权限门禁快照;`move_resource` 必须分别为 `movable` / `confirmed``create_target` 的移动权限为 `not_required`,但父级写入权限仍必须为 `confirmed`。 |
| `rollback_input` | 从 `ResourceItem` 复制出的完整恢复输入;仅 `move_resource` 必填,生成确认计划后不得再回查或猜测。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因;跨容器移动使用 `cross_container_permission_model_not_losslessly_restorable`,原父级 token 缺失使用 `original_parent_token_unavailable`。 |
| `execution_status` | 执行状态。 |

View File

@@ -0,0 +1,174 @@
# 主题资料收集工作流:输入与目标确认
由状态 `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT` 加载。
本文档负责用户输入解析、目标位置解析、搜索前确认和 `TargetLocation`。不得执行搜索召回、资源分类、目标创建或资源移动。
本文档只服务 `topic_move_collector`。进入本文档后必须确认 `workflow_id=topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 解析 Drive 目标时,遵循 [`lark-drive-inspect.md`](lark-drive-inspect.md)、[`lark-drive-create-folder.md`](lark-drive-create-folder.md) 和 [`lark-drive-search.md`](lark-drive-search.md)。
3. 解析 Wiki 目标时,遵循 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)、[`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 和 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md)。
## 状态:`PARSE_INPUT`
进入条件workflow 被触发。
必须:
1. 提取 `topic``target``identity``owner_scope``constraints`
2.`topic``target` 视为必填字段。
3. 除非用户明确要求 bot / app 视角,否则 `identity` 默认使用用户身份。
4. 默认 `allow_cross_container_move=true`,但必须在 `CONFIRM_CONTEXT` 展示。
5. 默认 `owner_scope=mine`,表示只搜索当前用户 owner / 负责的资源。
6. 只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才设置 `owner_scope=all_visible`
7. 除非用户明确提供限制,否则 `constraints` 保持为空。
8. 如果缺少 `topic``target`,只提出最小澄清问题。
### 输入字段
| 字段 | 说明 |
|-------|------|
| `topic` | 用户要查找的主题、关键词、内容线索、同义词、缩写、排除词。 |
| `target` | 归档目标,可以是已有 Drive 文件夹、已有 Wiki 节点、待创建 Drive 文件夹或待创建 Wiki 节点。 |
| `identity` | 执行身份,默认 `--as user`。 |
| `owner_scope` | 搜索 owner 范围,默认 `mine``all_visible` 仅在用户明确要求扩展到所有可见资源时使用。 |
| `constraints` | 用户显式给出的类型、时间、创建人、评论、标题、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须确认。 |
### 澄清模板
```text
我还需要补齐两个信息后才能开始:
1. 要查找的主题 / 关键词 / 内容线索是什么?
2. 找到后要移动到哪个 Drive 文件夹或 Wiki 节点?如果需要新建目标,也请说明父级位置和新名称。
```
## 状态:`RESOLVE_TARGET`
进入条件:`topic``target` 已获得。
必须:
1. 将已有目标解析为具体 token。
2. 如果目标需要创建,只解析父级位置和新目标名称。
3. 在本状态中不得创建文件夹或 Wiki 节点。
4. 分别保留 Drive 文件夹 token、Wiki 节点 token、Wiki 对象 token、space ID 和 parent token。
5. 如果目标 URL / token 存在,但当前身份无法读取或解析目标位置,设置 `target_resolve_status=permission_denied`,保持在 `RESOLVE_TARGET` 并等待用户更换目标或结束;不得进入搜索。
6. 如果已知移动方向不支持,尽早标记。
### 目标解析
| 条件 | agent 必须执行 | 设置 `target_type` |
|-----------|---------------|-------------------|
| 已有 Drive 文件夹 URL 或 token | 有 URL 时用 `drive +inspect` 解析;保留 `folder_token` | `drive_folder` |
| 已有 Wiki 节点 URL 或 token | 用 `wiki +node-get``drive +inspect` 解析;保留 `wiki_node_token``space_id` | `wiki_node` |
| 在已知父级下新建 Drive 文件夹 | 解析父文件夹;保存新文件夹名称;不创建 | `new_drive_folder` |
| 在已知父级下新建 Wiki 节点 | 解析知识空间和可选父节点;保存新节点标题;不创建 | `new_wiki_node` |
| 以 Wiki 空间根节点作为目标 | 解析 `space_id`parent token 可以为空 | `wiki_space` |
| 目标名称有歧义 | 仅在必要时搜索或列出候选;展示候选并等待用户选择 | `unknown` |
### 目标解析状态
| 条件 | `target_resolve_status` |
|------|--------------------------|
| 目标已解析,或待创建目标的父级位置已解析 | `resolved` |
| 目标名称有歧义、候选不唯一,或 `target_type=unknown` 需要用户选择 | `ambiguous` |
| 已知目标方向或目标类型不支持本 workflow | `unsupported` |
| 目标 URL / token 存在,但当前身份无权读取、解析或确认目标位置 | `permission_denied` |
### 目标解析出口门禁
| `target_resolve_status` | 下一状态 | agent 必须执行 |
|-------------------------|----------|----------------|
| `resolved` | `CONFIRM_CONTEXT` | 展示已解析目标并进入搜索前确认。 |
| `ambiguous` | 保持 `RESOLVE_TARGET` | 展示候选并等待用户选择;不得进入 `CONFIRM_CONTEXT`。 |
| `unsupported` | 保持 `RESOLVE_TARGET` | 展示不支持原因,等待用户更换目标或结束;不得搜索。 |
| `permission_denied` | 保持 `RESOLVE_TARGET` | 展示权限 blocker等待用户更换目标或结束不得搜索。 |
用户提供新目标后,重新执行 `RESOLVE_TARGET`。只有新的解析结果为 `resolved`,才能进入 `CONFIRM_CONTEXT`;用户选择结束时进入 `DONE`
### 跨容器规则
| 来源 -> 目标 | 默认规则 |
|------------------|---------|
| Drive 资源 -> Drive 文件夹 | 支持,使用 `drive +move`。 |
| Drive 文档类资源 -> Wiki 节点 / 空间 | 资源类型支持时,使用 `wiki +move`。 |
| Wiki 节点 -> Wiki 节点 / 空间 | 支持,使用 `wiki +move --node-token`。 |
| Wiki 节点 -> Drive 文件夹 | `wiki +move-to-drive`。 |
## 状态:`CONFIRM_CONTEXT`
进入条件:`target_resolve_status=resolved`
必须:
1. 展示主题、目标、身份、搜索 owner 范围、限制和目标解析字段。
2. 说明下一步只进行搜索 / 读取。
3. 说明是否计划创建目标,但尚未执行。
4. 展示是否允许跨容器移动。
5. 在进入 `SEARCH_RECALL` 前停止并等待用户确认。
6. 如果 `owner_scope=all_visible`,明确提示候选数量可能较多,且可能包含无法移动的资源。
### 确认 UI
```text
我先确认本次收集任务。
查找主题:
目标位置:
目标解析:
执行身份:
搜索范围:
可选限制:
跨容器移动:
下一步操作:只进行搜索和读取验证,不创建目标,不移动资源。
请确认是否按以上信息开始搜索?
```
默认搜索范围文案:
```text
搜索范围:当前用户 owner / 负责的资源
```
扩展搜索范围文案:
```text
搜索范围:所有当前身份可见资源
风险提示:候选数量可能较多,且部分资源可能无法移动;后续仍会经过资源解析和内容验证。
```
如果用户修改任一字段,更新 `topic``target_location``owner_scope``constraints`,然后只重新执行受影响的 setup 状态,再次展示确认信息。
## TargetLocation
```json
{
"target_type": "drive_folder|wiki_node|wiki_space|new_drive_folder|new_wiki_node|unknown",
"target_token": "已有目标的 folder_token 或 wiki_node_token",
"parent_token": "待创建目标的父级 folder_token 或 wiki_node_token",
"space_id": "知识库空间 ID",
"target_name": "待创建目标名称",
"create_required": false,
"allow_cross_container_move": true,
"target_resolve_status": "resolved|ambiguous|unsupported|permission_denied"
}
```
| 字段 | 说明 |
|-------|------|
| `target_type` | 目标位置类型,用于决定后续创建和移动命令。 |
| `target_token` | 已有目标的可执行 token。 |
| `parent_token` | 待创建目标的父级位置 token。 |
| `space_id` | Wiki 目标所属知识空间 ID。 |
| `target_name` | 待创建目标的名称。 |
| `create_required` | 是否需要在 `EXECUTE` 阶段创建目标。 |
| `allow_cross_container_move` | 是否允许 Drive / Wiki 之间移动。 |
| `target_resolve_status` | 目标位置解析状态;不要和 `ResourceItem.item_resolve_status` 混用。 |

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流
Workflow id: `topic_move_collector`
Risk / Structure: `R2-R3` / `S3`
本文档实现已注册的主题资料收集 workflow。执行前必须先阅读 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
本文档负责定义本 workflow 的全局约束、状态机和渐进加载关系。具体阶段规则放在配套文档中,只有进入对应状态时才加载。
配套文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到某个配套文档。
## 必读上下文
执行本 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。
按阶段渐进加载其他 skill / 引用文档:
- 目标是 Wiki 或个人文档库:[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)
- 需要读取文档内容:[`../../lark-doc/SKILL.md`](../../lark-doc/SKILL.md) 和 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md)
- 需要验证 Sheet 内容:[`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)
- 需要 Drive 搜索:[`lark-drive-search.md`](lark-drive-search.md)
- 需要资源解析:[`lark-drive-inspect.md`](lark-drive-inspect.md)
## 适用范围
本 workflow 用于根据用户给出的主题、关键词或内容线索,在云空间 / 云盘 / Wiki / 电子表格等 Workspace 资源中查找相关资料,并在用户确认后统一移动到指定 Drive 文件夹或 Wiki 节点下。
适用触发语包括:
- "帮我找到和某主题相关的文档并放到这个文件夹"
- "把所有关于某项目的资料收集到知识库节点下"
- "找出包含某内容的资料,确认后移动到新建目录"
- "按这个关键词搜索我负责的资料,把相关资料归档"
默认搜索范围是当前用户 owner / 负责的 Workspace 资源,即 `owner_scope=mine`。只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才使用 `owner_scope=all_visible` 进入扩展召回模式。
不要求用户先限定文件夹或知识库范围。只有用户明确指定范围时,才使用 `--folder-tokens``--space-ids` 或其他显式限制。
## 非目标
默认不生成:
- 长篇研究报告
- 内容总结文档
- Sheet 清单或统计看板
- 自动权限治理报告
默认禁止执行:
- 未确认前创建文件夹或 Wiki 节点
- 未确认前移动资源
- 删除资源、重命名资源或修改公开权限
- 自动批量申请权限
- 把无权限或无法验证的资源加入移动计划
- 把移动权限未知或不具备移动资格的资源加入移动计划
如果用户明确要求把结果写入 Sheet / Doc切到对应专项能力本 workflow 的默认产物是移动后的资源归档结果。
## Agent 执行约束
触发本 workflow 后agent 必须:
1. 按“执行状态机”的顺序执行。
2. 维护“运行时状态”中的字段。
3. 执行某个状态前,先读取本文档 `## 渐进加载关系` 表格中该状态对应的文档。
4. 用户可见说明、字段说明和 UI 文案使用中文。
5. 状态名、字段名、枚举值、命令名保留英文稳定标识。
6.`CONFIRM_CONTEXT``CONFIRM_EXECUTION` 作为强用户确认门:前者确认主题、目标位置、身份、搜索范围、可选限制和目标解析结果后才能搜索;后者确认创建目标和移动资源后才能写入。
7. 进入 `EXECUTE` 前,不得创建目标文件夹 / 节点,也不得移动资源。
8. 必须展示每个相关性分组中的资源名称;低置信分组可以折叠,但必须可查看。
9. 默认只移动 `high` 相关资源;`medium` 资源必须由用户显式选择。
10. 即使用户可见列表分页展示,也必须维护完整内部状态。
11. `RESOURCE_RESOLVE``CONTENT_VERIFY` 是两个独立的强制阶段,不得合并;不得用搜索结果、标题或摘要直接替代 `CONTENT_VERIFY`,也不得从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`
12. 触发后锁定 `workflow_id=topic_move_collector`;执行期间不得自动切换到其他 workflow。
13. 如果认为需要切换 workflow必须停止并向用户说明原因等待用户确认。
14. `RESOURCE_RESOLVE` 是移动资格门禁;只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源才能进入默认移动链路。
## 用户展示 UI 规则
所有用户可见 UI 都必须包含:
1. 已经完成的关键结果。
2. 下一步会做什么,以及是否会产生写操作。
3. 如果 `wait_for_user=true`,明确告诉用户可以选择的动作。
4. 如果无需用户操作,明确说明将继续执行,避免用户误以为流程停住。
典型动作包括:确认继续、修改主题 / 目标 / 限制、展开更多结果、调整相关性分组、选择中相关资源、确认执行、取消执行。
## 职责边界
| 文件 | 负责 | 不负责 |
|------|------|--------------|
| `lark-drive-workflow-topic-move-collector.md` | 触发规则、全局约束、状态机、渐进加载关系、命令族白名单 | 具体阶段规则、UI 模板、执行细节 |
| `lark-drive-workflow-topic-move-collector-setup.md` | `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT``TargetLocation` | 搜索执行、相关性分类、写操作 |
| `lark-drive-workflow-topic-move-collector-recall.md` | `SEARCH_RECALL``RECALL_ENHANCE`、搜索 query 策略、去重、`CandidateItem` | 资源 token 解析、内容验证、写操作 |
| `lark-drive-workflow-topic-move-collector-resolve-verify.md` | `RESOURCE_RESOLVE``CONTENT_VERIFY`、权限矩阵、`ResourceItem` | 相关性分类、移动计划、写操作 |
| `lark-drive-workflow-topic-move-collector-review-plan.md` | `RELEVANCE_CLASSIFY``PLAN_MOVE``MovePlanItem`、展示分组 | 资源解析、内容验证、写操作执行、恢复 |
| `lark-drive-workflow-topic-move-collector-execute.md` | `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE``RollbackSnapshotItem`、执行日志 | 搜索、分类和计划 schema |
## 运行时状态
本 workflow 扩展共享 Artifact Contract。agent 在一次 workflow 运行中必须维护以下专项内部字段:
| 字段 | 说明 |
|-------|------|
| `current_state` | 当前状态机节点。 |
| `topic` | 用户确认后的主题、关键词、同义词和排除词。 |
| `target_location` | 目标位置解析结果,见 setup 文件的 `TargetLocation`。 |
| `identity` | 执行身份;默认优先 `--as user`。 |
| `owner_scope` | 搜索 owner 范围;默认 `mine`,仅搜索当前用户 owner / 负责的资源;用户明确要求扩展时才为 `all_visible`。 |
| `constraints` | 用户显式确认的类型、时间、创建人、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须展示给用户确认。 |
| `recall_query_states` | 每个基础 / 增强 query 的分页状态、累计页数、`next_page_token``has_more`、完成或阻塞状态。 |
| `candidate_items` | 搜索召回结果,包含 query 证据和去重信息。 |
| `resource_items` | 解析后的标准资源列表。 |
| `content_verify_completed` | 内容验证阶段完成标记;`resource_items` 新建或变化时重置为 `false`,只有全部资源都有验证状态或跳过原因后才设为 `true`。 |
| `relevance_groups` | 高相关、中相关、低相关、无权限、无移动权限、移动权限未知、无法验证、不可移动分组。 |
| `move_plan_items` | 经用户选择后生成的完整移动计划,包含稳定资源关联、不可变命令参数、权限快照和恢复输入。 |
| `execution_journal` | 写操作日志,用于验证和恢复。 |
| `rollback_snapshot` | 写操作前位置快照,仅用于失败恢复或用户要求恢复。 |
| `display_page_state` | 用户可见列表的分页、筛选和展开状态。 |
## 执行状态机
| 状态 | Protocol Step | 进入条件 | agent 必须执行 | 用户可见输出 | `wait_for_user` | 下一状态 |
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_INPUT` | `route` / `scope` | workflow 被触发 | 加载 setup 文档;解析主题、目标、身份和限制 | 澄清问题或解析摘要 | 必填字段缺失时为 `true` | `RESOLVE_TARGET` |
| `RESOLVE_TARGET` | `scope` | 主题和目标已获得 | 解析已有目标,或解析待创建目标;按解析状态分流 | 目标解析结果或 blocker | 非 `resolved` 时为 `true` | `resolved` 时进入 `CONFIRM_CONTEXT`;否则保持本状态 |
| `CONFIRM_CONTEXT` | `scope` | `target_resolve_status=resolved` | 展示主题、目标、身份、限制和跨容器设置 | 搜索前确认 UI | `true` | `SEARCH_RECALL` |
| `SEARCH_RECALL` | `read` | 用户确认上下文 | 用原始关键词、默认 owner 范围和显式限制执行基础召回;按每批最多 5 页自动续批 | 搜索进度 / 基础统计 | 阻塞时为 `true` | 所有基础 query 完成后进入 `RECALL_ENHANCE` |
| `RECALL_ENHANCE` | `read` | 所有基础 query 已完成 | 执行覆盖增强 query按每批最多 5 页自动续批并合并结果 | 增强召回摘要 | 阻塞时为 `true` | 所有增强 query 完成后进入 `RESOURCE_RESOLVE` |
| `RESOURCE_RESOLVE` | `read` | 候选列表已准备 | 解析 token、类型、父级位置、owner 和移动资格 | 解析进度 / 阻塞摘要 | 阻塞时为 `true` | `CONTENT_VERIFY` |
| `CONTENT_VERIFY` | `read` | 资源列表已准备 | 对支持的资源做有界内容读取,并为其余资源写入跳过原因 | 验证进度 / 验证摘要 | 阻塞时为 `true` | `RELEVANCE_CLASSIFY` |
| `RELEVANCE_CLASSIFY` | `assess` | 证据已准备 | 按相关性和可执行性分组 | 分组结果列表 | `false` | `PLAN_MOVE` |
| `PLAN_MOVE` | `assess` / `plan` | 分组完成 | 基于默认规则和用户可选项生成移动计划 | 草案计划和选择项 | `true` | `CONFIRM_EXECUTION` |
| `CONFIRM_EXECUTION` | `confirm` | 用户要求执行 | 展示创建、移动、跳过项和风险 | 写操作确认 UI | `true` | `EXECUTE``PLAN_MOVE``DONE` |
| `EXECUTE` | `execute` | 用户明确确认写操作 | 需要时先创建目标,再移动确认资源 | 执行进度 | 阻塞时为 `true` | `VERIFY``RESTORE` |
| `VERIFY` | `verify` | 执行完成 | 验证目标位置下的移动结果 | 验证结果 | 提供恢复选项时为 `true` | `DONE``RESTORE` |
| `RESTORE` | `recovery confirm` / `recovery execute` | 用户要求恢复 | 仅基于快照和日志恢复 | 恢复确认 / 结果 | 写操作前为 `true` | `VERIFY``DONE` |
| `DONE` | `done` | 无后续操作 | 停止 | 最终回复 | `false` | 结束 |
### 状态跳转硬约束
1. `RESOLVE_TARGET` 只有在 `target_resolve_status=resolved` 时才能进入 `CONFIRM_CONTEXT``ambiguous``unsupported``permission_denied` 必须保持在 `RESOLVE_TARGET` 并等待用户选择、更换目标或结束。
2. `SEARCH_RECALL` 只有在全部基础 query 的 `has_more=false` 时才能进入 `RECALL_ENHANCE`;单批达到 5 页但仍有更多结果时必须自动续批,不得提前跳转。
3. `RECALL_ENHANCE` 只有在全部增强 query 的 `has_more=false` 时才能进入 `RESOURCE_RESOLVE`;不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE`
4. `RESOURCE_RESOLVE` 必须为每个 `CandidateItem` 生成对应的 `ResourceItem`,或生成明确的解析失败 / 权限受限状态。
5. `RESOURCE_RESOLVE` 必须为每个 `ResourceItem` 写入 `move_permission_state``move_permission_basis`;完成后将 `content_verify_completed=false`,下一状态只能是 `CONTENT_VERIFY`
6. 禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使没有任何资源可以读取正文,也必须进入 `CONTENT_VERIFY`,为每项写入验证状态或跳过原因并输出验证摘要。
7. `CONTENT_VERIFY` 必须为每个 `ResourceItem` 写入内容证据、搜索证据复用说明,或不可验证原因;移动权限未知或无移动权限的资源可以只写入跳过验证原因。
8. 只有当 `resource_items` 已准备、每项都有验证状态或跳过原因,且 `content_verify_completed=true` 时,才能进入 `RELEVANCE_CLASSIFY`
9. 用户调整相关性分组后,必须回到 `RELEVANCE_CLASSIFY` 输出调整后的分组结果,再进入 `PLAN_MOVE` 重新生成计划。
### Workflow 切换门禁
只有以下情况允许考虑切换 workflow
1. 用户明确说不再做主题资料收集,改为整理整个目录结构或生成盘点方案。
2. 当前 workflow 明确无法覆盖用户的新目标。
3. 用户要求的是目录结构治理,而不是查找主题相关资料并移动。
即使满足以上条件,也不得自动切换;必须先向用户说明原因并等待确认。
## 渐进加载关系
| 状态 | 必读文档 |
|-------|---------------|
| `PARSE_INPUT` / `RESOLVE_TARGET` / `CONFIRM_CONTEXT` | [`lark-drive-workflow-topic-move-collector-setup.md`](lark-drive-workflow-topic-move-collector-setup.md) |
| `SEARCH_RECALL` / `RECALL_ENHANCE` | [`lark-drive-workflow-topic-move-collector-recall.md`](lark-drive-workflow-topic-move-collector-recall.md) |
| `RESOURCE_RESOLVE` / `CONTENT_VERIFY` | [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) |
| `RELEVANCE_CLASSIFY` / `PLAN_MOVE` | [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) |
| `CONFIRM_EXECUTION` / `EXECUTE` / `VERIFY` / `RESTORE` | [`lark-drive-workflow-topic-move-collector-execute.md`](lark-drive-workflow-topic-move-collector-execute.md) |
## 命令映射
| 状态 | 允许的命令族 | 用途 |
|-------|--------------------------|---------|
| `RESOLVE_TARGET` | `drive +inspect``wiki +node-get``wiki +space-list`、仅用于查找文件夹候选的 `drive +search` | 解析目标位置 |
| `SEARCH_RECALL` / `RECALL_ENHANCE` | `drive +search` | 搜索召回和覆盖增强 |
| `RESOURCE_RESOLVE` | `drive +inspect``wiki +node-get``drive metas batch_query`、必要时 `drive permission.members auth` | 解析标准 token、owner、权限信号和移动资格 |
| `CONTENT_VERIFY` | `docs +fetch``sheets +read``sheets +find`、必要时 `drive +preview` | 验证内容证据 |
| `EXECUTE` | `drive +create-folder``wiki +node-create``drive +move``wiki +move``wiki +move-to-drive``drive +task_result` | 执行已确认写操作 |
| `VERIFY` | `drive files list``wiki +node-list``wiki +node-get``drive +inspect``drive +task_result` | 验证执行结果 |
| `RESTORE` | `drive +move``wiki +move``drive +delete``wiki +node-delete``drive +task_result` | 恢复已确认资源并清理本次新建目标 |
## 引用文档
- [输入与目标确认](lark-drive-workflow-topic-move-collector-setup.md)
- [召回](lark-drive-workflow-topic-move-collector-recall.md)
- [资源解析与内容验证](lark-drive-workflow-topic-move-collector-resolve-verify.md)
- [审核与计划](lark-drive-workflow-topic-move-collector-review-plan.md)
- [执行](lark-drive-workflow-topic-move-collector-execute.md)
- [lark-drive-search](lark-drive-search.md)
- [lark-drive-inspect](lark-drive-inspect.md)
- [lark-drive-move](lark-drive-move.md)
- [lark-drive-create-folder](lark-drive-create-folder.md)
- [lark-drive-delete](lark-drive-delete.md)
- [lark-wiki-move](../../lark-wiki/references/lark-wiki-move.md)
- [lark-wiki-move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md)
- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md)
- [lark-wiki-node-delete](../../lark-wiki/references/lark-wiki-node-delete.md)

View File

@@ -97,7 +97,7 @@ Structure Level
2. Entry file 超过约 300 行时,优先拆 `commands``outputs``artifacts` reference。
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template不默认新增独立 workflow。
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``R2-R3/S3`
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``topic_move_collector``R2-R3/S3`
## 加载与拆分边界
@@ -108,10 +108,11 @@ Structure Level
## Workflow Registry
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|-----------------------------------------------------------------|
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
| `topic_move_collector` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-topic-move-collector.md`](lark-drive-workflow-topic-move-collector.md) | 按主题、关键词或内容线索跨容器搜索资料,验证相关性和移动资格,并在用户确认后归档到 Drive 文件夹或 Wiki 节点 |
## Workflow Loading

View File

@@ -24,6 +24,7 @@ metadata:
## 快速决策
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`

View File

@@ -88,12 +88,6 @@ func SkipWithoutTenantAccessToken(t *testing.T) {
if token == "" || appID == "" {
t.Skip("skipped: tenant test credentials not set")
}
// Scope standard env credentials to tests that explicitly require a live
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
// config and dry-run CLI subprocesses from activating the env provider.
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
}
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
@@ -245,14 +239,36 @@ func buildCommandEnv(req Request) []string {
for k, v := range req.Env {
overrides[k] = v
}
// Keep user-token injection scoped to user-only test commands so bot
// commands retain the process-level bot credentials.
if req.DefaultAs == "user" {
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
// Shared TEST_* credentials are fallbacks for explicitly identified live
// commands. Existing standard env (including dry-run fixtures) and
// per-request overrides always take precedence.
switch req.DefaultAs {
case "bot":
if !hasCredentialEnv(req.Env,
"LARKSUITE_CLI_APP_ID",
"LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN",
) {
appID := os.Getenv("TEST_BOT1_APP_ID")
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
if appID != "" && token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_TENANT_ACCESS_TOKEN"] = token
}
}
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
case "user":
if !hasCredentialEnv(req.Env,
"LARKSUITE_CLI_APP_ID",
"LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_USER_ACCESS_TOKEN",
) {
appID := os.Getenv("TEST_BOT1_APP_ID")
token := os.Getenv("TEST_USER_ACCESS_TOKEN")
if appID != "" && token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
}
}
}
for k, v := range overrides {
@@ -272,6 +288,18 @@ func buildCommandEnv(req Request) []string {
return env
}
func hasCredentialEnv(requestEnv map[string]string, keys ...string) bool {
for _, key := range keys {
if _, ok := requestEnv[key]; ok {
return true
}
if os.Getenv(key) != "" {
return true
}
}
return false
}
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {

View File

@@ -190,7 +190,7 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
assert.True(t, ran)
})
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
t.Run("accepts shared tenant credentials without mutating standard env", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
t.Setenv("LARKSUITE_CLI_APP_ID", "")
@@ -198,8 +198,8 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutTenantAccessToken(t)
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
})
require.True(t, ok)
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
@@ -274,25 +274,65 @@ func TestRunCmd(t *testing.T) {
assert.Equal(t, "hello from stdin\n", result.Stdout)
})
t.Run("injects user token env only for user commands", func(t *testing.T) {
t.Run("injects shared credentials by requested identity", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "tat_test")
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
env := buildCommandEnv(Request{DefaultAs: "user"})
env := buildCommandEnv(Request{DefaultAs: "bot"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "user"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "bot"})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
env = buildCommandEnv(Request{})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("preserves standard dry-run bot credentials", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "dry-run-app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "dry-run-secret")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
env := buildCommandEnv(Request{DefaultAs: "bot"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=dry-run-app")
assert.Contains(t, env, "LARKSUITE_CLI_APP_SECRET=dry-run-secret")
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
})
t.Run("request env overrides shared bot credentials", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
env := buildCommandEnv(Request{
DefaultAs: "bot",
Env: map[string]string{
"LARKSUITE_CLI_APP_ID": "request-app",
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN": "",
},
})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=request-app")
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=")
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")

View File

@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
"--block-id", "blkA,blkB,blkC",
"--block-id", "blkA, blkB, blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
},
{
name: "history list",
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--title", "Flag title",
"--content", "<title>Content title</title><p>body</p>",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
}
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
want string
}{
{
name: "multiline XML str_replace",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
want: "must be inline",
},
{
name: "duplicate block delete ID",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
want: "duplicate ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
})
}
}