Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441c23c0ac | ||
|
|
9b0a23b7d0 | ||
|
|
7764b5c4ca | ||
|
|
867c7e517b | ||
|
|
9c08207657 | ||
|
|
bd5eac3b50 | ||
|
|
f1b41426ce | ||
|
|
465b414c2d | ||
|
|
ca811acc51 | ||
|
|
d3efec96ce | ||
|
|
2b319b2547 | ||
|
|
e194008375 | ||
|
|
b71509dc44 | ||
|
|
2052a87e5e | ||
|
|
2168cae031 | ||
|
|
3a2a60d155 | ||
|
|
3f0e75ea0a | ||
|
|
63fe34a239 | ||
|
|
8bc19b3d3b | ||
|
|
03860cdf69 | ||
|
|
05cdedcd52 | ||
|
|
5adbbfebdb | ||
|
|
6914a02e83 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **DingTalk task workflows** — adds strict write receipts and read-back verification,
|
||||
executable parameter constraints, local dry-run plans for write shortcuts, bounded
|
||||
list scripts, and per-item verification ledgers for batch creation.
|
||||
@@ -15,6 +15,7 @@ package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
@@ -52,6 +53,7 @@ var Assign = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +assign",
|
||||
},
|
||||
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true},
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的单人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executorId":{"type":"string","description":"解析出的执行人 userId"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executorId","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
@@ -59,10 +61,11 @@ var Assign = shortcut.Shortcut{
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按姓名给某人创建并指派一条待办(自动解析 userId)",
|
||||
UseWhen: []string{"当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws todo +assign --to 张三 --task \"整理周报\""},
|
||||
AgentSummary: "按姓名给某人创建并指派一条待办(自动解析 userId)",
|
||||
UseWhen: []string{"当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws todo +assign --to 张三 --task \"整理周报\""},
|
||||
ExampleDispositions: todoStatefulPreviewExampleDispositions(),
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
@@ -72,43 +75,51 @@ var Assign = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws todo +assign --to 张三 --task "整理周报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
name := strings.TrimSpace(rt.Str("to"))
|
||||
task := strings.TrimSpace(rt.Str("task"))
|
||||
var dueMillis int64
|
||||
dueProvided := rt.Changed("due")
|
||||
if dueProvided {
|
||||
var err error
|
||||
dueMillis, err = shortcutRemindParseMillis("due", rt.Str("due"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Step 1 — resolve the assignee name to a unique userId.
|
||||
user, err := resolveUser(rt, rt.Str("to"))
|
||||
user, err := resolveUser(rt, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
preview := map[string]any{"dryRun": true, "executed": false, "preview_kind": "plan", "subject": task, "assigneeQuery": name, "executorId": user.userID}
|
||||
if dueProvided {
|
||||
preview["dueTime"] = dueMillis
|
||||
}
|
||||
return rt.Output(preview)
|
||||
}
|
||||
|
||||
// Step 2 — create the todo with that user as executor. create_personal_todo
|
||||
// accepts executorIds directly, so assignment is part of creation.
|
||||
vo := map[string]any{
|
||||
"subject": rt.Str("task"),
|
||||
"subject": task,
|
||||
"executorIds": []string{user.userID},
|
||||
}
|
||||
if rt.Changed("due") {
|
||||
// create_personal_todo stores dueTime as epoch millis (int64); the todo
|
||||
// helper feeds --due through parseISOTimeToMillis, so mirror that here
|
||||
// (shared with +remind's --at) rather than passing a raw ISO string.
|
||||
ms, err := shortcutRemindParseMillis("due", rt.Str("due"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vo["dueTime"] = ms
|
||||
if dueProvided {
|
||||
vo["dueTime"] = dueMillis
|
||||
}
|
||||
params := map[string]any{
|
||||
"PersonalTodoCreateVO": vo,
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "executorId": user.userID, "verified": true})
|
||||
return rt.Output(map[string]any{"taskId": taskID, "subject": task, "executorId": user.userID, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ var AssignMulti = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +assign-multi",
|
||||
},
|
||||
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true},
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的多人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executors":{"type":"array","description":"已解析执行人摘要","items":{"type":"string"}},"count":{"type":"integer","description":"执行人数"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executors","count","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
@@ -74,10 +75,11 @@ var AssignMulti = shortcut.Shortcut{
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
|
||||
UseWhen: []string{"当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义,就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。全部姓名都解析成功后,才用这些 userId 一次性创建这条待办并指派给所有人。会真实创建一条新的待办。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws todo +assign-multi --to \"张三,李四\" --task \"周五前提交排期\""},
|
||||
AgentSummary: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
|
||||
UseWhen: []string{"当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义,就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。全部姓名都解析成功后,才用这些 userId 一次性创建这条待办并指派给所有人。会真实创建一条新的待办。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws todo +assign-multi --to \"张三,李四\" --task \"周五前提交排期\""},
|
||||
ExampleDispositions: todoStatefulPreviewExampleDispositions(),
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
@@ -103,7 +105,6 @@ var AssignMulti = shortcut.Shortcut{
|
||||
if len(names) == 0 {
|
||||
return apperrors.NewValidation("--to 不能为空,请至少提供一个执行人姓名")
|
||||
}
|
||||
|
||||
// Resolve every name up front. Collect all failures and abort before any
|
||||
// write, so we never create a todo assigned to only some of the people.
|
||||
var (
|
||||
@@ -128,6 +129,12 @@ var AssignMulti = shortcut.Shortcut{
|
||||
if len(executorIDs) == 0 {
|
||||
return apperrors.NewValidation("没有解析出任何有效的执行人 userId,已中止")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dryRun": true, "executed": false, "preview_kind": "plan", "subject": task,
|
||||
"assigneeQueries": names, "executorIds": executorIDs, "executors": resolved, "count": len(executorIDs),
|
||||
})
|
||||
}
|
||||
|
||||
// Create the todo once, assigning all resolved executors. Params mirror the
|
||||
// todo helper's `task create` call site verbatim.
|
||||
@@ -137,9 +144,6 @@ var AssignMulti = shortcut.Shortcut{
|
||||
"executorIds": executorIDs,
|
||||
},
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": task, "count": len(executorIDs)})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -16,6 +16,7 @@ package smart
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -59,6 +60,7 @@ var Remind = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +remind",
|
||||
},
|
||||
Description: "给自己创建一条带可选截止时间的待办",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: false},
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的自用待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
@@ -77,10 +79,28 @@ var Remind = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task", Type: shortcut.FlagString, Desc: "待办标题/内容", Required: true},
|
||||
{Name: "at", Type: shortcut.FlagString, Desc: "截止时间(ISO8601,可选,不是提醒时间;如 2026-03-10T18:00:00+08:00)"},
|
||||
{Name: "at", Type: shortcut.FlagString, Desc: "截止时间(ISO8601,可选,写入 dueTime,不是提醒时间)"},
|
||||
},
|
||||
Tips: []string{`dws todo +remind --task "交周报" --at 2026-03-10T18:00:00+08:00`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
task := strings.TrimSpace(rt.Str("task"))
|
||||
var dueMillis int64
|
||||
atProvided := rt.Changed("at")
|
||||
if atProvided {
|
||||
var err error
|
||||
dueMillis, err = shortcutRemindParseMillis("at", rt.Str("at"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rt.DryRun() {
|
||||
preview := map[string]any{"dryRun": true, "executed": false, "preview_kind": "plan", "subject": task, "executor": "current_user"}
|
||||
if atProvided {
|
||||
preview["dueTime"] = dueMillis
|
||||
}
|
||||
return rt.Output(preview)
|
||||
}
|
||||
|
||||
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -91,36 +111,29 @@ var Remind = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
vo := map[string]any{
|
||||
"subject": rt.Str("task"),
|
||||
"subject": task,
|
||||
"executorIds": []string{userID},
|
||||
}
|
||||
|
||||
// Optional due time. The todo helper feeds --due through
|
||||
// parseISOTimeToMillis and stores dueTime as epoch milliseconds (int64),
|
||||
// so we do the same here rather than passing a raw string.
|
||||
if rt.Changed("at") {
|
||||
ms, err := shortcutRemindParseMillis("at", rt.Str("at"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vo["dueTime"] = ms
|
||||
if atProvided {
|
||||
vo["dueTime"] = dueMillis
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"PersonalTodoCreateVO": vo,
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
|
||||
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "verified": true})
|
||||
return rt.Output(map[string]any{"taskId": taskID, "subject": task, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,68 @@ func TestRemindShortcutRejectsInvalidAtBeforeTodoCreate(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "--at 时间格式无效") {
|
||||
t.Fatalf("error = %v, want invalid --at validation", err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "get_current_user_profile" {
|
||||
t.Fatalf("tool calls = %#v, want profile read only", fake.calls)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want local validation before any remote read", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodoCreateShortcutsPreserveEpochZeroDueTime(t *testing.T) {
|
||||
const epoch = "1970-01-01T00:00:00Z"
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
dryArgs []string
|
||||
liveArgs []string
|
||||
drySteps map[string][]calendarSmartTestStep
|
||||
wantDryCalls int
|
||||
}{
|
||||
{
|
||||
name: "assign due",
|
||||
flag: "due",
|
||||
dryArgs: []string{"todo", "+assign", "--to", "张三", "--task", "交周报", "--due", epoch, "--dry-run", "--yes"},
|
||||
liveArgs: []string{"todo", "+assign", "--to", "张三", "--task", "交周报", "--due", epoch, "--yes"},
|
||||
drySteps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}},
|
||||
wantDryCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "remind at",
|
||||
flag: "at",
|
||||
dryArgs: []string{"todo", "+remind", "--task", "交周报", "--at", epoch, "--dry-run", "--yes"},
|
||||
liveArgs: []string{"todo", "+remind", "--task", "交周报", "--at", epoch, "--yes"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
previewCaller := &calendarSmartTestCaller{steps: tt.drySteps}
|
||||
preview, _, err := runCalendarSmartCLI(t, previewCaller, tt.dryArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run with --%s: %v", tt.flag, err)
|
||||
}
|
||||
if got, exists := preview["dueTime"]; !exists || got != float64(0) {
|
||||
t.Fatalf("dry-run dueTime = %#v, exists=%v, want numeric zero", got, exists)
|
||||
}
|
||||
if got := len(previewCaller.calls); got != tt.wantDryCalls {
|
||||
t.Fatalf("dry-run tool calls = %#v", previewCaller.calls)
|
||||
}
|
||||
if previewCaller.counts["todo/create_personal_todo"] != 0 {
|
||||
t.Fatalf("dry-run attempted a write: %#v", previewCaller.calls)
|
||||
}
|
||||
|
||||
fake := &platformCoverageCaller{}
|
||||
if _, _, err := runCalendarSmartCLI(t, fake, tt.liveArgs...); err != nil {
|
||||
t.Fatalf("live request with --%s: %v", tt.flag, err)
|
||||
}
|
||||
if len(fake.calls) != 3 {
|
||||
t.Fatalf("tool calls = %#v, want resolver, create, and verification", fake.calls)
|
||||
}
|
||||
request, ok := fake.calls[1].args["PersonalTodoCreateVO"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("PersonalTodoCreateVO = %#v, want object", fake.calls[1].args["PersonalTodoCreateVO"])
|
||||
}
|
||||
if got, exists := request["dueTime"]; !exists || got != int64(0) {
|
||||
t.Fatalf("create dueTime = %#v, exists=%v, want int64 zero", got, exists)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,21 +177,45 @@ func TestCrossPlatformCoverageCalendarSmartReadCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"assign-empty-task": {"todo", "+assign", "--task", " ", "--to", "fixture person", "--yes"},
|
||||
"assign-bad-due": {"todo", "+assign", "--task", "task", "--to", "fixture person", "--due", "tomorrow", "--yes"},
|
||||
"remind-empty-task": {"todo", "+remind", "--task", " ", "--yes"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, args...); err == nil {
|
||||
t.Fatal("invalid todo shortcut arguments accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
assignDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, assignDry, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
assignPreview, _, err := runCalendarSmartCLI(t, assignDry, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assignPreview["executorId"] != "user-placeholder" || assignDry.counts["todo/create_personal_todo"] != 0 {
|
||||
t.Fatalf("assign preview/calls = %#v/%#v", assignPreview, assignDry.calls)
|
||||
}
|
||||
if _, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{}, "todo", "+assign", "--task", "task", "--to", "fixture person", "--yes"); err == nil {
|
||||
t.Fatal("assign contact resolution failure accepted")
|
||||
}
|
||||
for name, responses := range map[string]map[string][]calendarSmartTestStep{
|
||||
"call": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}},
|
||||
"verify": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"result":{}}`}}},
|
||||
"success": {"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}},
|
||||
} {
|
||||
t.Run("assign-"+name, func(t *testing.T) {
|
||||
_, _, err := runCalendarSmartCLI(t, &calendarSmartTestCaller{steps: responses}, "todo", "+assign", "--task", "task", "--to", "fixture person", "--yes")
|
||||
caller := &calendarSmartTestCaller{steps: responses}
|
||||
_, _, err := runCalendarSmartCLI(t, caller, "todo", "+assign", "--task", "task", "--to", "fixture person", "--due", smartCoverageEnd, "--yes")
|
||||
if name == "success" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := caller.calls[1].args["PersonalTodoCreateVO"].(map[string]any)
|
||||
if got := request["executorIds"].([]string); len(got) != 1 || got[0] != assignPreview["executorId"] {
|
||||
t.Fatalf("assign executorIds = %#v, preview = %#v", got, assignPreview)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("bad assign path accepted")
|
||||
}
|
||||
@@ -199,26 +223,42 @@ func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
|
||||
}
|
||||
|
||||
multiDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, multiDry, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--dry-run", "--yes"); err != nil {
|
||||
multiPreview, _, err := runCalendarSmartCLI(t, multiDry, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multiPreviewIDs := multiPreview["executorIds"].([]any)
|
||||
if len(multiPreviewIDs) != 1 || multiPreviewIDs[0] != "user-placeholder" || multiDry.counts["todo/create_personal_todo"] != 0 {
|
||||
t.Fatalf("assign-multi preview/calls = %#v/%#v", multiPreview, multiDry.calls)
|
||||
}
|
||||
multiSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, multiSuccess, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multiRequest := multiSuccess.calls[1].args["PersonalTodoCreateVO"].(map[string]any)
|
||||
if got := multiRequest["executorIds"].([]string); len(got) != 1 || got[0] != multiPreviewIDs[0] {
|
||||
t.Fatalf("assign-multi executorIds = %#v, preview = %#v", got, multiPreview)
|
||||
}
|
||||
multiFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/search_contact_by_key_word": {{text: smartContact().text}}, "todo/create_personal_todo": {{err: errors.New("write")}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, multiFailure, "todo", "+assign-multi", "--task", "task", "--to", "fixture person", "--yes"); err == nil {
|
||||
t.Fatal("assign-multi write failure accepted")
|
||||
}
|
||||
|
||||
doneDry := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneDry, "todo", "+todo-done", "--task", "needle", "--dry-run", "--yes"); err != nil {
|
||||
donePreview, _, err := runCalendarSmartCLI(t, doneDry, "todo", "+todo-done", "--task", "needle", "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if donePreview["taskId"] != "task-placeholder" || doneDry.counts["todo/update_todo_done_status"] != 0 {
|
||||
t.Fatalf("todo-done preview/calls = %#v/%#v", donePreview, doneDry.calls)
|
||||
}
|
||||
doneSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{text: `{"success":true}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","isDone":true}}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneSuccess, "todo", "+todo-done", "--task", "needle", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := doneSuccess.calls[1].args["taskId"]; got != donePreview["taskId"] {
|
||||
t.Fatalf("todo-done taskId = %#v, preview = %#v", got, donePreview)
|
||||
}
|
||||
doneCall := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"todo/get_user_todos_in_current_org": {{text: `{"success":true,"result":{"todoCards":[{"taskId":"task-placeholder","subject":"needle"}],"hasMore":false}}`}}, "todo/update_todo_done_status": {{err: errors.New("write")}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, doneCall, "todo", "+todo-done", "--task", "needle", "--yes"); err == nil {
|
||||
t.Fatal("todo-done write failure accepted")
|
||||
@@ -232,6 +272,10 @@ func TestCrossPlatformCoverageTodoSmartWriteBranches(t *testing.T) {
|
||||
if _, _, err := runCalendarSmartCLI(t, remindDry, "todo", "+remind", "--task", "task", "--at", smartCoverageEnd, "--dry-run", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remindProfileFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{err: errors.New("profile")}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, remindProfileFailure, "todo", "+remind", "--task", "task", "--yes"); err == nil {
|
||||
t.Fatal("remind profile failure accepted")
|
||||
}
|
||||
remindSuccess := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": {{text: `{"result":{"userId":"user-placeholder"}}`}}, "todo/create_personal_todo": {{text: `{"success":true,"result":{"taskId":"task-placeholder"}}`}}, "todo/get_todo_detail": {{text: `{"success":true,"result":{"todoDetailModel":{"taskId":"task-placeholder","subject":"task"}}}`}}}}
|
||||
if _, _, err := runCalendarSmartCLI(t, remindSuccess, "todo", "+remind", "--task", "task", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,6 +65,7 @@ var TodoDone = shortcut.Shortcut{
|
||||
PrimaryCLIPath: "todo +todo-done",
|
||||
},
|
||||
Description: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true},
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的完成结果","properties":{"taskId":{"type":"string","description":"已完成待办 taskId"},"subject":{"type":"string","description":"已完成待办标题"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","verified"],"additionalProperties":false}`)},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
@@ -72,10 +73,11 @@ var TodoDone = shortcut.Shortcut{
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
|
||||
UseWhen: []string{"当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」,匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws todo +todo-done --task 周报"},
|
||||
AgentSummary: "按标题关键词把我的某条待办标记完成(自动定位 taskId)",
|
||||
UseWhen: []string{"当你只记得某条待办的标题关键词、想直接把它标记完成,却不想先翻列表复制 taskId 时使用;内部先拉取你当前组织下作为执行人的待办列表,按标题(subject)包含关键词匹配:没匹配到会提示「没找到匹配待办」,匹配到多条会列出候选(标题+taskId)让你写得更精确,唯一命中时才把它标记为已完成。这会真实修改待办完成状态。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws todo +todo-done --task 周报"},
|
||||
ExampleDispositions: todoStatefulPreviewExampleDispositions(),
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
@@ -87,7 +89,6 @@ var TodoDone = shortcut.Shortcut{
|
||||
if keyword == "" {
|
||||
return apperrors.NewValidation("请用 --task 提供待办标题关键词")
|
||||
}
|
||||
|
||||
// Step 1 — list ALL my todos across pages (roleTypes defaults to executor,
|
||||
// mirroring helpers.buildListTodoTaskArgs) so a match beyond the first page
|
||||
// is still found instead of yielding a false "没找到匹配待办".
|
||||
@@ -108,12 +109,15 @@ var TodoDone = shortcut.Shortcut{
|
||||
"%q 匹配到 %d 条待办,请用更精确的关键词,或用 `dws todo task done --task-id` 指定:%s",
|
||||
keyword, len(matches), strings.Join(shortcutTodoLabels(matches), ";")))
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dryRun": true, "executed": false, "preview_kind": "plan", "taskQuery": keyword,
|
||||
"taskId": matches[0].taskID, "subject": matches[0].subject,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3 — mark it done. taskId + isDone mirror helpers `todo task done`
|
||||
// (update_todo_done_status, isDone passed as a string).
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "taskId": matches[0].taskID})
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("todo", "update_todo_done_status", map[string]any{
|
||||
"taskId": matches[0].taskID,
|
||||
"isDone": "true",
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
@@ -32,6 +33,16 @@ const (
|
||||
todoMaxPages = 40
|
||||
)
|
||||
|
||||
func todoStatefulPreviewExampleDispositions() []contract.ExampleDisposition {
|
||||
index := 0
|
||||
return []contract.ExampleDisposition{{
|
||||
Index: &index, Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
Reason: "dry-run must resolve the current remote todo or user identity before preview; the isolated Agent example runner has no remote fixture",
|
||||
Reviewed: true,
|
||||
}}
|
||||
}
|
||||
|
||||
// shortcutListAllTodoCards pages through get_user_todos_in_current_org, merging
|
||||
// every page's todoCards into one slice so callers match/filter across the FULL
|
||||
// list instead of silently seeing only the first page. base carries the call's
|
||||
|
||||
@@ -38,19 +38,24 @@ func TestAllShortcutsTodoSmartContractsAndRelatedAlias(t *testing.T) {
|
||||
name string
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
dryRun bool
|
||||
}{
|
||||
{"related", RelatedTasks.OutputRollout, RelatedTasks.Contract.Result != nil},
|
||||
{"due-today", DueToday.OutputRollout, DueToday.Contract.Result != nil},
|
||||
{"assign", Assign.OutputRollout, Assign.Contract.Result != nil},
|
||||
{"assign-multi", AssignMulti.OutputRollout, AssignMulti.Contract.Result != nil},
|
||||
{"created", CreatedTodos.OutputRollout, CreatedTodos.Contract.Result != nil},
|
||||
{"overdue", Overdue.OutputRollout, Overdue.Contract.Result != nil},
|
||||
{"todo-done", TodoDone.OutputRollout, TodoDone.Contract.Result != nil},
|
||||
{"remind", Remind.OutputRollout, Remind.Contract.Result != nil},
|
||||
{"related", RelatedTasks.OutputRollout, RelatedTasks.Contract.Result != nil, RelatedTasks.Contract.DryRun != nil},
|
||||
{"due-today", DueToday.OutputRollout, DueToday.Contract.Result != nil, DueToday.Contract.DryRun != nil},
|
||||
{"assign", Assign.OutputRollout, Assign.Contract.Result != nil, Assign.Contract.DryRun != nil},
|
||||
{"assign-multi", AssignMulti.OutputRollout, AssignMulti.Contract.Result != nil, AssignMulti.Contract.DryRun != nil},
|
||||
{"created", CreatedTodos.OutputRollout, CreatedTodos.Contract.Result != nil, CreatedTodos.Contract.DryRun != nil},
|
||||
{"overdue", Overdue.OutputRollout, Overdue.Contract.Result != nil, Overdue.Contract.DryRun != nil},
|
||||
{"todo-done", TodoDone.OutputRollout, TodoDone.Contract.Result != nil, TodoDone.Contract.DryRun != nil},
|
||||
{"remind", Remind.OutputRollout, Remind.Contract.Result != nil, Remind.Contract.DryRun != nil},
|
||||
} {
|
||||
if item.rollout != output.RolloutUnifiedActive || !item.result {
|
||||
t.Errorf("%s missing unified Result contract", item.name)
|
||||
}
|
||||
wantDryRun := item.name == "assign" || item.name == "assign-multi" || item.name == "todo-done" || item.name == "remind"
|
||||
if item.dryRun != wantDryRun {
|
||||
t.Errorf("%s dry-run declaration=%v, want %v", item.name, item.dryRun, wantDryRun)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Assign.Intent, "创建") {
|
||||
t.Fatal("legacy +assign semantics must remain create-and-assign")
|
||||
|
||||
@@ -5,6 +5,7 @@ package todo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -66,13 +67,19 @@ func requireTodoResponse(data map[string]any, operation string) (map[string]any,
|
||||
}
|
||||
|
||||
func requireTodoWriteReceipt(data map[string]any, operation string) error {
|
||||
data, err := requireTodoResponse(data, operation)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(data) == 0 {
|
||||
return todoWriteResponseError(operation, "empty_tool_response", "写响应为空,远端效果未知")
|
||||
}
|
||||
success, ok := data["success"].(bool)
|
||||
if !ok || !success {
|
||||
return todoResponseError(operation, "missing_success_receipt", "写响应没有明确的 success=true 回执")
|
||||
raw, ok := data["success"]
|
||||
if !ok {
|
||||
return todoWriteResponseError(operation, "missing_success_receipt", "写响应没有明确的 success=true 回执")
|
||||
}
|
||||
success, valid := raw.(bool)
|
||||
if !valid {
|
||||
return todoWriteResponseError(operation, "malformed_success", "写响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
return todoWriteResponseError(operation, "remote_failure", "服务明确返回 success=false")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -307,14 +314,14 @@ func VerifyCreatedTodo(rt *shortcut.RuntimeContext, data map[string]any, operati
|
||||
}
|
||||
taskID := todoCreatedTaskID(data)
|
||||
if taskID == "" {
|
||||
return "", nil, todoResponseError(operation, "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||||
return "", nil, todoWriteResponseError(operation, "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", nil, todoWriteVerificationError(operation, err)
|
||||
}
|
||||
if subject, _ := detail["subject"].(string); expectedSubject != "" && subject != expectedSubject {
|
||||
return "", nil, todoResponseError(operation, "verification_mismatch", "创建后读回的标题不一致")
|
||||
return "", nil, todoWriteResponseError(operation, "verification_mismatch", "创建后读回的标题不一致")
|
||||
}
|
||||
return taskID, detail, nil
|
||||
}
|
||||
@@ -327,11 +334,11 @@ func VerifyDoneStatus(rt *shortcut.RuntimeContext, data map[string]any, taskID s
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
return todoWriteVerificationError("todo/update_todo_done_status", err)
|
||||
}
|
||||
actual, ok := detail["isDone"].(bool)
|
||||
if !ok || actual != expected {
|
||||
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "写后读回 isDone 不一致或缺失")
|
||||
return todoWriteResponseError("todo/update_todo_done_status", "verification_mismatch", "写后读回 isDone 不一致或缺失")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -345,3 +352,48 @@ func todoResponseError(operation, reason, message string) error {
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
// todoWriteResponseError marks failures found only after a write request was
|
||||
// submitted. Callers must reconcile the returned resource or receipt before
|
||||
// retrying, especially for non-idempotent create/comment operations.
|
||||
func todoWriteResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
|
||||
// todoWriteVerificationError upgrades a read-back failure after a confirmed
|
||||
// write receipt. It preserves the original reason and cause while making the
|
||||
// unsafe retry boundary explicit to callers.
|
||||
func todoWriteVerificationError(operation string, cause error) error {
|
||||
reason := "write_verification_failed"
|
||||
origin := "mcp"
|
||||
var typed *apperrors.Error
|
||||
if stderrors.As(cause, &typed) {
|
||||
if strings.TrimSpace(typed.Reason) != "" {
|
||||
reason = typed.Reason
|
||||
}
|
||||
if strings.TrimSpace(typed.Origin) != "" {
|
||||
origin = typed.Origin
|
||||
}
|
||||
}
|
||||
options := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin(origin),
|
||||
apperrors.WithFailureStage("write_verification"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithCause(cause),
|
||||
}
|
||||
message := "写操作已提交,但读回核验失败;请先查询对账,禁止直接重试"
|
||||
if typed != nil && typed.Category == apperrors.CategoryAuth {
|
||||
return apperrors.NewAuth(message, options...)
|
||||
}
|
||||
return apperrors.NewAPI(message, options...)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ func todoContract(command, description, useWhen string, result *contract.ResultS
|
||||
}
|
||||
}
|
||||
|
||||
func withTodoDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
|
||||
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
|
||||
return decl
|
||||
}
|
||||
|
||||
func todoWriteSafety(idempotency string) contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
|
||||
}
|
||||
@@ -89,14 +94,14 @@ func createTodo(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
taskID := todoCreatedTaskID(data)
|
||||
if taskID == "" {
|
||||
return todoResponseError("todo/create_personal_todo", "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||||
return todoWriteResponseError("todo/create_personal_todo", "missing_stable_id", "创建响应缺少稳定 taskId;远端效果未知")
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
return todoWriteVerificationError("todo/create_personal_todo", err)
|
||||
}
|
||||
if subject, _ := detail["subject"].(string); subject != title {
|
||||
return todoResponseError("todo/create_personal_todo", "verification_mismatch", "创建后读回的标题不一致")
|
||||
return todoWriteResponseError("todo/create_personal_todo", "verification_mismatch", "创建后读回的标题不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||||
}
|
||||
@@ -106,12 +111,12 @@ var Create = shortcut.Shortcut{
|
||||
Service: "todo", Command: "+create", Product: "todo",
|
||||
Description: "创建待办并读回验证", Intent: "创建一条个人待办;只有取得稳定 taskId 且详情读回一致才报告成功。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
|
||||
Contract: todoContract("+create", "创建待办并读回验证", "标题和执行人已确定,需要创建待办时使用", todoObjectResult("已验证的新建待办"), `dws todo +create --title "提交报告" --executors <USER_ID>`),
|
||||
Contract: withTodoDryRun(todoContract("+create", "创建待办并读回验证", "标题和执行人已确定,需要创建待办时使用", todoObjectResult("已验证的新建待办"), `dws todo +create --title "提交报告" --executors <USER_ID>`), contract.DryRunPreviewRequest, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "待办标题", Required: true},
|
||||
{Name: "executors", Type: shortcut.FlagStringSlice, Desc: "执行人 userId", Required: true},
|
||||
{Name: "due", Type: shortcut.FlagString, Desc: "截止时间(ISO8601)"},
|
||||
{Name: "priority", Type: shortcut.FlagInt, Desc: "优先级 10/20/30/40"},
|
||||
{Name: "priority", Type: shortcut.FlagInt, Desc: "优先级;--priority 仅接受 10/20/30/40"},
|
||||
},
|
||||
Execute: createTodo,
|
||||
}
|
||||
@@ -154,14 +159,14 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
return todoWriteVerificationError("todo/update_todo_task", err)
|
||||
}
|
||||
for key, expected := range request {
|
||||
if key == "taskId" {
|
||||
continue
|
||||
}
|
||||
if !todoUpdateFieldMatches(key, detail[key], expected) {
|
||||
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
|
||||
return todoWriteResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||||
@@ -214,12 +219,12 @@ var Update = shortcut.Shortcut{
|
||||
Service: "todo", Command: "+update", Product: "todo",
|
||||
Description: "更新待办并读回验证", Intent: "按 taskId 修改标题、截止时间或优先级,并严格读回核验。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
|
||||
Contract: todoContract("+update", "更新待办并读回验证", "已知 taskId 且需要修改待办字段时使用", todoObjectResult("已验证的更新结果"), `dws todo +update --task-id <TASK_ID> --title "新标题"`),
|
||||
Contract: withTodoDryRun(todoContract("+update", "更新待办并读回验证", "已知 taskId 且需要修改待办字段时使用", todoObjectResult("已验证的更新结果"), `dws todo +update --task-id <TASK_ID> --title "新标题"`), contract.DryRunPreviewRequest, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新标题"},
|
||||
{Name: "due", Type: shortcut.FlagString, Desc: "新截止时间(ISO8601)"},
|
||||
{Name: "priority", Type: shortcut.FlagInt, Desc: "新优先级 10/20/30/40"},
|
||||
{Name: "priority", Type: shortcut.FlagInt, Desc: "新优先级;--priority 仅接受 10/20/30/40"},
|
||||
},
|
||||
Execute: updateTodo,
|
||||
}
|
||||
@@ -249,11 +254,11 @@ func setTodoDone(rt *shortcut.RuntimeContext, target bool) error {
|
||||
}
|
||||
detail, err := readTodoDetail(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
return todoWriteVerificationError("todo/update_todo_done_status", err)
|
||||
}
|
||||
done, ok := detail["isDone"].(bool)
|
||||
if !ok || done != target {
|
||||
return todoResponseError("todo/update_todo_done_status", "verification_mismatch", "完成状态读回不一致或缺失")
|
||||
return todoWriteResponseError("todo/update_todo_done_status", "verification_mismatch", "完成状态读回不一致或缺失")
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "isDone": target, "verified": true, "alreadyInTargetState": false})
|
||||
}
|
||||
@@ -267,7 +272,7 @@ func doneShortcut(command string, target bool) shortcut.Shortcut {
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: command, Product: "todo", Description: description, Intent: description,
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("idempotent"),
|
||||
Contract: todoContract(command, description, "已知 taskId 且需要改变完成状态时使用", todoObjectResult("已验证的待办状态"), "dws todo "+command+" --task-id <TASK_ID>"),
|
||||
Contract: withTodoDryRun(todoContract(command, description, "已知 taskId 且需要改变完成状态时使用", todoObjectResult("已验证的待办状态"), "dws todo "+command+" --task-id <TASK_ID>"), contract.DryRunPreviewRequest, false),
|
||||
Flags: []shortcut.Flag{{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true}},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { return setTodoDone(rt, target) },
|
||||
}
|
||||
@@ -323,7 +328,7 @@ var Comment = shortcut.Shortcut{
|
||||
Service: "todo", Command: "+comment", Product: "todo",
|
||||
Description: "添加待办评论并读回验证", Intent: "向指定待办添加评论;取得稳定 commentId 并从评论列表读回才报告成功。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("non_idempotent"),
|
||||
Contract: todoContract("+comment", "添加待办评论并读回验证", "已知 taskId 且需要发表明确评论时使用", todoObjectResult("已验证的新评论"), `dws todo +comment --task-id <TASK_ID> --content "已处理"`),
|
||||
Contract: withTodoDryRun(todoContract("+comment", "添加待办评论并读回验证", "已知 taskId 且需要发表明确评论时使用", todoObjectResult("已验证的新评论"), `dws todo +comment --task-id <TASK_ID> --content "已处理"`), contract.DryRunPreviewRequest, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "评论内容", Required: true},
|
||||
@@ -353,7 +358,7 @@ var Comment = shortcut.Shortcut{
|
||||
}
|
||||
after, err := listAllTodoComments(rt, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
return todoWriteVerificationError("todo/add_todo_comment", err)
|
||||
}
|
||||
matches := make([]map[string]any, 0, 1)
|
||||
for _, comment := range after {
|
||||
@@ -364,7 +369,7 @@ var Comment = shortcut.Shortcut{
|
||||
}
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return todoResponseError("todo/add_todo_comment", "verification_ambiguous", "评论写入后无法在完整列表中唯一识别新增评论")
|
||||
return todoWriteResponseError("todo/add_todo_comment", "verification_ambiguous", "评论写入后无法在完整列表中唯一识别新增评论")
|
||||
}
|
||||
commentID := todoStableString(matches[0], "commentId", "id")
|
||||
return rt.Output(map[string]any{"taskId": taskID, "commentId": commentID, "content": content, "verified": true})
|
||||
@@ -401,10 +406,10 @@ var Reminder = shortcut.Shortcut{
|
||||
Service: "todo", Command: "+reminder", Product: "todo",
|
||||
Description: "设置或清除待办提醒(仅终端回执)", Intent: "设置或清除提醒;上游无提醒读接口,结果固定 verified=false。",
|
||||
Risk: shortcut.RiskWrite, Safety: todoWriteSafety("unknown"),
|
||||
Contract: todoContract("+reminder", "设置或清除待办提醒(仅终端回执)", "接受无法读回核验且需要设置/清除提醒时使用", &contract.ResultSpec{
|
||||
Contract: withTodoDryRun(todoContract("+reminder", "设置或清除待办提醒(仅终端回执)", "接受无法读回核验且需要设置/清除提醒时使用", &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"提醒写入终端回执","properties":{"taskId":{"type":"string","description":"待办 taskId"},"action":{"type":"string","description":"set 或 clear"},"terminalReceipt":{"type":"boolean","description":"是否取得终端成功回执"},"verified":{"type":"boolean","description":"固定 false;上游无法读回提醒规则"}},"required":["taskId","action","terminalReceipt","verified"],"additionalProperties":false}`),
|
||||
}, `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30`),
|
||||
}, `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30`), contract.DryRunPreviewRequest, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办 taskId", Required: true},
|
||||
{Name: "clear", Type: shortcut.FlagBool, Desc: "清除全部提醒规则"},
|
||||
@@ -419,6 +424,9 @@ var Reminder = shortcut.Shortcut{
|
||||
if clear == (baseTime != "") {
|
||||
return apperrors.NewValidation("必须且只能选择 --clear 或 --base-time")
|
||||
}
|
||||
if clear && (rt.Changed("due-date-offset") || rt.Changed("at")) {
|
||||
return apperrors.NewValidation("--clear 不能与 --due-date-offset 或 --at 同时使用")
|
||||
}
|
||||
tool := "reset_todo_reminder"
|
||||
params := map[string]any{"todoReminderUpdateRequest": map[string]any{"taskId": taskID, "reminderRules": []any{}}}
|
||||
action := "clear"
|
||||
@@ -428,11 +436,17 @@ var Reminder = shortcut.Shortcut{
|
||||
if !rt.Changed("due-date-offset") {
|
||||
return apperrors.NewValidation("--base-time=dueTime 要求 --due-date-offset")
|
||||
}
|
||||
if rt.Changed("at") {
|
||||
return apperrors.NewValidation("--base-time=dueTime 不能同时提供 --at")
|
||||
}
|
||||
request["dueDateOffset"] = strconv.Itoa(rt.Int("due-date-offset"))
|
||||
} else {
|
||||
if !rt.Changed("at") {
|
||||
return apperrors.NewValidation("--base-time=customTime 要求 --at")
|
||||
}
|
||||
if rt.Changed("due-date-offset") {
|
||||
return apperrors.NewValidation("--base-time=customTime 不能同时提供 --due-date-offset")
|
||||
}
|
||||
millis, err := parseTodoMillis("at", rt.Str("at"))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -64,18 +64,21 @@ var GetMyTasks = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码"},
|
||||
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量"},
|
||||
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码;--page 必须大于 0;--size 必须在 1-20"},
|
||||
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量;--page 必须大于 0;--size 必须在 1-20"},
|
||||
{Name: "status", Type: shortcut.FlagString, Enum: []string{"true", "false"}, Desc: "true=已完成, false=未完成"},
|
||||
{Name: "priority", Type: shortcut.FlagStringSlice, Desc: "优先级过滤: 10/20/30/40"},
|
||||
{Name: "role-types", Type: shortcut.FlagStringSlice, Default: "executor", Desc: "角色类型: creator/executor/participant"},
|
||||
{Name: "priority", Type: shortcut.FlagStringSlice, Desc: "优先级过滤;--priority 仅接受 10/20/30/40"},
|
||||
{Name: "role-types", Type: shortcut.FlagStringSlice, Default: "executor", Desc: "角色类型;--role-types 仅接受 creator/executor/participant"},
|
||||
{Name: "plan-finish-start", Type: shortcut.FlagInt, Desc: "截止时间范围开始(Unix 毫秒时间戳)"},
|
||||
{Name: "plan-finish-end", Type: shortcut.FlagInt, Desc: "截止时间范围结束(Unix 毫秒时间戳)"},
|
||||
{Name: "all", Type: shortcut.FlagBool, Desc: "遍历全部分页;达到安全页数上限仍有下一页时失败"},
|
||||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "--all 的最大页数(1-40)"},
|
||||
{Name: "all", Type: shortcut.FlagBool, Desc: "遍历全部分页;--max-pages 仅用于 --all,且必须在 1-40"},
|
||||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "40", Desc: "--all 的最大页数;--max-pages 仅用于 --all,且必须在 1-40"},
|
||||
},
|
||||
Tips: []string{`dws todo +get-my-tasks --status false --priority 40,30`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Changed("max-pages") && !rt.Bool("all") {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_page_limit", "--max-pages 只能与 --all 一起使用")
|
||||
}
|
||||
page, err := strconv.Atoi(rt.Str("page"))
|
||||
if err != nil || page < 1 {
|
||||
return todoResponseError("todo/+get-my-tasks", "invalid_page", "--page 必须是大于 0 的整数")
|
||||
@@ -587,8 +590,8 @@ var ListComment = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "待办任务 ID", Required: true},
|
||||
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码"},
|
||||
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量"},
|
||||
{Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "页码;--page 必须大于 0;--size 必须在 1-20"},
|
||||
{Name: "size", Type: shortcut.FlagString, Default: "20", Desc: "每页数量;--page 必须大于 0;--size 必须在 1-20"},
|
||||
},
|
||||
Tips: []string{`dws todo +list-comment --task-id <taskId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
@@ -83,6 +84,18 @@ func todoRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[
|
||||
return shortcut.RuntimeContextForTest(cmd, declaration)
|
||||
}
|
||||
|
||||
func requireTodoExecutionStartedError(t *testing.T, err error, wantReason string) {
|
||||
t.Helper()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != wantReason || typed.ExecutionStarted == nil || !*typed.ExecutionStarted ||
|
||||
!typed.RetryableSet || typed.Retryable || typed.Cause == nil {
|
||||
t.Fatalf("post-write failure = %#v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCommonStrictBranches(t *testing.T) {
|
||||
for _, response := range []map[string]any{{}, {"success": "yes"}, {"success": false}} {
|
||||
if _, err := requireTodoResponse(response, "todo/test"); err == nil {
|
||||
@@ -92,7 +105,7 @@ func TestCrossPlatformCoverageTodoCommonStrictBranches(t *testing.T) {
|
||||
if _, err := requireTodoResponse(map[string]any{"result": map[string]any{}}, "todo/test"); err != nil {
|
||||
t.Fatalf("response without optional success rejected: %v", err)
|
||||
}
|
||||
for _, response := range []map[string]any{{}, {"result": map[string]any{}}, {"success": false}} {
|
||||
for _, response := range []map[string]any{{}, {"result": map[string]any{}}, {"success": "yes"}, {"success": false}} {
|
||||
if err := requireTodoWriteReceipt(response, "todo/test"); err == nil {
|
||||
t.Fatalf("bad receipt accepted: %#v", response)
|
||||
}
|
||||
@@ -297,6 +310,12 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Create, createSuccess, "--title", "x", "--executors", "u", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createReadFailure := &todoCoverageCaller{responses: map[string][]string{
|
||||
"create_personal_todo": {`{"success":true,"result":{"taskId":"task-1"}}`},
|
||||
"get_todo_detail": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
err := runTodoCoverage(t, Create, createReadFailure, "--title", "x", "--executors", "u", "--yes")
|
||||
requireTodoExecutionStartedError(t, err, "missing_detail")
|
||||
|
||||
updateCases := map[string]map[string][]string{
|
||||
"call": {"update_todo_task": {"__ERROR__"}},
|
||||
@@ -463,13 +482,22 @@ func TestCrossPlatformCoverageTodoCommentAndReminder(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Comment, commentSuccess, "--task-id", "task-1", "--content", "body", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commentReadFailure := &todoCoverageCaller{responses: map[string][]string{
|
||||
"list_todo_comment": {emptyPage, `{"success":true,"result":{}}`},
|
||||
"add_todo_comment": {`{"success":true}`},
|
||||
}}
|
||||
err := runTodoCoverage(t, Comment, commentReadFailure, "--task-id", "task-1", "--content", "body", "--yes")
|
||||
requireTodoExecutionStartedError(t, err, "missing_collection")
|
||||
|
||||
for name, args := range map[string][]string{
|
||||
"none": {"--task-id", "task-1", "--yes"},
|
||||
"both": {"--task-id", "task-1", "--clear", "--base-time", "dueTime", "--yes"},
|
||||
"due-offset": {"--task-id", "task-1", "--base-time", "dueTime", "--yes"},
|
||||
"custom-at": {"--task-id", "task-1", "--base-time", "customTime", "--yes"},
|
||||
"custom-value": {"--task-id", "task-1", "--base-time", "customTime", "--at", "bad", "--yes"},
|
||||
"none": {"--task-id", "task-1", "--yes"},
|
||||
"both": {"--task-id", "task-1", "--clear", "--base-time", "dueTime", "--yes"},
|
||||
"clear-offset": {"--task-id", "task-1", "--clear", "--due-date-offset", "-30", "--yes"},
|
||||
"due-offset": {"--task-id", "task-1", "--base-time", "dueTime", "--yes"},
|
||||
"due-at": {"--task-id", "task-1", "--base-time", "dueTime", "--due-date-offset", "-30", "--at", todoCoverageTime, "--yes"},
|
||||
"custom-at": {"--task-id", "task-1", "--base-time", "customTime", "--yes"},
|
||||
"custom-offset": {"--task-id", "task-1", "--base-time", "customTime", "--at", todoCoverageTime, "--due-date-offset", "-30", "--yes"},
|
||||
"custom-value": {"--task-id", "task-1", "--base-time", "customTime", "--at", "bad", "--yes"},
|
||||
} {
|
||||
t.Run("reminder-"+name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, Reminder, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
@@ -576,11 +604,12 @@ func TestCrossPlatformCoverageTodoVerificationHelpers(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageTodoGetMyTasksAndListLeaves(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"page": {"--page", "0"},
|
||||
"size": {"--size", "21"},
|
||||
"priority": {"--priority", "11"},
|
||||
"role": {"--role-types", "owner"},
|
||||
"max": {"--all", "--max-pages", "0"},
|
||||
"page": {"--page", "0"},
|
||||
"size": {"--size", "21"},
|
||||
"priority": {"--priority", "11"},
|
||||
"role": {"--role-types", "owner"},
|
||||
"max": {"--all", "--max-pages", "0"},
|
||||
"max-alone": {"--max-pages", "1"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runTodoCoverage(t, GetMyTasks, &todoCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package todo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -47,26 +49,70 @@ func TestAllShortcutsTodoLifecycleContractsAreComplete(t *testing.T) {
|
||||
rollout output.RolloutState
|
||||
result bool
|
||||
safety string
|
||||
dryRun bool
|
||||
}{
|
||||
{"create", Create.OutputRollout, Create.Contract.Result != nil, Create.Safety.Confirmation},
|
||||
{"update", Update.OutputRollout, Update.Contract.Result != nil, Update.Safety.Confirmation},
|
||||
{"complete", Complete.OutputRollout, Complete.Contract.Result != nil, Complete.Safety.Confirmation},
|
||||
{"reopen", Reopen.OutputRollout, Reopen.Contract.Result != nil, Reopen.Safety.Confirmation},
|
||||
{"search", Search.OutputRollout, Search.Contract.Result != nil, Search.Safety.Confirmation},
|
||||
{"comment", Comment.OutputRollout, Comment.Contract.Result != nil, Comment.Safety.Confirmation},
|
||||
{"upload-attachment", UploadAttachment.OutputRollout, UploadAttachment.Contract.Result != nil, UploadAttachment.Safety.Confirmation},
|
||||
{"reminder", Reminder.OutputRollout, Reminder.Contract.Result != nil, Reminder.Safety.Confirmation},
|
||||
{"get-my-tasks", GetMyTasks.OutputRollout, GetMyTasks.Contract.Result != nil, GetMyTasks.Safety.Confirmation},
|
||||
{"list-sub", ListSub.OutputRollout, ListSub.Contract.Result != nil, ListSub.Safety.Confirmation},
|
||||
{"get", Get.OutputRollout, Get.Contract.Result != nil, Get.Safety.Confirmation},
|
||||
{"list-attachment", ListAttachment.OutputRollout, ListAttachment.Contract.Result != nil, ListAttachment.Safety.Confirmation},
|
||||
{"list-comment", ListComment.OutputRollout, ListComment.Contract.Result != nil, ListComment.Safety.Confirmation},
|
||||
{"create", Create.OutputRollout, Create.Contract.Result != nil, Create.Safety.Confirmation, Create.Contract.DryRun != nil},
|
||||
{"update", Update.OutputRollout, Update.Contract.Result != nil, Update.Safety.Confirmation, Update.Contract.DryRun != nil},
|
||||
{"complete", Complete.OutputRollout, Complete.Contract.Result != nil, Complete.Safety.Confirmation, Complete.Contract.DryRun != nil},
|
||||
{"reopen", Reopen.OutputRollout, Reopen.Contract.Result != nil, Reopen.Safety.Confirmation, Reopen.Contract.DryRun != nil},
|
||||
{"search", Search.OutputRollout, Search.Contract.Result != nil, Search.Safety.Confirmation, Search.Contract.DryRun != nil},
|
||||
{"comment", Comment.OutputRollout, Comment.Contract.Result != nil, Comment.Safety.Confirmation, Comment.Contract.DryRun != nil},
|
||||
{"upload-attachment", UploadAttachment.OutputRollout, UploadAttachment.Contract.Result != nil, UploadAttachment.Safety.Confirmation, UploadAttachment.Contract.DryRun != nil},
|
||||
{"reminder", Reminder.OutputRollout, Reminder.Contract.Result != nil, Reminder.Safety.Confirmation, Reminder.Contract.DryRun != nil},
|
||||
{"get-my-tasks", GetMyTasks.OutputRollout, GetMyTasks.Contract.Result != nil, GetMyTasks.Safety.Confirmation, GetMyTasks.Contract.DryRun != nil},
|
||||
{"list-sub", ListSub.OutputRollout, ListSub.Contract.Result != nil, ListSub.Safety.Confirmation, ListSub.Contract.DryRun != nil},
|
||||
{"get", Get.OutputRollout, Get.Contract.Result != nil, Get.Safety.Confirmation, Get.Contract.DryRun != nil},
|
||||
{"list-attachment", ListAttachment.OutputRollout, ListAttachment.Contract.Result != nil, ListAttachment.Safety.Confirmation, ListAttachment.Contract.DryRun != nil},
|
||||
{"list-comment", ListComment.OutputRollout, ListComment.Contract.Result != nil, ListComment.Safety.Confirmation, ListComment.Contract.DryRun != nil},
|
||||
} {
|
||||
if item.rollout != output.RolloutUnifiedActive || !item.result || item.safety == "" {
|
||||
t.Errorf("%s contract incomplete: rollout=%q result=%v safety=%q", item.name, item.rollout, item.result, item.safety)
|
||||
}
|
||||
wantDryRun := item.safety == "user_required" && item.name != "upload-attachment"
|
||||
if item.dryRun != wantDryRun {
|
||||
t.Errorf("%s dry-run declaration=%v, want %v", item.name, item.dryRun, wantDryRun)
|
||||
}
|
||||
}
|
||||
if text := string(Reminder.Contract.Result.DataSchema); !strings.Contains(text, `"verified"`) || !strings.Contains(Reminder.Intent, "verified=false") {
|
||||
t.Fatalf("reminder must publish terminal-only verification boundary: %s / %s", text, Reminder.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoWriteReceiptMarksExecutionStarted(t *testing.T) {
|
||||
err := requireTodoWriteReceipt(map[string]any{}, "todo/create_personal_todo")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("write failure safety = started %v retryable_set %v retryable %v", typed.ExecutionStarted, typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoWriteVerificationPreservesReason(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cause error
|
||||
wantReason string
|
||||
wantOrigin string
|
||||
wantType apperrors.Category
|
||||
}{
|
||||
{"api", todoResponseError("todo/get_todo_detail", "missing_detail", "missing detail"), "missing_detail", "mcp", apperrors.CategoryAPI},
|
||||
{"plain", errors.New("read timeout"), "write_verification_failed", "mcp", apperrors.CategoryAPI},
|
||||
{"auth", apperrors.NewAuth("expired", apperrors.WithReason("auth_expired"), apperrors.WithOrigin("gateway")), "auth_expired", "gateway", apperrors.CategoryAuth},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := todoWriteVerificationError("todo/create_personal_todo", tc.cause)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != tc.wantType || typed.Reason != tc.wantReason || typed.Origin != tc.wantOrigin ||
|
||||
typed.FailureStage != "write_verification" ||
|
||||
typed.ExecutionStarted == nil || !*typed.ExecutionStarted ||
|
||||
!typed.RetryableSet || typed.Retryable || !errors.Is(err, tc.cause) {
|
||||
t.Fatalf("verification failure = %#v, cause preserved=%v", typed, errors.Is(err, tc.cause))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ metadata:
|
||||
|
||||
# 钉钉待办 Skill
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
## 执行契约
|
||||
|
||||
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
|
||||
|
||||
> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。
|
||||
- 执行第一个 `dws` 操作前完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md);当前任务已加载则不重复读取。
|
||||
- 先把请求拆成有序步骤,再逐步选入口。已知命令直接执行,不先查 Help、Schema 或 Shortcut Catalog;只有当前 leaf 的 flag 或安全语义确实不明时才查精确 leaf。
|
||||
- 所有命令加 `--format json`,按结构化业务返回判断结果。后续 ID 只取自本次真实返回;零匹配、多匹配或类型不明时停止并消歧。
|
||||
- 写操作遵循最终 Runtime gate。需要确认时先说明对象、动作和影响,用户确认后才追加 `--yes`;不要把 `--yes` 写入存储示例。
|
||||
- 写后必须核验。非幂等写超时、缺少稳定 ID 或读回失败时先查询对账,禁止盲目重放。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcuts(无专用脚本/recipe 时优先)
|
||||
@@ -44,80 +46,74 @@ metadata:
|
||||
| `dws todo +update` | write | 更新待办并读回验证 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
## 路由优先级
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors <userId>` |
|
||||
| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) |
|
||||
| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` |
|
||||
| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"` |
|
||||
| "批量建待办" | 按 SOP-4 逐条创建、收集 `taskId` 并批量回读 |
|
||||
| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` |
|
||||
| "逾期待办" | `python scripts/todo_overdue_check.py` |
|
||||
| "标记完成 / 重开" | `dws todo task done --task-id <taskId> --status true\|false` |
|
||||
| "修改标题/截止时间/优先级" | `dws todo task update --task-id <taskId> ...` |
|
||||
| "删除待办" | `dws todo task delete --task-id <taskId>`(需用户确认) |
|
||||
上面的通用 Shortcut 优先规则只适用于**一个 Shortcut 完整覆盖当前步骤**的情况。不要因为请求里出现“创建”就默认使用 `+remind` / `+create`。
|
||||
|
||||
## 标准 SOP(必遵流程)
|
||||
1. **先选创建入口**:按姓名指派用 `+assign` / `+assign-multi`;给自己记一条且后续只有搜索、详情或清理时用 `+remind`;已有真实 `userId` 且只需创建、回读和清理时用 `+create`。
|
||||
2. **组合生命周期从原子创建开始**:创建后还要按状态/优先级/角色/日期/页码列举,或继续更新、完成/重开、提醒、评论、附件、成员、子待办、标签,或一次创建多个对象时,使用 `todo task create`。不要用创建 Shortcut 代替第一步。
|
||||
3. **后续按步骤选最窄入口**:聚合、搜索和核验完整由 Shortcut 覆盖时使用 `+get-my-tasks`、`+get-related-tasks`、`+due-today`、`+overdue`、`+search`、`+get`、`+complete`、`+reopen`、`+update`、`+comment`、`+reminder` 或 `+list-*`;需要原子特有参数、动态子资源 ID、多对象或中间状态时用原子命令。
|
||||
4. **确定性批量/汇总使用脚本**:批量创建、今天/明天/本周汇总、逾期扫描分别使用 bundled script。
|
||||
5. **跨步骤只传稳定 ID**:Shortcut 与原子命令可以共存,但只传递规范化后的 `taskId`、`commentId`、`attachmentId`、`tagCode`、`userId`;不要假设两类入口的完整返回结构相同。
|
||||
|
||||
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 taskId。每条命令必须带 `--format json`。创建/完成/删除后**必须**回读验证,不要凭创建返回或口头计划就结束。
|
||||
## Golden Routes
|
||||
|
||||
### SOP-1 建待办(create-todo)
|
||||
| 用户意图 | 首选入口 | 关键结果 / 边界 |
|
||||
|---|---|---|
|
||||
| 给自己创建,随后只搜索、看详情或清理 | `dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json` | 自动解析当前用户;`--at` 是截止时间,不是提醒时间 |
|
||||
| “给张三建待办” | `dws todo +assign --to "张三" --task "<标题>" --format json` | 姓名必须唯一解析后才创建 |
|
||||
| “给张三、李四建同一条待办” | `dws todo +assign-multi --to "张三,李四" --task "<标题>" --format json` | 任一姓名不唯一则零写入 |
|
||||
| 已有 `userId`,只创建、回读和清理 | `dws todo +create --title "<标题>" --executors <USER_ID> [--due "<截止ISO>"] [--priority 10\|20\|30\|40] --format json` | 返回稳定 `taskId`,并读回核验标题 |
|
||||
| 创建后还要筛选、变更资源或创建多个对象 | `dws todo task create --title "<标题>" --executors <USER_ID> ... --format json` | 从 `result.taskId` 进入组合生命周期;不要以 `+remind` / `+create` 起步 |
|
||||
| 今天到期 / 已逾期 | `dws todo +due-today --format json` / `dws todo +overdue --format json` | 均有界拉全分页;空集合也是成功结果 |
|
||||
| 当前组织下我的执行待办 | `dws todo +get-my-tasks --all --status false --format json` | `--all` 达到 40 页仍未耗尽会失败,不伪装完整 |
|
||||
| 与我相关的全部待办 | `dws todo +get-related-tasks --format json` | 创建人、执行人、参与人三种角色并集,按 `taskId` 去重 |
|
||||
| 按标题关键词查询 | `dws todo +search --query "<关键词>" --format json` | 搜索与 list 不混用;跨全部分页匹配 |
|
||||
| 已知 `taskId` 查详情 | `dws todo +get --task-id <TASK_ID> --format json` | 详情必须回传同一个稳定 `taskId` |
|
||||
| 已知 `taskId` 完成 / 重开 | `dws todo +complete --task-id <TASK_ID> --format json` / `dws todo +reopen ...` | 先读当前状态,避免重复写,再读回核验 |
|
||||
| 只记得标题,标记完成 | `dws todo +todo-done --task "<关键词>" --format json` | 仅唯一命中时写;零个或多个候选均停止 |
|
||||
| 修改标题、截止时间或优先级 | `dws todo +update --task-id <TASK_ID> ... --format json` | 至少指定一个待改字段;写后逐字段核验 |
|
||||
| 设置独立提醒 | `dws todo +reminder --task-id <TASK_ID> --base-time customTime --at "<提醒ISO>" --format json` | 上游无提醒查询接口,只能返回终端写回执,`verified=false` |
|
||||
| 基于截止时间提前提醒 | `dws todo +reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30 --format json` | 待办必须已有截止时间;偏移单位为分钟 |
|
||||
| 清除全部提醒 | `dws todo +reminder --task-id <TASK_ID> --clear --format json` | 清除写操作;不能与提醒参数混用 |
|
||||
| 批量创建 | `python scripts/todo_batch_create.py <todos.json> --dry-run` | 预览返回稳定 `planDigest`;执行必须提交用户确认的同一摘要,内容变化会在零调用时拒绝 |
|
||||
| 今天/明天/本周汇总 | `python scripts/todo_daily_summary.py today\|tomorrow\|week` | 走 `+get-my-tasks --all`,只纳入范围内且有截止时间的未完成待办 |
|
||||
|
||||
**触发**:建待办/任务提醒/指派任务/TODO。
|
||||
## 低频原子能力
|
||||
|
||||
1. **解析执行者(必须)**:指定姓名 → `dws aisearch person --query "<姓名>" --dimension name --format json` 取 `userId`;未指定 → `dws contact user get-self --format json` 取当前用户 `userId`;多人逐个搜索后英文逗号拼接。
|
||||
2. **执行(必须)**:`dws todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40> --format json`;有截止时间加 `--due "<ISO>"`;循环待办加 `--due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"`。
|
||||
3. **验证(必须)**:从返回取 `taskId`/`todoTaskId`,立即 `dws todo task get --task-id <taskId> --format json` 回读。
|
||||
组合请求先读 [组合生命周期](references/02-task.md)。以下常用原子命令已审定,直接执行,不要先猜别名或查 Help:
|
||||
|
||||
**禁止**:跳过执行者解析直接传姓名、用 `task detail` 取详情(正确是 `task get`)、创建后不回读。
|
||||
| 意图 | 命令骨架 |
|
||||
|---|---|
|
||||
| 解析自己 / 姓名 | `dws contact user get-self --format json` / `dws aisearch person --query "<姓名>" --dimension name --format json` |
|
||||
| 创建 / 子待办 | `dws todo task create ...` / `dws todo task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID> ...` |
|
||||
| 列表 / 详情 | `dws todo task list [--status true\|false] [--priority ...] [--role-types ...] [--page N --size N] ...` / `dws todo task get --task-id <TASK_ID>` |
|
||||
| 更新 / 完成或重开 | `dws todo task update --task-id <TASK_ID> ...` / `dws todo task done --task-id <TASK_ID> --status true\|false` |
|
||||
| 增删执行人 / 参与人 | `task add-executor` / `task remove-executor` / `task add-participant` / `task remove-participant`,均传真实 `taskId` 与 `userId` |
|
||||
| 评论 | `comment add` / `comment list` / `comment delete`;删除使用列表返回的真实 `commentId` |
|
||||
| 附件 | `task add-attachment --file <绝对路径>` / `task list-attachment` / `task remove-attachment` |
|
||||
| 提醒 | `task add-reminder` 添加单条;`task reset-reminder` 替换全部或清空 |
|
||||
| 标签 | `tag create` / `tag list` / `tag update` / `tag add` / `tag delete`;只使用真实 `tagCode` |
|
||||
| 删除待办 | `dws todo task delete --task-id <TASK_ID> --format json` |
|
||||
|
||||
### SOP-2 查询待办(query-todo)
|
||||
删除类操作必须由用户确认;若用户在当前请求中已明确授权“办完后删除/清理本次创建对象”,该授权只覆盖本次记录的精确 ID。附件上传会真实传输本地文件,不能用来试探权限。
|
||||
|
||||
**触发**:查待办/今天本周待办/未完成/已完成。
|
||||
## 关键约束
|
||||
|
||||
1. **执行(必须)**:`dws todo task list --status false|true --format json`(`false`=未完成、`true`=已完成、不传=全部);`hasMore=true` 必须翻页。
|
||||
2. **摘要脚本(必须)**:今天/本周未完成 → `python scripts/todo_daily_summary.py today|tomorrow|week`;逾期 → `python scripts/todo_overdue_check.py`。
|
||||
3. **详情(必须)**:`dws todo task get --task-id <taskId> --format json`;按主题筛选先 `task list` 再按标题过滤,**禁止**编造主题查询 flag。
|
||||
- 标题、URL、展示序号都不是 `taskId`。已知 ID 直接行动;未知 ID 用列表/搜索定位,零匹配或多匹配时停止。
|
||||
- 待办公开命令统一使用 `--task-id`;`--id` / `--ids` 只是隐藏兼容别名,不要写入新命令或示例。
|
||||
- 优先级:低=10、普通=20、较高/高/重要=30、紧急/最高/P0=40。
|
||||
- `--due` / `+remind --at` 表示 deadline;独立 reminder 必须走 `+reminder`。
|
||||
- 自定义时间提醒的原子 flag 是 `--reminder-time-stamp`;不要把 Shortcut 的 `--at` 套到 `task add-reminder`。
|
||||
- `task list` 使用 `--status`,不要写 `--done`;详情是 `task get`,不存在 `task detail`。
|
||||
- “待办标签”始终属于 Todo,使用 `dws todo tag ...`;绝不能解释成 Git tag、通讯录标签或其他产品标签。
|
||||
- 创建和评论是非幂等写。超时、缺少稳定 ID 或读回失败时保留“可能已提交/未核验”状态,先查询对账,禁止盲目重放。
|
||||
- 所有命令加 `--format json`;写 Shortcut 按 Runtime 安全契约确认,确认前不得自行附加 `--yes`。
|
||||
- 会后行动项来自听记时先走 `dingtalk-minutes`;OA 审批走 `dingtalk-misc`;时间块和会议走 `dingtalk-calendar`。
|
||||
|
||||
**禁止**:写 `--done true`(用 `--status true`)、编造主题筛选参数。
|
||||
## 按需参考
|
||||
|
||||
### SOP-3 完成 / 重开 / 改 / 删(mutate-todo)
|
||||
|
||||
**触发**:标记完成/重开/改标题截止优先级/删待办。
|
||||
|
||||
1. **执行(必须)**:完成/重开 `dws todo task done --task-id <taskId> --status true|false --format json`;修改 `dws todo task update --task-id <taskId> ...`;删除 `dws todo task delete --task-id <taskId>`(**必须**先与用户确认)。
|
||||
2. **验证(必须)**:`task done`/`update` 后用 `task get` 或对应 `task list --status ...` 回读确认;`delete` 后用 `task get` 确认已不存在或列表已移除。
|
||||
|
||||
**禁止**:未确认就删除、用 `update --done`(首选 `task done --status`)、改动后不回读。
|
||||
|
||||
### SOP-4 批量建待办(batch-create)
|
||||
|
||||
**触发**:批量建待办/一次建多条。
|
||||
|
||||
1. **解析(必须)**:执行者姓名先批量解析成真实 `userId`;单批最多 30 条。
|
||||
2. **执行(必须)**:对每条待办执行 `dws todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40> [--due "<ISO>"] --format json`,逐条收集返回的 `taskId`/`todoTaskId`。可并行执行,但不得丢失“输入条目 → taskId”对应关系。
|
||||
3. **验证(必须)**:对全部新建 `taskId` 执行 `dws todo task get --task-id <taskId> --format json` 回读;多 ID 按共享并行规则处理,全部成功后才能报告批量创建完成。
|
||||
|
||||
**禁止**:只统计创建命令退出码、不保留 taskId、创建后不回读、在执行者位置传姓名。
|
||||
|
||||
## 参数硬约束
|
||||
|
||||
- 任务详情只用 `dws todo task get --task-id <taskId>`;不要写 `task detail`。
|
||||
- 完成状态首选 `dws todo task done --task-id <taskId> --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。
|
||||
- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。
|
||||
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
|
||||
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
|
||||
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
|
||||
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
|
||||
- 所有 dws 命令带 `--format json`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId`
|
||||
- 会后从听记自动建待办 → 切到 `dingtalk-minutes`
|
||||
- 项目进度汇总写文档 → 切到 `dingtalk-doc`
|
||||
## 局部意图与短流程
|
||||
|
||||
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
|
||||
- [局部意图消歧](references/intent-guide.md)
|
||||
- [轻量流程](references/lite-recipes.md)
|
||||
- [组合流程](references/02-task.md)
|
||||
- [完整命令参考](references/todo.md)
|
||||
|
||||
@@ -1,19 +1,70 @@
|
||||
# 任务管理
|
||||
# Todo 组合生命周期
|
||||
|
||||
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`todo-query-ops`。其中 `todo-query-ops` 统一覆盖 list/get/complete/reopen/topic-filter。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。
|
||||
当一个请求包含多个资源动作并需要传递 `taskId`、`commentId`、`attachmentId`、`tagCode` 或 `userId` 时使用本文件。先拆步骤,再逐步选择能完整覆盖当前步骤的 Shortcut 或原子命令;不要因为第一步是创建就让 `+remind` / `+create` 接管整个流程。
|
||||
|
||||
## Recipe 速查(非 SKILL lite)
|
||||
## 通用骨架
|
||||
|
||||
| Recipe | 步骤(命令均须 `--format json`,下略) |
|
||||
|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读标题、执行者、优先级和截止时间 |
|
||||
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --priority <10/20/30/40> --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读循环规则和任务字段 |
|
||||
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
|
||||
1. 列出完整动作序列和需要传递的 ID;同一链路使用同一个 profile。
|
||||
2. 选择创建入口:
|
||||
- 按姓名创建并指派:`+assign` / `+assign-multi`。
|
||||
- 给自己简单创建,后续只有搜索、详情或清理:`+remind`。
|
||||
- 已有真实 `userId`,后续只有回读或清理:`+create`。
|
||||
- 后续需要列表筛选、更新、完成/重开、提醒、评论、附件、成员、子待办、标签或多对象:使用原子 `task create`。
|
||||
3. 原子创建前解析执行人:未指定执行人用 `dws contact user get-self --format json`;指定姓名用 `dws aisearch person --query "<姓名>" --dimension name --format json`,唯一匹配后取 `userId`。
|
||||
4. 原子创建:
|
||||
|
||||
## Full / 组合(固定路线)
|
||||
```bash
|
||||
dws todo task create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] [--recurrence "<规则>"] --format json
|
||||
```
|
||||
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| generate-progress-report | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
|
||||
| batch-create-todo | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --query "<姓名>" --dimension name` → 取真实 `userId`;同名时先消歧<br>3. 逐条执行 `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>`,从每次响应收集真实 `todoTaskId`;单批超 30 条须用户确认<br>4. 对全部 `todoTaskId` 并行执行 `todo task get --task-id <todoTaskId>`,逐项核对标题、执行者、优先级和截止时间;不能只以退出码或创建响应作为成功证据 |
|
||||
| assign-and-notify | 1. `aisearch person --query "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>` → 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读任务,确认无误后再通知<br>4. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --conversation-id <openConversationId> --content "<通知内容>"` |
|
||||
5. 原子创建只从成功响应的 `result.taskId` 取 ID;创建 Shortcut 从其成功业务结果的 `taskId` 取 ID。不要跨入口猜字段层级。
|
||||
6. 按下表执行后续步骤。Shortcut 完整覆盖一个步骤时可使用其核验能力;需要原子特有参数、动态子资源 ID、多对象或中间状态时使用原子命令。
|
||||
7. 只清理本次创建且账本中已有精确 ID 的对象。删除后用 `task get` 不存在或对应列表移除验证。
|
||||
|
||||
## 步骤路由表
|
||||
|
||||
| 当前步骤 | 首选条件 | 入口 / 核验 |
|
||||
|---|---|---|
|
||||
| 当前组织我的未完成待办 | 需要有界拉全 | `+get-my-tasks --all --status false` |
|
||||
| 与我相关的全部待办 | 需要按创建人/执行人/参与人合并去重 | `+get-related-tasks` |
|
||||
| 今天到期 / 逾期 | 需要完整聚合 | `+due-today` / `+overdue` |
|
||||
| 按标题关键词定位 | 需要跨页唯一匹配 | `+search --query "<关键词>"` |
|
||||
| 按状态/优先级/角色/日期/页码列举 | 需要指定筛选条件或控制分页 | `task list --status ... --priority ... --role-types ... --plan-finish-date-start ... --plan-finish-date-end ... --page N --size N`;`hasMore=true` 继续下一页 |
|
||||
| 已知 ID 查详情 | 需要稳定投影和 ID 核验 | `+get --task-id <TASK_ID>`;需要原始字段时用 `task get` |
|
||||
| 单次更新并读回 | Shortcut flags 完整覆盖 | `+update --task-id <TASK_ID> ...` |
|
||||
| 更新后还需中间列表或多对象对比 | 需要原子中间状态 | `task update --task-id <TASK_ID> ...`,再 `task get/list` |
|
||||
| 单次完成 / 重开并读回 | 不需要按状态列表检查 | `+complete` / `+reopen` |
|
||||
| 完成/重开后按状态列表确认,或处理多个/子待办 | 需要中间状态 | `task done --task-id <TASK_ID> --status true\|false`,再 `task list/get` |
|
||||
| 创建子待办 | 需要父 ID、执行人和子 ID | `task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID> ...`,取 `result.taskId` |
|
||||
| 列出子待办 | 只需聚合读取 | `+list-sub --task-id <PARENT_ID>`;需要原始子 ID/状态时用 `task list-sub` |
|
||||
| 增删执行人 | 成员变更 | `task add-executor/remove-executor --task-id <TASK_ID> --executors <USER_IDS>`,再 `task get` |
|
||||
| 增删参与人 | 关注关系变更 | `task add-participant/remove-participant --task-id <TASK_ID> --participants <USER_IDS>`,再 `task get` |
|
||||
| 添加一条评论并读回 | 不需要删除评论 | `+comment --task-id <TASK_ID> --content "<内容>"` |
|
||||
| 多评论、评论 ID 或删除评论 | 需要动态评论 ID | `comment add --task-id <TASK_ID> --content "<内容>"`;`comment list --task-id <TASK_ID>`;从列表取真实 ID 后 `comment delete --task-id <TASK_ID> --comment-id <COMMENT_ID>` |
|
||||
| 上传附件 | 本地文件已存在 | `task add-attachment --task-id <TASK_ID> --file <绝对路径>`,再列附件 |
|
||||
| 查看或移除附件 | 只读可用 Shortcut;移除需要动态 ID | `+list-attachment --task-id <TASK_ID>`;需要 `attachmentId` 时用 `task list-attachment`,再 `task remove-attachment --task-id <TASK_ID> --attachment-id <ATTACHMENT_ID>` |
|
||||
| 添加或清除一条提醒意图 | Shortcut 参数完整覆盖 | `+reminder`;只报告终端写回执 |
|
||||
| 多提醒、整体替换或清空后继续重设 | 需要原子规则数组 | 截止偏移:`task add-reminder --base-time dueTime --due-date-offset -30`;自定义:`--base-time customTime --reminder-time-stamp "<提醒ISO>"`;替换/清空:`task reset-reminder [--reminder-rules '<JSON数组>']` |
|
||||
| 标签生命周期 | 需要真实 `tagCode` | `tag create --name "<名称>"`;`tag list`;改名 `tag update --user-tags '[{"code":"<TAG_CODE>","name":"<新名称>"}]'`;关联 `tag add --task-id <TASK_ID> --tag-codes <TAG_CODES>`;删除 `tag delete --tag-codes <TAG_CODES>` |
|
||||
| 删除待办 | 已获得本次授权和真实 ID | `task delete --task-id <TASK_ID>`,再 `task get` 验证不存在 |
|
||||
|
||||
所有表中命令都加前缀 `dws todo` 和后缀 `--format json`。
|
||||
|
||||
## 动态 ID 账本
|
||||
|
||||
| ID | 只允许来自 | 禁止来源 |
|
||||
|---|---|---|
|
||||
| `taskId` | 原子 `task create/create-sub/get/list` 或 Todo Shortcut 的成功业务返回 | 标题、URL、展示序号、其他 case |
|
||||
| `commentId` | 同一 `taskId` 的 `+comment` 成功结果或 `comment list` 返回 | 评论文本或猜测 |
|
||||
| `attachmentId` | 同一 `taskId` 的 `task list-attachment` 返回 | 文件名或本地路径 |
|
||||
| `tagCode` | `todo tag create/list` 返回的 `result.userTags[].code` | 标签名或 Git tag |
|
||||
| `userId` | `contact user get-self` 或 `aisearch person` 的唯一匹配 | 姓名、`me/self`、其他 profile |
|
||||
|
||||
“待办标签”只能调用 `dws todo tag ...`;禁止运行 `git tag`。本地待办附件只能调用 `dws todo task add-attachment`,不要改走 Drive。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- `unknown command/flag`:查该精确 leaf 的 `--help` 后最多修正一次,不轮询相似命令。
|
||||
- 创建、评论等非幂等写超时或返回不明:保留“可能已提交”,先按标题/父 ID 查询对账,不自动重试。
|
||||
- 提醒接口没有查询能力:成功时只报告服务端接受写入;失败时保留原错误。
|
||||
- 任一步失败后仍按账本清理已创建的临时对象;未取得稳定 ID 的对象不得猜 ID 清理。
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# todo 局部意图消歧
|
||||
# Todo 局部意图消歧
|
||||
|
||||
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
|
||||
| 用户说 | 应该用 | 不要用 | 边界 |
|
||||
|---|---|---|---|
|
||||
| “帮我记一下明天要做的事” | `todo +remind` | `doc` | 这是个人待办;`--at` 写截止时间 |
|
||||
| “给自己留一个明天下午的时间块” | `calendar event create` | `todo` | 时间块属于日历事件 |
|
||||
| “明早 9 点提醒我提交周报” | 先创建待办,再用 `todo +reminder --base-time customTime --at ...` | 把 `+remind --at` 当提醒 | 独立提醒与截止时间是两种资源字段 |
|
||||
| “截止前 30 分钟提醒” | `todo +reminder --base-time dueTime --due-date-offset -30` | `calendar` | 待办必须先有截止时间 |
|
||||
| “每天重复提醒我” | `todo task create --due ... --recurrence ...`,必要时再加 reminder | 只写 reminder | recurrence 管重复待办;reminder 管单条待办提醒规则 |
|
||||
| “创建/改名/删除待办标签” | `dws todo tag create/update/delete` | `git tag`、通讯录标签、其他产品标签 | “待办标签”由 Todo 产品拥有;后续只传真实 `tagCode` |
|
||||
| “提交日报/周报” | `dingtalk-misc` | `todo` | 日志产品不是个人待办 |
|
||||
| “审批这个申请” | `dingtalk-misc` | `todo` | OA 审批任务与个人待办不同 |
|
||||
| “把会议行动项建成待办” | 先走 `dingtalk-minutes` 取真实行动项,再进入 Todo 创建路线 | 凭标题猜行动项 | 来源证据归听记,任务对象归 Todo |
|
||||
|
||||
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|
||||
|---|---|---|---|---|
|
||||
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` | 个人待办提醒,非文档内容 |
|
||||
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
|
||||
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
|
||||
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
|
||||
| "把最近几次关于XX的会议汇总成报告" | 按主题汇总多次听记 | #5 generate-topic-report | #7 meeting-followup | #7 是单次会议听记跟进;多次会议按主题汇总属于工作汇报 |
|
||||
提醒写入目前没有对应的查询接口,成功响应只能证明服务端接受了写请求,不能声称已经读回核验规则。
|
||||
|
||||
@@ -1,151 +1,68 @@
|
||||
# todo Lite Recipe
|
||||
# Todo 单步与短流程
|
||||
|
||||
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
|
||||
用于单步 Todo 意图,也用于组合请求中被 Shortcut 完整覆盖的独立步骤。组合请求先按 [02-task.md](02-task.md) 拆分;涉及列表筛选、资源写入、多对象或动态子资源 ID 时,不要用创建 Shortcut 代替原子 `task create`。
|
||||
|
||||
## #2 任务管理
|
||||
## 创建
|
||||
|
||||
### create-todo
|
||||
|
||||
1. 确定执行者:指定姓名 → `aisearch person --query "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
|
||||
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <优先级>`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
|
||||
|
||||
### todo-query-ops
|
||||
|
||||
- 查询:`todo task list [--status false|true]`(不传=全部)
|
||||
- 详情:`todo task get --task-id <id>`
|
||||
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
|
||||
- 按主题筛选:list 后按标题关键词过滤
|
||||
|
||||
|
||||
## #7 听记与会后
|
||||
|
||||
> 产品命令完整参考见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。full recipe 见 [07-minutes.md](../../dingtalk-minutes/references/07-minutes.md)。
|
||||
|
||||
### minutes-query(查询与获取)
|
||||
|
||||
> **scope 选择铁律(P2 真实 badcase)**:`list` 后的 scope 决定查询范围,最高频误判是把"我能访问的所有听记"错选成 `mine`:
|
||||
> - `mine` = **仅我自己创建/发起**的听记(范围最窄)
|
||||
> - `shared` = **仅他人共享给我**的听记
|
||||
> - `all` = **我可访问的全部**(= mine ∪ shared,范围最广)
|
||||
> - **判定口诀**:query 含"访问/权限/可见/能看到/所有/我的"等覆盖范围语义 → 一律走 `all`;**仅当**明确说"我创建的/我发起的/我录的" → 才走 `mine`。**不要因为句子里有"我"字就退化成 `mine`。**
|
||||
> - 错误:`我能访问的所有听记` → `list mine`(漏掉共享给我的,判定不通过)
|
||||
> - 正确:`我能访问的所有听记` → `dws minutes list all --format json`
|
||||
|
||||
> **选对象铁律(0605 P2 EDD badcase 提炼,命令对了但选错听记 = 整任务失败)**:list/搜索拿到结果后,必须按语义精准锁定目标听记,详见 [minutes.md](../../dingtalk-minutes/references/minutes.md)「选对象铁律 S1~S6」。速记:
|
||||
> - **S1 跨组织汇总**:以 list 返回的 `taskUuid + title + organizationName` 三元组为准逐条照抄,组织与听记不可张冠李戴。
|
||||
> - **S2 "最近一次某类会议"**:先 `--query "<主题词>"`(如周会)过滤出该类,再在候选里取时间最新;主题匹配优先级高于时间。
|
||||
> - **S3 比时长最长**:必须读 `durationMicros` 字段做数值比较,禁止凭印象/标题猜,口头结论与操作的 taskUuid 须自洽。
|
||||
> - **S4 内容为空**:锁定 taskUuid 后所有 get/update 复用同一 id;某字段为空就如实说,**禁止偷偷切换到另一条听记**。
|
||||
> - **S5 模糊日期匹配不到**:日期可能是"会议主题日期"而非"创建日期",按标题关键词搜,精确日期没命中就放宽 ±7 天/同主题候选请用户确认,**禁止直接报"找不到"**。搜索回退策略:① 先 `--query "<主题关键词>"` 不带日期搜 → ② 若结果过多则加 `--start/--end` 扩大到 ±7 天 → ③ 列出候选让用户确认。
|
||||
> - **S6 给标题没给 id**:必须先 `list all --query "<标题关键词>"` 定位 taskUuid 再 update/get,禁止凭记忆直接填 `--id` 跳过定位。
|
||||
|
||||
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
|
||||
### 给自己
|
||||
|
||||
```bash
|
||||
# 我可访问的所有听记(默认)
|
||||
dws minutes list all --format json
|
||||
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
|
||||
dws minutes list all --query "周会" --format json
|
||||
# 按时间范围筛选(ISO-8601 格式)
|
||||
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
|
||||
# 关键词 + 时间组合
|
||||
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
|
||||
# 限制条数
|
||||
dws minutes list mine --limit 5 --format json
|
||||
# 共享给我的听记
|
||||
dws minutes list shared --query "ROI" --format json
|
||||
dws todo +remind --task "提交周报" --at "2026-08-19T18:00:00+08:00" --format json
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--query "<关键词>"` | 服务端关键词搜索 |
|
||||
| `--start "<ISO-8601>"` | 开始时间 |
|
||||
| `--end "<ISO-8601>"` | 结束时间 |
|
||||
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
|
||||
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
|
||||
|
||||
**获取详情**:
|
||||
|
||||
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
|
||||
- 单篇摘要:`minutes get summary --id <taskUuid>`
|
||||
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
|
||||
- 关键词:`minutes get keywords --id <taskUuid>`
|
||||
- 待办事项:`minutes get todos --id <taskUuid>`
|
||||
- 基础信息:`minutes get info --id <taskUuid>`
|
||||
- 音频地址:`minutes get audio --id <taskUuid>`
|
||||
|
||||
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
|
||||
|
||||
### minutes-edit(编辑与替换)
|
||||
|
||||
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
|
||||
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
|
||||
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
|
||||
- **替换发言人**:先统一搜人取得 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
|
||||
- 查询 dingUid:`aisearch person --query "姓名" --dimension name --format json` → 取 `userId`
|
||||
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
|
||||
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
|
||||
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
|
||||
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
|
||||
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
|
||||
|
||||
### minutes-tag(标签/分组查询)
|
||||
|
||||
- 查询标签列表:`minutes tag list` → 返回用户在听记页面创建的所有标签/分组(含 tagId 和名称)
|
||||
- 按标签查听记:`minutes tag query --tag-id <tagId> [--limit 20] [--cursor <token>]`
|
||||
- tagId 来自 `tag list` 返回值,不可编造
|
||||
- 支持分页,`--cursor` 传入上一次返回的 nextToken
|
||||
|
||||
**典型链路**:用户说"帮我看看'周会'标签下的听记" →
|
||||
1. `dws minutes tag list --format json` → 按名称匹配找到 tagId
|
||||
2. `dws minutes tag query --tag-id <tagId> --format json`
|
||||
|
||||
### minutes-permission(权限管理)
|
||||
|
||||
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
|
||||
- 需先通过 `aisearch person --query "<姓名>" --dimension name` 获取目标 userId
|
||||
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
|
||||
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
|
||||
|
||||
### minutes-upload(音频上传)
|
||||
`--at` 是截止时间。需要 9 点弹出独立提醒时,在取得 `taskId` 后再执行:
|
||||
|
||||
```bash
|
||||
# 创建上传会话
|
||||
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
|
||||
# 上传完成后确认
|
||||
dws minutes upload complete --session-id <sid> --format json
|
||||
# 取消上传
|
||||
dws minutes upload cancel --session-id <sid> --format json
|
||||
dws todo +reminder --task-id <TASK_ID> --base-time customTime --at "2026-08-19T09:00:00+08:00" --format json
|
||||
```
|
||||
|
||||
### 最佳实践案例速查(详见 [minutes.md](../../dingtalk-minutes/references/minutes.md))
|
||||
### 按姓名指派
|
||||
|
||||
| 案例 | 场景 | 正确链路 |
|
||||
|------|------|----------|
|
||||
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
|
||||
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
|
||||
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
|
||||
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
|
||||
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
|
||||
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `aisearch person` 并发 → 置信度 ≥70% → 确认 → 替换 |
|
||||
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `aisearch person` |
|
||||
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
|
||||
| 案例 9 | 按标签筛选听记 | `tag list` → 按名称匹配 tagId → `tag query --tag-id <tagId>`;**禁止**编造 tagId |
|
||||
```bash
|
||||
dws todo +assign --to "张三" --task "周五前提交排期" --format json
|
||||
dws todo +assign-multi --to "张三,李四" --task "周五前提交排期" --format json
|
||||
```
|
||||
|
||||
### 听记取数深度约束(0609 点踩 case 提炼)
|
||||
姓名必须唯一解析。多人场景任一姓名失败时整条待办不会创建。
|
||||
|
||||
> 详细说明见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。
|
||||
### 已有 userId
|
||||
|
||||
- **转写原文硬约束**:用户诉求含「聚焦原话/逐字/沟通细节/具体讨论了什么」等词时,**必须先调 `get transcription` 翻页拉全**,禁止仅凭 summary 出稿
|
||||
- **数据源下钻**:听记维度**必须 `get summary`(或 `get transcription`)读正文**,严禁只取标题列表;scope 用 `all`;空时换窗重试或标注
|
||||
- **听记链接解析**:聊天消息中遇到听记链接(`flash_minutes_detail`/`SHANJI`)→ 解析 `minutesId` → 调 `minutes get summary/transcription`,禁止把链接降级为关键词
|
||||
- **忠实性约束**:源数据无某要素(行动项/责任人/数字)时禁止生成;统计字段基于实际取数计数,不得编造
|
||||
- **多源全覆盖**:用户枚举多数据源时每个来源都必须调对应工具;瞬时错误重试;如实声明缺失来源,禁编无来源数字
|
||||
```bash
|
||||
dws todo +create --title "修复线上问题" --executors <USER_ID> --priority 40 --format json
|
||||
```
|
||||
|
||||
### 间接意图识别铁律
|
||||
成功结果必须含稳定 `taskId` 且 `verified=true`。缺少 ID、写后读回失败或超时都不能重放创建;先搜索/列表对账。创建后还要按状态/优先级/角色/日期/页码列举,或更新、完成/重开、提醒、评论、附件、成员、子待办、标签,或创建多个对象时,创建步骤改用原子 `task create`。
|
||||
|
||||
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
|
||||
## 查询与定位
|
||||
|
||||
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
|
||||
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
|
||||
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
|
||||
```bash
|
||||
dws todo +get-my-tasks --all --status false --format json
|
||||
dws todo +get-related-tasks --format json
|
||||
dws todo +search --query "周报" --format json
|
||||
dws todo +get --task-id <TASK_ID> --format json
|
||||
```
|
||||
|
||||
- list 用于枚举,search 用于标题关键词,get 用于已知稳定 ID。
|
||||
- 空集合是正常成功;响应结构缺失、分页未耗尽或 ID 缺失必须失败。
|
||||
- 零匹配或多匹配时不得自行选择第一条。
|
||||
- 用户明确要求状态、优先级、角色、日期或页码筛选时,使用 `todo task list` 的对应 flag;不要先拉取无关数据再在本地猜范围。
|
||||
|
||||
## 完成、重开与更新
|
||||
|
||||
```bash
|
||||
dws todo +complete --task-id <TASK_ID> --format json
|
||||
dws todo +reopen --task-id <TASK_ID> --format json
|
||||
dws todo +update --task-id <TASK_ID> --title "新标题" --format json
|
||||
```
|
||||
|
||||
只记得标题时用 `+todo-done --task "<关键词>"`;它只会在唯一命中时修改。单次变更优先使用这些自带读回的 Shortcut;需要中间列表、多对象对比或原子特有参数时切到 [组合生命周期](02-task.md) 的原子路线。
|
||||
|
||||
## 汇总与批量
|
||||
|
||||
```bash
|
||||
python scripts/todo_daily_summary.py today
|
||||
python scripts/todo_overdue_check.py
|
||||
python scripts/todo_batch_create.py todos.json --dry-run
|
||||
```
|
||||
|
||||
批量脚本单批最多 30 条。先用 dry-run 展示精确批次和稳定 `planDigest`;取得用户对该摘要的明确确认后,执行时必须同时传 `--confirm-digest <PLAN_DIGEST>` 与 `--yes`。脚本重新规范化输入并核对摘要,不匹配时零调用拒绝;匹配后只把 Runtime 确认传给逐条创建,回读不携带该标记。输出保留逐项 ledger;`unknown` 表示写可能已提交,`unverified` 表示已有 `taskId` 但读回未通过,两者都需对账,不能自动重试。
|
||||
|
||||
@@ -108,7 +108,7 @@ Usage:
|
||||
dws todo task delete [flags]
|
||||
Example:
|
||||
dws todo task delete --task-id <taskId>
|
||||
dws todo task delete --task-id <taskId> --yes
|
||||
dws todo task delete --task-id <taskId>
|
||||
Flags:
|
||||
--task-id string 待办任务 ID (必填)
|
||||
```
|
||||
@@ -146,7 +146,7 @@ Usage:
|
||||
dws todo comment delete [flags]
|
||||
Example:
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId>
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId>
|
||||
Flags:
|
||||
--task-id string 待办任务 ID (必填)
|
||||
--comment-id string 评论 ID (必填)
|
||||
@@ -237,7 +237,7 @@ Usage:
|
||||
dws todo task remove-attachment [flags]
|
||||
Example:
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
|
||||
Flags:
|
||||
--attachment-id string 待办附件 ID (必填)
|
||||
--task-id string 待办任务 ID (必填)
|
||||
@@ -314,7 +314,7 @@ Usage:
|
||||
dws todo tag delete [flags]
|
||||
Example:
|
||||
dws todo tag delete --tag-codes code1,code2
|
||||
dws todo tag delete --tag-codes code1,code2 --yes
|
||||
dws todo tag delete --tag-codes code1,code2
|
||||
Flags:
|
||||
--tag-codes string 要删除的标签编码列表,逗号分隔 (必填)
|
||||
--yes 跳过交互确认,直接执行删除
|
||||
@@ -410,7 +410,7 @@ dws todo task update --task-id <taskId> --title "新标题" --priority 40 --form
|
||||
dws todo task done --task-id <taskId> --status true --format json
|
||||
|
||||
# 6. 删除待办
|
||||
dws todo task delete --task-id <taskId> --yes --format json
|
||||
dws todo task delete --task-id <taskId> --format json
|
||||
|
||||
# 7. 给待办新增评论
|
||||
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
|
||||
@@ -419,7 +419,7 @@ dws todo comment add --task-id <taskId> --content "已开始处理" --format jso
|
||||
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
|
||||
|
||||
# 9. 删除待办评论
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --format json
|
||||
|
||||
# 10. 添加待办执行人
|
||||
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
|
||||
@@ -446,7 +446,7 @@ dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --forma
|
||||
# 20. 查询待办附件列表
|
||||
dws todo task list-attachment --task-id <taskId> --format json
|
||||
# 21. 删除待办附件
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes --format json
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --format json
|
||||
|
||||
# 22. 查询待办标签列表
|
||||
dws todo tag list --format json
|
||||
@@ -457,7 +457,7 @@ dws todo tag add --task-id <taskId> --tag-codes code1,code2 --format json
|
||||
# 25. 更新待办标签
|
||||
dws todo tag update --user-tags '[{"tagCode":"code1","name":"新名称"}]' --format json
|
||||
# 26. 删除待办标签
|
||||
dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
dws todo tag delete --tag-codes code1,code2 --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
@@ -476,9 +476,10 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
|
||||
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
|
||||
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
|
||||
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
|
||||
- `--due` 与 `todo +remind --at` 都只写截止时间 dueTime,不要解释成“几点提醒”
|
||||
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
|
||||
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
|
||||
- 独立精确提醒使用 `todo +reminder --base-time customTime --at <ISO>`,或原子命令 `task add-reminder --base-time customTime --reminder-time-stamp <ISO>`
|
||||
- 基于截止时间的提前提醒使用 `todo +reminder --base-time dueTime --due-date-offset <分钟>`;待办必须已有截止时间
|
||||
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
|
||||
- `task list` 的 `--priority` 支持逗号分隔多个优先级值(如 `40,30,10`),用于同时筛选多个优先级
|
||||
- `task list` 的 `--role-types` 支持 `creator`/`executor`/`participant`,可在一次调用中同时传入多个角色用逗号分隔(如 `--role-types creator,executor`),无需分多次查询;不传时默认按 `executor` 查询
|
||||
@@ -486,7 +487,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
- todo 是个人待办管理产品
|
||||
- `task update` 可同时修改标题/优先级/截止时间/完成状态
|
||||
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
|
||||
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
|
||||
- `task delete` 为不可逆操作;先与用户确认,再由运行时确认门执行
|
||||
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
|
||||
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
|
||||
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
|
||||
@@ -501,7 +502,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
- `tag add` 用于给指定待办打标签,`--task-id` 可通过 `task list` 或 `task create` 获取;`--tag-codes` 可通过 `tag list` 获取
|
||||
- `tag create` 用于创建新标签,`--name` 为标签名称 (必填)
|
||||
- `tag update` 用于更新已有标签信息,`--user-tags` 格式同 `tag create`
|
||||
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认;传 `--yes` 可跳过交互提示,建议加 `--yes` 并与用户确认
|
||||
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认
|
||||
- `tag add`(给待办打标签)与 `tag delete`(删除标签定义)作用不同:前者是关联关系,后者是删除标签本身
|
||||
|
||||
|
||||
@@ -510,5 +511,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|------|------|------|
|
||||
| [todo_daily_summary.py](../scripts/todo_daily_summary.py) | 查看今天/明天/本周未完成待办汇总 | `python todo_daily_summary.py today` |
|
||||
| [todo_batch_create.py](../scripts/todo_batch_create.py) | 从 JSON 文件批量创建待办 | `python todo_batch_create.py todos.json` |
|
||||
| [todo_batch_create.py](../scripts/todo_batch_create.py) | 从 JSON 文件批量创建待办 | `python todo_batch_create.py todos.json --dry-run` |
|
||||
| [todo_overdue_check.py](../scripts/todo_overdue_check.py) | 扫描逾期待办输出逾期清单 | `python todo_overdue_check.py` |
|
||||
|
||||
批量创建必须先预览精确批次和 `planDigest`;用户明确确认该摘要后,执行时同时提交 `--confirm-digest <PLAN_DIGEST>` 与 `--yes`。脚本会重新规范化输入并核对摘要:任何实际写入内容变化都会在首次 `dws` 调用前拒绝。
|
||||
|
||||
@@ -1,159 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 JSON 文件批量创建待办(含优先级、截止时间、执行者)
|
||||
"""Create up to 30 Todo tasks, preserve every task ID, and verify each task."""
|
||||
|
||||
用法:
|
||||
python todo_batch_create.py todos.json
|
||||
python todo_batch_create.py todos.json --dry-run
|
||||
|
||||
todos.json 格式:
|
||||
[
|
||||
{"title": "修复线上Bug", "executors": "userId1,userId2", "priority": 40},
|
||||
{"title": "写周报", "executors": "userId1", "due": "2026-03-15"},
|
||||
{"title": "代码评审", "executors": "userId1"},
|
||||
{"title": "每日站会", "executors": "userId1", "due": "2026-03-20",
|
||||
"recurrence": "DTSTART:20260320T020000Z\\nRRULE:FREQ=DAILY;INTERVAL=1"}
|
||||
]
|
||||
|
||||
字段说明:
|
||||
- title: 待办标题 (必填)
|
||||
- executors: 执行者 userId,多人逗号分隔 (必填)
|
||||
- priority: 优先级 10=低/20=普通/30=较高/40=紧急 (可选)
|
||||
- due: 截止日期 YYYY-MM-DD 或毫秒时间戳 (可选)
|
||||
- recurrence: 循环待办规则 (可选,需同时有 due);字符串内需含换行,与 dws --recurrence 一致
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ALLOWED_PRIORITIES = {10, 20, 30, 40}
|
||||
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
||||
|
||||
TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
MAX_ITEMS = 30
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
ALLOWED_PRIORITIES = {10, 20, 30, 40}
|
||||
PLAN_DIGEST_DOMAIN = b"dws-todo-batch-plan-v1\x00"
|
||||
MIN_EPOCH_MILLISECONDS = 1_000_000_000_000
|
||||
MAX_EPOCH_MILLISECONDS = 9_999_999_999_999
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return {'dry_run': True}
|
||||
class ScriptError(RuntimeError):
|
||||
def __init__(self, message: str, *, commit_unknown: bool = False):
|
||||
super().__init__(message)
|
||||
self.commit_unknown = commit_unknown
|
||||
|
||||
|
||||
def run_dws_json(
|
||||
args: List[str], dws: str = "dws", *, write_started: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
[dws, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" ✗ 错误:{result.stderr.strip()}")
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(' ✗ 命令执行超时', file=sys.stderr)
|
||||
return None
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
print(f" ✗ 错误:{e}", file=sys.stderr)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ScriptError("dws timed out", commit_unknown=write_started) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise ScriptError(str(exc), commit_unknown=False) from exc
|
||||
|
||||
payload: Optional[Dict[str, Any]] = None
|
||||
if result.stdout.strip():
|
||||
try:
|
||||
decoded = json.loads(result.stdout)
|
||||
if isinstance(decoded, dict):
|
||||
payload = decoded
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
|
||||
if result.returncode != 0:
|
||||
error = payload.get("error", {}) if payload else {}
|
||||
started = error.get("execution_started") if isinstance(error, dict) else None
|
||||
message = ""
|
||||
if isinstance(error, dict):
|
||||
message = str(error.get("message") or error.get("hint") or "")
|
||||
message = message or result.stderr.strip() or f"dws exited {result.returncode}"
|
||||
raise ScriptError(
|
||||
message,
|
||||
commit_unknown=write_started and started is not False,
|
||||
)
|
||||
if payload is None:
|
||||
raise ScriptError("dws returned non-object or invalid JSON", commit_unknown=write_started)
|
||||
if payload.get("ok") is False or payload.get("success") is False:
|
||||
error = payload.get("error", {})
|
||||
started = error.get("execution_started") if isinstance(error, dict) else None
|
||||
message = str(error.get("message") if isinstance(error, dict) else payload)
|
||||
raise ScriptError(message, commit_unknown=write_started and started is not False)
|
||||
if payload.get("outcome") == "pending":
|
||||
raise ScriptError("create outcome is pending", commit_unknown=write_started)
|
||||
return payload
|
||||
|
||||
|
||||
def walk_objects(value: Any) -> Iterable[Dict[str, Any]]:
|
||||
if isinstance(value, dict):
|
||||
yield value
|
||||
for child in value.values():
|
||||
yield from walk_objects(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from walk_objects(child)
|
||||
|
||||
|
||||
def first_string(payload: Dict[str, Any], keys: Iterable[str]) -> str:
|
||||
for obj in walk_objects(payload):
|
||||
for key in keys:
|
||||
value = obj.get(key)
|
||||
if value not in (None, ""):
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_due(value: Any) -> Optional[str]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if raw.isdigit():
|
||||
milliseconds = int(raw)
|
||||
if not MIN_EPOCH_MILLISECONDS <= milliseconds <= MAX_EPOCH_MILLISECONDS:
|
||||
raise ScriptError(
|
||||
"epoch-millisecond due time must be a 13-digit value "
|
||||
f"between {MIN_EPOCH_MILLISECONDS} and {MAX_EPOCH_MILLISECONDS}: "
|
||||
f"{raw}"
|
||||
)
|
||||
try:
|
||||
return datetime.fromtimestamp(milliseconds / 1000, TIMEZONE).isoformat()
|
||||
except (OSError, OverflowError, ValueError) as exc:
|
||||
raise ScriptError(f"invalid epoch-millisecond due time: {raw}") from exc
|
||||
if len(raw) == 10:
|
||||
try:
|
||||
day = datetime.strptime(raw, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59, tzinfo=TIMEZONE
|
||||
)
|
||||
return day.isoformat()
|
||||
except ValueError as exc:
|
||||
raise ScriptError(f"invalid due date: {raw}") from exc
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ScriptError(
|
||||
f"due must be YYYY-MM-DD, epoch milliseconds, or ISO-8601: {raw}"
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ScriptError(f"ISO due time must include a timezone: {raw}")
|
||||
return parsed.isoformat()
|
||||
|
||||
|
||||
def parse_due(due_value) -> Optional[str]:
|
||||
if not due_value:
|
||||
return None
|
||||
due_str = str(due_value)
|
||||
if due_str.isdigit() and len(due_str) >= 10:
|
||||
return due_str
|
||||
if DATE_PATTERN.match(due_str):
|
||||
dt = datetime.strptime(due_str, '%Y-%m-%d')
|
||||
dt = dt.replace(hour=23, minute=59, second=59)
|
||||
return str(int(dt.timestamp() * 1000))
|
||||
print(f" ⚠ 无法解析截止时间:{due_value},跳过")
|
||||
return None
|
||||
|
||||
|
||||
def validate_todo(item: Dict[str, Any], idx: int) -> bool:
|
||||
if not isinstance(item, dict):
|
||||
print(f" ✗ #{idx+1} 不是有效对象")
|
||||
return False
|
||||
if not item.get('title', '').strip():
|
||||
print(f" ✗ #{idx+1} 缺少 title")
|
||||
return False
|
||||
if not item.get('executors', '').strip():
|
||||
print(f" ✗ #{idx+1} 缺少 executors")
|
||||
return False
|
||||
priority = item.get('priority')
|
||||
if priority is not None and int(priority) not in ALLOWED_PRIORITIES:
|
||||
print(f" ✗ #{idx+1} 无效优先级:{priority}")
|
||||
return False
|
||||
recurrence = item.get('recurrence')
|
||||
if recurrence and not str(recurrence).strip():
|
||||
print(f" ✗ #{idx+1} recurrence 不能为空字符串")
|
||||
return False
|
||||
if recurrence and not item.get('due'):
|
||||
print(f" ✗ #{idx+1} 设置 recurrence 时必须提供 due")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
args = [a for a in sys.argv[1:] if a != '--dry-run']
|
||||
if not args:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
file_path = Path(args[0])
|
||||
if not file_path.exists():
|
||||
print(f"错误:文件不存在:{file_path}")
|
||||
sys.exit(1)
|
||||
if file_path.stat().st_size > MAX_FILE_SIZE:
|
||||
print(f"错误:文件过大 (限制 {MAX_FILE_SIZE // 1024}KB)")
|
||||
sys.exit(1)
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
todos = json.load(f)
|
||||
if not isinstance(todos, list) or not todos:
|
||||
print('错误:JSON 文件必须是非空数组')
|
||||
sys.exit(1)
|
||||
|
||||
for i, item in enumerate(todos):
|
||||
if not validate_todo(item, i):
|
||||
sys.exit(1)
|
||||
|
||||
print(f"📋 准备创建 {len(todos)} 条待办\n")
|
||||
success, fail = 0, 0
|
||||
for i, item in enumerate(todos):
|
||||
title = item['title'].strip()
|
||||
cmd_args = [
|
||||
'todo', 'task', 'create',
|
||||
'--title', title,
|
||||
'--executors', item['executors'].strip(),
|
||||
'--format', 'json',
|
||||
]
|
||||
priority = item.get('priority')
|
||||
def validate(items: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ScriptError("input must be a non-empty JSON array")
|
||||
if len(items) > MAX_ITEMS:
|
||||
raise ScriptError(f"a batch may contain at most {MAX_ITEMS} tasks")
|
||||
validated: List[Dict[str, Any]] = []
|
||||
for index, item in enumerate(items, 1):
|
||||
if not isinstance(item, dict):
|
||||
raise ScriptError(f"item {index} must be an object")
|
||||
title = str(item.get("title") or "").strip()
|
||||
executors = str(item.get("executors") or "").strip()
|
||||
if not title or not executors:
|
||||
raise ScriptError(f"item {index} requires non-empty title and executors")
|
||||
priority = item.get("priority")
|
||||
if priority is not None:
|
||||
cmd_args.extend(['--priority', str(int(priority))])
|
||||
due = parse_due(item.get('due'))
|
||||
if due:
|
||||
cmd_args.extend(['--due', due])
|
||||
recurrence = item.get('recurrence')
|
||||
if recurrence:
|
||||
rr = str(recurrence).replace('\\n', '\n')
|
||||
cmd_args.extend(['--recurrence', rr])
|
||||
|
||||
result = run_dws(cmd_args, dry_run=dry_run)
|
||||
if result:
|
||||
print(f" ✓ [{i+1}/{len(todos)}] {title}")
|
||||
success += 1
|
||||
else:
|
||||
print(f" ✗ [{i+1}/{len(todos)}] {title}")
|
||||
fail += 1
|
||||
|
||||
print(f"\n完成: 成功 {success}, 失败 {fail}")
|
||||
sys.exit(0 if fail == 0 else 1)
|
||||
try:
|
||||
priority = int(priority)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ScriptError(f"item {index} has invalid priority") from exc
|
||||
if priority not in ALLOWED_PRIORITIES:
|
||||
raise ScriptError(
|
||||
f"item {index} priority must be one of {sorted(ALLOWED_PRIORITIES)}"
|
||||
)
|
||||
due = normalize_due(item.get("due"))
|
||||
recurrence = item.get("recurrence")
|
||||
if recurrence and not due:
|
||||
raise ScriptError(f"item {index} recurrence requires due")
|
||||
validated.append(
|
||||
{
|
||||
"title": title,
|
||||
"executors": executors,
|
||||
"priority": priority,
|
||||
"due": due,
|
||||
"recurrence": (
|
||||
str(recurrence).replace("\\n", "\n") if recurrence else None
|
||||
),
|
||||
}
|
||||
)
|
||||
return validated
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
def batch_plan_digest(items: List[Dict[str, Any]]) -> str:
|
||||
canonical = json.dumps(
|
||||
items,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
digest = hashlib.sha256(PLAN_DIGEST_DOMAIN + canonical).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
|
||||
|
||||
def run(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--dws", default="dws")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--yes",
|
||||
action="store_true",
|
||||
help="confirm execution of the exact validated batch after user approval",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--confirm-digest",
|
||||
help="planDigest from the user-approved dry-run output",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if not args.input.is_file():
|
||||
raise ScriptError(f"input file not found: {args.input}")
|
||||
if args.input.stat().st_size > MAX_FILE_SIZE:
|
||||
raise ScriptError(f"input exceeds {MAX_FILE_SIZE} bytes")
|
||||
items = validate(json.loads(args.input.read_text(encoding="utf-8")))
|
||||
except (OSError, json.JSONDecodeError, ScriptError) as exc:
|
||||
print(json.dumps({"complete": False, "error": str(exc)}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
plan_digest = batch_plan_digest(items)
|
||||
if not args.dry_run and (not args.yes or not args.confirm_digest):
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"complete": False,
|
||||
"dryRun": False,
|
||||
"reason": "confirmation_required",
|
||||
"executionStarted": False,
|
||||
"error": (
|
||||
"preview the exact batch with --dry-run; after the user "
|
||||
"confirms that planDigest, rerun with --yes and "
|
||||
"--confirm-digest <planDigest>"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 2
|
||||
if not args.dry_run and args.confirm_digest != plan_digest:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"complete": False,
|
||||
"dryRun": False,
|
||||
"reason": "plan_mismatch",
|
||||
"executionStarted": False,
|
||||
"confirmedPlanDigest": args.confirm_digest,
|
||||
"actualPlanDigest": plan_digest,
|
||||
"error": (
|
||||
"the validated batch differs from the confirmed dry-run; "
|
||||
"preview again and obtain confirmation for the new planDigest"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 2
|
||||
|
||||
ledger: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
create = [
|
||||
"todo",
|
||||
"task",
|
||||
"create",
|
||||
"--title",
|
||||
item["title"],
|
||||
"--executors",
|
||||
item["executors"],
|
||||
]
|
||||
if item["priority"] is not None:
|
||||
create.extend(["--priority", str(item["priority"])])
|
||||
if item["due"]:
|
||||
create.extend(["--due", item["due"]])
|
||||
if item["recurrence"]:
|
||||
create.extend(["--recurrence", item["recurrence"]])
|
||||
create.extend(["--format", "json"])
|
||||
if args.dry_run:
|
||||
ledger.append({"title": item["title"], "command": [args.dws, *create]})
|
||||
continue
|
||||
|
||||
# The script-level confirmation covers this exact validated batch. Pass the
|
||||
# Runtime bypass only to the writes; readback remains an ordinary read.
|
||||
create.append("--yes")
|
||||
|
||||
entry: Dict[str, Any] = {"title": item["title"], "status": "unknown"}
|
||||
try:
|
||||
created = run_dws_json(create, args.dws, write_started=True)
|
||||
identifier = first_string(created, ("taskId", "todoTaskId"))
|
||||
if not identifier:
|
||||
raise ScriptError(
|
||||
"create response did not contain a stable taskId",
|
||||
commit_unknown=True,
|
||||
)
|
||||
entry["taskId"] = identifier
|
||||
try:
|
||||
detail = run_dws_json(
|
||||
[
|
||||
"todo",
|
||||
"task",
|
||||
"get",
|
||||
"--task-id",
|
||||
identifier,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
args.dws,
|
||||
)
|
||||
actual_title = first_string(detail, ("subject", "title"))
|
||||
if actual_title != item["title"]:
|
||||
raise ScriptError(
|
||||
f"readback title mismatch: expected {item['title']!r}, "
|
||||
f"got {actual_title!r}"
|
||||
)
|
||||
entry["status"] = "verified"
|
||||
except ScriptError as exc:
|
||||
entry.update({"status": "unverified", "error": str(exc)})
|
||||
except ScriptError as exc:
|
||||
entry.update(
|
||||
{
|
||||
"status": "unknown" if exc.commit_unknown else "failed",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
ledger.append(entry)
|
||||
|
||||
complete = args.dry_run or all(item.get("status") == "verified" for item in ledger)
|
||||
output = {
|
||||
"complete": complete,
|
||||
"dryRun": args.dry_run,
|
||||
"planDigest": plan_digest,
|
||||
"requestedCount": len(items),
|
||||
"verifiedCount": sum(item.get("status") == "verified" for item in ledger),
|
||||
"failedCount": sum(item.get("status") == "failed" for item in ledger),
|
||||
"unverifiedCount": sum(item.get("status") == "unverified" for item in ledger),
|
||||
"unknownCount": sum(item.get("status") == "unknown" for item in ledger),
|
||||
"ledger": ledger,
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return 0 if complete else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
|
||||
@@ -1,178 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
查询今天/明天/本周未完成的待办并汇总输出
|
||||
"""Return open Todo tasks due today, tomorrow, or this week."""
|
||||
|
||||
用法:
|
||||
python todo_daily_summary.py # 默认查今天
|
||||
python todo_daily_summary.py today # 今天的待办
|
||||
python todo_daily_summary.py tomorrow # 明天的待办
|
||||
python todo_daily_summary.py week # 本周的待办
|
||||
python todo_daily_summary.py --dry-run # 仅显示将执行的命令
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
|
||||
PAGE_SIZE = 50
|
||||
MAX_PAGES = 10
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
MAX_PAGES = 40
|
||||
|
||||
|
||||
class ScriptError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_dws_json(args: List[str], dws: str = "dws") -> Dict[str, Any]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
[dws, *args], capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except subprocess.TimeoutExpired:
|
||||
print('错误:命令执行超时', file=sys.stderr)
|
||||
return None
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_date_range(scope: str):
|
||||
now = datetime.now()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if scope == 'today':
|
||||
return today_start, today_start + timedelta(days=1)
|
||||
elif scope == 'tomorrow':
|
||||
tmr = today_start + timedelta(days=1)
|
||||
return tmr, tmr + timedelta(days=1)
|
||||
elif scope == 'week':
|
||||
week_start = today_start - timedelta(days=today_start.weekday())
|
||||
return week_start, week_start + timedelta(days=7)
|
||||
return today_start, today_start + timedelta(days=1)
|
||||
|
||||
|
||||
def fetch_all_todos(
|
||||
dry_run: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
all_todos: List[Dict[str, Any]] = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
data = run_dws([
|
||||
'todo', 'task', 'list',
|
||||
'--page', str(page),
|
||||
'--size', str(PAGE_SIZE),
|
||||
'--status', 'false',
|
||||
'--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run:
|
||||
return []
|
||||
if not data:
|
||||
break
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
inner = data.get('result', data)
|
||||
if isinstance(inner, dict):
|
||||
items = inner.get('todoCards', [])
|
||||
elif isinstance(inner, list):
|
||||
items = inner
|
||||
else:
|
||||
items = []
|
||||
else:
|
||||
items = []
|
||||
if not items or not isinstance(items, list):
|
||||
break
|
||||
all_todos.extend(items)
|
||||
if len(items) < PAGE_SIZE:
|
||||
break
|
||||
return all_todos
|
||||
|
||||
|
||||
def format_priority(p) -> str:
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
raise ScriptError(str(exc)) from exc
|
||||
if result.returncode != 0:
|
||||
raise ScriptError(result.stderr.strip() or f"dws exited {result.returncode}")
|
||||
try:
|
||||
return PRIORITY_MAP.get(int(p), str(p))
|
||||
except (ValueError, TypeError):
|
||||
return '普通'
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScriptError("dws returned non-JSON output") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ScriptError("dws returned a non-object JSON payload")
|
||||
if payload.get("ok") is False or payload.get("success") is False:
|
||||
error = payload.get("error", {})
|
||||
raise ScriptError(str(error.get("message") if isinstance(error, dict) else payload))
|
||||
return payload
|
||||
|
||||
|
||||
def format_due(due_ms) -> str:
|
||||
if not due_ms:
|
||||
return '无截止时间'
|
||||
def date_range(scope: str, now: Optional[datetime] = None) -> Tuple[datetime, datetime]:
|
||||
current = now.astimezone(TIMEZONE) if now else datetime.now(TIMEZONE)
|
||||
today = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if scope == "today":
|
||||
return today, today + timedelta(days=1)
|
||||
if scope == "tomorrow":
|
||||
start = today + timedelta(days=1)
|
||||
return start, start + timedelta(days=1)
|
||||
start = today - timedelta(days=today.weekday())
|
||||
return start, start + timedelta(days=7)
|
||||
|
||||
|
||||
def extract_cards(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
data: Any = payload.get("data", payload)
|
||||
if isinstance(data, dict) and "todos" in data:
|
||||
if data.get("complete") is not True:
|
||||
raise ScriptError("Todo traversal did not prove endpoint exhaustion")
|
||||
value = data["todos"]
|
||||
else:
|
||||
legacy = payload.get("result", payload)
|
||||
value = legacy.get("todoCards") if isinstance(legacy, dict) else None
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise ScriptError("Todo response is missing a valid todos[] collection")
|
||||
return value
|
||||
|
||||
|
||||
def due_millis(item: Dict[str, Any]) -> Optional[int]:
|
||||
value = item.get("dueTime") or item.get("planFinishDate") or item.get("due")
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromtimestamp(int(due_ms) / 1000)
|
||||
return dt.strftime('%Y-%m-%d %H:%M')
|
||||
except (ValueError, TypeError, OSError):
|
||||
return str(due_ms)
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ScriptError(f"invalid due time for task {task_id(item)!r}: {value!r}") from exc
|
||||
|
||||
|
||||
def filter_by_due(
|
||||
todos: List[Dict[str, Any]], start: datetime, end: datetime,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
end_ms = int(end.timestamp() * 1000)
|
||||
result = []
|
||||
for t in todos:
|
||||
due = t.get('dueTime') or t.get('due')
|
||||
if not due:
|
||||
result.append(t)
|
||||
continue
|
||||
try:
|
||||
due_val = int(due)
|
||||
if start_ms <= due_val < end_ms:
|
||||
result.append(t)
|
||||
except (ValueError, TypeError):
|
||||
result.append(t)
|
||||
return result
|
||||
def task_id(item: Dict[str, Any]) -> str:
|
||||
value = item.get("taskId") or item.get("id") or item.get("todoTaskId")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def print_summary(
|
||||
todos: List[Dict[str, Any]], scope: str,
|
||||
start: datetime, end: datetime,
|
||||
):
|
||||
scope_label = {
|
||||
'today': '今天', 'tomorrow': '明天', 'week': '本周',
|
||||
}.get(scope, scope)
|
||||
print(f"\n📋 {scope_label}未完成待办 "
|
||||
f"({start.strftime('%m-%d')} ~ {end.strftime('%m-%d')})")
|
||||
print('=' * 50)
|
||||
if not todos:
|
||||
print(' ✅ 暂无待办,轻松一下!')
|
||||
return
|
||||
urgent = [t for t in todos if format_priority(
|
||||
t.get('priority')) == '紧急']
|
||||
if urgent:
|
||||
print(f"\n🔴 紧急 ({len(urgent)} 条)")
|
||||
for t in urgent:
|
||||
title = t.get('subject') or t.get('title', '无标题')
|
||||
print(f" • {title} ⏰ {format_due(t.get('dueTime'))}")
|
||||
normal = [t for t in todos if t not in urgent]
|
||||
if normal:
|
||||
print(f"\n📌 其他 ({len(normal)} 条)")
|
||||
for t in normal:
|
||||
title = t.get('subject') or t.get('title', '无标题')
|
||||
pri = format_priority(t.get('priority'))
|
||||
print(f" • [{pri}] {title} ⏰ {format_due(t.get('dueTime'))}")
|
||||
print(f"\n合计: {len(todos)} 条待办")
|
||||
def run(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"scope", nargs="?", choices=("today", "tomorrow", "week"), default="today"
|
||||
)
|
||||
parser.add_argument("--dws", default="dws")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
start, end = date_range(args.scope)
|
||||
start_ms, end_ms = int(start.timestamp() * 1000), int(end.timestamp() * 1000)
|
||||
command = [
|
||||
"todo",
|
||||
"+get-my-tasks",
|
||||
"--status",
|
||||
"false",
|
||||
"--plan-finish-start",
|
||||
str(start_ms),
|
||||
"--plan-finish-end",
|
||||
str(end_ms),
|
||||
"--all",
|
||||
"--max-pages",
|
||||
str(MAX_PAGES),
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
if args.dry_run:
|
||||
print(json.dumps({"command": [args.dws, *command]}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
try:
|
||||
cards = extract_cards(run_dws_json(command, args.dws))
|
||||
selected = []
|
||||
for item in cards:
|
||||
due = due_millis(item)
|
||||
if due is None or due < start_ms or due >= end_ms:
|
||||
continue
|
||||
selected.append(
|
||||
{
|
||||
"taskId": task_id(item),
|
||||
"title": item.get("subject") or item.get("title") or "",
|
||||
"priority": item.get("priority"),
|
||||
"dueTime": due,
|
||||
"dueTimeISO": datetime.fromtimestamp(
|
||||
due / 1000, TIMEZONE
|
||||
).isoformat(),
|
||||
}
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"complete": True,
|
||||
"scope": args.scope,
|
||||
"timezone": str(TIMEZONE),
|
||||
"range": {
|
||||
"start": start.isoformat(),
|
||||
"endExclusive": end.isoformat(),
|
||||
},
|
||||
"count": len(selected),
|
||||
"todos": selected,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except ScriptError as exc:
|
||||
print(json.dumps({"complete": False, "error": str(exc)}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
args = [a for a in sys.argv[1:] if a != '--dry-run']
|
||||
scope = args[0] if args else 'today'
|
||||
if scope not in ('today', 'tomorrow', 'week'):
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
start, end = get_date_range(scope)
|
||||
todos = fetch_all_todos(dry_run=dry_run)
|
||||
if dry_run:
|
||||
return
|
||||
filtered = filter_by_due(todos, start, end)
|
||||
print_summary(filtered, scope, start, end)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
|
||||
@@ -1,132 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
扫描已过截止时间但未完成的待办,输出逾期清单
|
||||
"""Return overdue open Todo tasks through the bounded Todo Shortcut."""
|
||||
|
||||
用法:
|
||||
python todo_overdue_check.py
|
||||
python todo_overdue_check.py --dry-run
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
PAGE_SIZE = 50
|
||||
MAX_PAGES = 10
|
||||
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def run_dws(
|
||||
args: List[str], dry_run: bool = False,
|
||||
) -> Optional[Any]:
|
||||
cmd = ['dws'] + args
|
||||
if dry_run:
|
||||
print(f"[dry-run] {' '.join(cmd)}")
|
||||
return None
|
||||
TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
class ScriptError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_dws_json(args: List[str], dws: str = "dws") -> Dict[str, Any]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
[dws, *args], capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
|
||||
return None
|
||||
return json.loads(result.stdout)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError,
|
||||
FileNotFoundError) as e:
|
||||
print(f"错误:{e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_all_undone(dry_run: bool = False) -> List[Dict[str, Any]]:
|
||||
all_todos: List[Dict[str, Any]] = []
|
||||
for page in range(1, MAX_PAGES + 1):
|
||||
data = run_dws([
|
||||
'todo', 'task', 'list',
|
||||
'--page', str(page), '--size', str(PAGE_SIZE),
|
||||
'--status', 'false', '--format', 'json',
|
||||
], dry_run=dry_run)
|
||||
if dry_run or not data:
|
||||
break
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
inner = data.get('result', data)
|
||||
if isinstance(inner, dict):
|
||||
items = inner.get('todoCards', [])
|
||||
elif isinstance(inner, list):
|
||||
items = inner
|
||||
else:
|
||||
items = []
|
||||
else:
|
||||
items = []
|
||||
if not items or not isinstance(items, list):
|
||||
break
|
||||
all_todos.extend(items)
|
||||
if len(items) < PAGE_SIZE:
|
||||
break
|
||||
return all_todos
|
||||
|
||||
|
||||
def find_overdue(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
now_ms = int(datetime.now().timestamp() * 1000)
|
||||
overdue = []
|
||||
for t in todos:
|
||||
due = t.get('dueTime') or t.get('due')
|
||||
if not due:
|
||||
continue
|
||||
try:
|
||||
if int(due) < now_ms:
|
||||
overdue.append(t)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return overdue
|
||||
|
||||
|
||||
def days_overdue(due_ms) -> int:
|
||||
now = datetime.now()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
raise ScriptError(str(exc)) from exc
|
||||
if result.returncode != 0:
|
||||
raise ScriptError(result.stderr.strip() or f"dws exited {result.returncode}")
|
||||
try:
|
||||
due_dt = datetime.fromtimestamp(int(due_ms) / 1000)
|
||||
return max(0, (now - due_dt).days)
|
||||
except (ValueError, TypeError, OSError):
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ScriptError("dws returned non-JSON output") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ScriptError("dws returned a non-object JSON payload")
|
||||
if payload.get("ok") is False or payload.get("success") is False:
|
||||
error = payload.get("error", {})
|
||||
raise ScriptError(str(error.get("message") if isinstance(error, dict) else payload))
|
||||
return payload
|
||||
|
||||
|
||||
def extract_overdue(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
data: Any = payload.get("data", payload)
|
||||
value = data.get("overdue") if isinstance(data, dict) else None
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise ScriptError("Todo response is missing a valid overdue[] collection")
|
||||
return value
|
||||
|
||||
|
||||
def run(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dws", default="dws")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
command = ["todo", "+overdue", "--format", "json"]
|
||||
if args.dry_run:
|
||||
print(json.dumps({"command": [args.dws, *command]}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
dry_run = '--dry-run' in sys.argv
|
||||
todos = fetch_all_undone(dry_run=dry_run)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
overdue = find_overdue(todos)
|
||||
overdue.sort(
|
||||
key=lambda t: int(t.get('dueTime') or t.get('due', 0))
|
||||
)
|
||||
|
||||
print(f"\n⏰ 逾期待办检查 ({datetime.now().strftime('%Y-%m-%d %H:%M')})")
|
||||
print('=' * 50)
|
||||
|
||||
if not overdue:
|
||||
print(' ✅ 没有逾期待办,继续保持!')
|
||||
return
|
||||
|
||||
for t in overdue:
|
||||
title = t.get('subject') or t.get('title', '无标题')
|
||||
due = t.get('dueTime') or t.get('due')
|
||||
days = days_overdue(due)
|
||||
pri = PRIORITY_MAP.get(
|
||||
int(t.get('priority', 20)), '普通'
|
||||
try:
|
||||
now = datetime.now(TIMEZONE)
|
||||
items = []
|
||||
for item in extract_overdue(run_dws_json(command, args.dws)):
|
||||
task = item.get("taskId")
|
||||
due = item.get("dueTime")
|
||||
if task in (None, "") or due in (None, ""):
|
||||
raise ScriptError("overdue item is missing taskId or dueTime")
|
||||
try:
|
||||
due_ms = int(due)
|
||||
due_at = datetime.fromtimestamp(due_ms / 1000, TIMEZONE)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
raise ScriptError(f"invalid overdue dueTime: {due!r}") from exc
|
||||
items.append(
|
||||
{
|
||||
"taskId": str(task),
|
||||
"title": item.get("subject") or item.get("title") or "",
|
||||
"dueTime": due_ms,
|
||||
"dueTimeISO": due_at.isoformat(),
|
||||
"daysOverdue": max(0, (now.date() - due_at.date()).days),
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda item: item["dueTime"])
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"complete": True,
|
||||
"timezone": str(TIMEZONE),
|
||||
"checkedAt": now.isoformat(),
|
||||
"count": len(items),
|
||||
"todos": items,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
due_str = datetime.fromtimestamp(
|
||||
int(due) / 1000
|
||||
).strftime('%Y-%m-%d')
|
||||
print(f" 🔴 [{pri}] {title}")
|
||||
print(f" 截止: {due_str} 逾期: {days} 天")
|
||||
|
||||
print(f"\n合计: {len(overdue)} 条逾期待办")
|
||||
sys.exit(1 if overdue else 0)
|
||||
return 0
|
||||
except ScriptError as exc:
|
||||
print(json.dumps({"complete": False, "error": str(exc)}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for the Todo Skill Golden Routes and deterministic scripts."""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TODO_ROOT = ROOT / "skills" / "multi" / "dingtalk-todo"
|
||||
|
||||
|
||||
def load_script(filename):
|
||||
path = TODO_ROOT / "scripts" / filename
|
||||
spec = importlib.util.spec_from_file_location(f"todo_test_{path.stem}", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DAILY = load_script("todo_daily_summary.py")
|
||||
OVERDUE = load_script("todo_overdue_check.py")
|
||||
BATCH = load_script("todo_batch_create.py")
|
||||
|
||||
|
||||
class TodoSkillAlignmentTest(unittest.TestCase):
|
||||
def test_golden_routes_prefer_verified_shortcuts(self):
|
||||
skill = (TODO_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
for route in (
|
||||
"todo +remind",
|
||||
"todo +assign",
|
||||
"todo +create",
|
||||
"todo +get-my-tasks",
|
||||
"todo +search",
|
||||
"todo +complete",
|
||||
"todo +update",
|
||||
"todo +reminder",
|
||||
):
|
||||
with self.subTest(route=route):
|
||||
self.assertIn(route, skill)
|
||||
self.assertIn("## Golden Routes", skill)
|
||||
self.assertIn("只有当前 leaf 的 flag 或安全语义确实不明时才查精确 leaf", skill)
|
||||
self.assertLessEqual(len(skill.encode("utf-8")), 16000)
|
||||
|
||||
def test_composite_lifecycle_starts_with_atomic_create(self):
|
||||
skill = (TODO_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
lifecycle = (TODO_ROOT / "references" / "02-task.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("组合生命周期从原子创建开始", skill)
|
||||
self.assertIn("不要用创建 Shortcut 代替第一步", skill)
|
||||
self.assertIn("dws todo task create --title", lifecycle)
|
||||
self.assertIn("dws contact user get-self --format json", lifecycle)
|
||||
self.assertIn(
|
||||
'task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID>',
|
||||
lifecycle,
|
||||
)
|
||||
self.assertNotIn("dws contact me --format json", lifecycle)
|
||||
|
||||
def test_step_routing_keeps_shortcuts_and_dynamic_id_boundaries(self):
|
||||
lifecycle = (TODO_ROOT / "references" / "02-task.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for route in (
|
||||
"+get-my-tasks",
|
||||
"+get-related-tasks",
|
||||
"+due-today",
|
||||
"+overdue",
|
||||
"+search",
|
||||
"+get",
|
||||
"+complete",
|
||||
"+reopen",
|
||||
"+update",
|
||||
"+comment",
|
||||
"+reminder",
|
||||
"+list-sub",
|
||||
"+list-attachment",
|
||||
):
|
||||
with self.subTest(route=route):
|
||||
self.assertIn(route, lifecycle)
|
||||
for stable_id in (
|
||||
"taskId",
|
||||
"commentId",
|
||||
"attachmentId",
|
||||
"tagCode",
|
||||
"userId",
|
||||
):
|
||||
with self.subTest(stable_id=stable_id):
|
||||
self.assertIn(stable_id, lifecycle)
|
||||
self.assertIn("禁止运行 `git tag`", lifecycle)
|
||||
|
||||
def test_golden_route_table_keeps_exactly_three_columns(self):
|
||||
skill = (TODO_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
table = skill.split("## Golden Routes", 1)[1].split("## 低频原子能力", 1)[0]
|
||||
for row in (line for line in table.splitlines() if line.startswith("|")):
|
||||
with self.subTest(row=row):
|
||||
self.assertEqual(5, len(re.split(r"(?<!\\)\|", row)), row)
|
||||
|
||||
def test_all_markdown_tables_keep_consistent_column_counts(self):
|
||||
for document in TODO_ROOT.rglob("*.md"):
|
||||
expected_columns = None
|
||||
for line_number, line in enumerate(
|
||||
document.read_text(encoding="utf-8").splitlines(), start=1
|
||||
):
|
||||
if line.startswith("|") and line.endswith("|"):
|
||||
columns = len(re.split(r"(?<!\\)\|", line))
|
||||
if expected_columns is None:
|
||||
expected_columns = columns
|
||||
with self.subTest(document=document.name, line=line_number):
|
||||
self.assertEqual(expected_columns, columns, line)
|
||||
else:
|
||||
expected_columns = None
|
||||
|
||||
def test_references_are_todo_only_and_reminder_contract_is_consistent(self):
|
||||
combined = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in (TODO_ROOT / "references").rglob("*.md")
|
||||
)
|
||||
self.assertNotIn("## #7 听记与会后", combined)
|
||||
self.assertNotIn("minutes list all", combined)
|
||||
self.assertNotIn("当前不支持单独的 `reminder`", combined)
|
||||
self.assertIn("提醒写入目前没有对应的查询接口", combined)
|
||||
|
||||
def test_markdown_links_resolve(self):
|
||||
missing = []
|
||||
pattern = re.compile(r"\[[^\]]+\]\(([^)#]+)(?:#[^)]+)?\)")
|
||||
for document in TODO_ROOT.rglob("*.md"):
|
||||
for target in pattern.findall(document.read_text(encoding="utf-8")):
|
||||
if "://" not in target and not (document.parent / target).resolve().exists():
|
||||
missing.append(f"{document.relative_to(ROOT)} -> {target}")
|
||||
self.assertEqual([], missing)
|
||||
|
||||
|
||||
class TodoDailySummaryTest(unittest.TestCase):
|
||||
def test_uses_bounded_shortcut_and_excludes_missing_due(self):
|
||||
start, _ = DAILY.date_range("today")
|
||||
inside = int((start + timedelta(hours=9)).timestamp() * 1000)
|
||||
outside = int((start + timedelta(days=2)).timestamp() * 1000)
|
||||
calls = []
|
||||
|
||||
def fake_run(args, dws="dws"):
|
||||
calls.append(args)
|
||||
return {
|
||||
"ok": True,
|
||||
"outcome": "success",
|
||||
"data": {
|
||||
"complete": True,
|
||||
"count": 3,
|
||||
"todos": [
|
||||
{"taskId": "in", "subject": "inside", "dueTime": inside},
|
||||
{"taskId": "none", "subject": "no due"},
|
||||
{"taskId": "out", "subject": "outside", "dueTime": outside},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(DAILY, "run_dws_json", side_effect=fake_run):
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = DAILY.run(["today"])
|
||||
self.assertEqual(0, code)
|
||||
self.assertEqual(1, len(calls))
|
||||
self.assertEqual(["todo", "+get-my-tasks"], calls[0][:2])
|
||||
self.assertIn("--all", calls[0])
|
||||
self.assertIn("--plan-finish-start", calls[0])
|
||||
self.assertEqual(
|
||||
["in"], [item["taskId"] for item in json.loads(stdout.getvalue())["todos"]]
|
||||
)
|
||||
|
||||
def test_incomplete_traversal_fails_closed(self):
|
||||
payload = {"ok": True, "data": {"complete": False, "todos": []}}
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(DAILY, "run_dws_json", return_value=payload):
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = DAILY.run(["today"])
|
||||
self.assertEqual(2, code)
|
||||
self.assertFalse(json.loads(stdout.getvalue())["complete"])
|
||||
|
||||
|
||||
class TodoOverdueTest(unittest.TestCase):
|
||||
def test_uses_overdue_shortcut_and_empty_is_success(self):
|
||||
calls = []
|
||||
|
||||
def fake_run(args, dws="dws"):
|
||||
calls.append(args)
|
||||
return {"ok": True, "outcome": "success", "data": {"overdue": []}}
|
||||
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(OVERDUE, "run_dws_json", side_effect=fake_run):
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = OVERDUE.run([])
|
||||
self.assertEqual(0, code)
|
||||
self.assertEqual(["todo", "+overdue", "--format", "json"], calls[0])
|
||||
self.assertEqual(0, json.loads(stdout.getvalue())["count"])
|
||||
|
||||
|
||||
class TodoBatchCreateTest(unittest.TestCase):
|
||||
def test_batch_uses_iso_due_captures_id_and_reads_back(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"title": "reviewed task",
|
||||
"executors": "user1",
|
||||
"priority": 40,
|
||||
"due": "2026-08-18",
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_subprocess_run(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
if argv[1:4] == ["todo", "task", "create"]:
|
||||
payload = {"result": {"taskId": "task-1"}}
|
||||
else:
|
||||
payload = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"todoDetailModel": {
|
||||
"taskId": "task-1",
|
||||
"subject": "reviewed task",
|
||||
}
|
||||
},
|
||||
}
|
||||
return mock.Mock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(payload),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
stdout = io.StringIO()
|
||||
preview_stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as preview_run_dws:
|
||||
with contextlib.redirect_stdout(preview_stdout):
|
||||
preview_code = BATCH.run(
|
||||
[str(source), "--dry-run", "--dws", "fake-dws"]
|
||||
)
|
||||
self.assertEqual(0, preview_code)
|
||||
preview_run_dws.assert_not_called()
|
||||
plan_digest = json.loads(preview_stdout.getvalue())["planDigest"]
|
||||
|
||||
with mock.patch.object(
|
||||
BATCH.subprocess, "run", side_effect=fake_subprocess_run
|
||||
):
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run(
|
||||
[
|
||||
str(source),
|
||||
"--yes",
|
||||
"--confirm-digest",
|
||||
plan_digest,
|
||||
"--dws",
|
||||
"fake-dws",
|
||||
]
|
||||
)
|
||||
self.assertEqual(0, code)
|
||||
self.assertEqual(2, len(calls))
|
||||
self.assertEqual(
|
||||
[
|
||||
"fake-dws",
|
||||
"todo",
|
||||
"task",
|
||||
"create",
|
||||
"--title",
|
||||
"reviewed task",
|
||||
"--executors",
|
||||
"user1",
|
||||
"--priority",
|
||||
"40",
|
||||
"--due",
|
||||
"2026-08-18T23:59:59+08:00",
|
||||
"--format",
|
||||
"json",
|
||||
"--yes",
|
||||
],
|
||||
calls[0][0],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"fake-dws",
|
||||
"todo",
|
||||
"task",
|
||||
"get",
|
||||
"--task-id",
|
||||
"task-1",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
calls[1][0],
|
||||
)
|
||||
for _, kwargs in calls:
|
||||
self.assertTrue(kwargs["capture_output"])
|
||||
self.assertTrue(kwargs["text"])
|
||||
self.assertIs(BATCH.subprocess.DEVNULL, kwargs["stdin"])
|
||||
self.assertEqual(120, kwargs["timeout"])
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertTrue(payload["complete"])
|
||||
self.assertEqual(plan_digest, payload["planDigest"])
|
||||
self.assertEqual("verified", payload["ledger"][0]["status"])
|
||||
|
||||
def test_unconfirmed_batch_stops_before_first_dws_call(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
'[{"title":"reviewed task","executors":"user1"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run([str(source)])
|
||||
self.assertEqual(2, code)
|
||||
run_dws.assert_not_called()
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertFalse(payload["complete"])
|
||||
self.assertEqual("confirmation_required", payload["reason"])
|
||||
self.assertFalse(payload["executionStarted"])
|
||||
|
||||
def test_yes_without_confirmed_digest_stops_before_first_dws_call(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
'[{"title":"reviewed task","executors":"user1"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run([str(source), "--yes"])
|
||||
self.assertEqual(2, code)
|
||||
run_dws.assert_not_called()
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertEqual("confirmation_required", payload["reason"])
|
||||
self.assertFalse(payload["executionStarted"])
|
||||
|
||||
def test_dry_run_previews_exact_batch_without_confirmation_bypass(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
'[{"title":"reviewed task","executors":"user1"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run([str(source), "--dry-run"])
|
||||
self.assertEqual(0, code)
|
||||
run_dws.assert_not_called()
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertTrue(payload["complete"])
|
||||
self.assertTrue(payload["dryRun"])
|
||||
self.assertRegex(payload["planDigest"], r"^sha256:[0-9a-f]{64}$")
|
||||
self.assertEqual(
|
||||
[
|
||||
"dws",
|
||||
"todo",
|
||||
"task",
|
||||
"create",
|
||||
"--title",
|
||||
"reviewed task",
|
||||
"--executors",
|
||||
"user1",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
payload["ledger"][0]["command"],
|
||||
)
|
||||
self.assertNotIn("--yes", payload["ledger"][0]["command"])
|
||||
|
||||
def test_plan_digest_is_stable_for_equivalent_validated_content(self):
|
||||
first = BATCH.validate(
|
||||
[
|
||||
{
|
||||
"title": " reviewed task ",
|
||||
"executors": "user1",
|
||||
"priority": "40",
|
||||
"due": "2026-08-18",
|
||||
}
|
||||
]
|
||||
)
|
||||
second = BATCH.validate(
|
||||
[
|
||||
{
|
||||
"due": "2026-08-18T23:59:59+08:00",
|
||||
"priority": 40,
|
||||
"executors": "user1",
|
||||
"title": "reviewed task",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
BATCH.batch_plan_digest(first), BATCH.batch_plan_digest(second)
|
||||
)
|
||||
|
||||
def test_changed_file_rejects_confirmed_digest_before_first_dws_call(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
'[{"title":"reviewed task","executors":"user1"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
preview_stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as preview_run_dws:
|
||||
with contextlib.redirect_stdout(preview_stdout):
|
||||
preview_code = BATCH.run([str(source), "--dry-run"])
|
||||
self.assertEqual(0, preview_code)
|
||||
preview_run_dws.assert_not_called()
|
||||
confirmed_digest = json.loads(preview_stdout.getvalue())["planDigest"]
|
||||
|
||||
source.write_text(
|
||||
'[{"title":"changed task","executors":"user1"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run(
|
||||
[
|
||||
str(source),
|
||||
"--yes",
|
||||
"--confirm-digest",
|
||||
confirmed_digest,
|
||||
]
|
||||
)
|
||||
self.assertEqual(2, code)
|
||||
run_dws.assert_not_called()
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertEqual("plan_mismatch", payload["reason"])
|
||||
self.assertFalse(payload["executionStarted"])
|
||||
self.assertEqual(confirmed_digest, payload["confirmedPlanDigest"])
|
||||
self.assertNotEqual(confirmed_digest, payload["actualPlanDigest"])
|
||||
|
||||
def test_short_numeric_due_stops_before_first_dws_call(self):
|
||||
for due in ("0", "123", "2026", "0000000000123"):
|
||||
with self.subTest(due=due), tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"title": "must not be created",
|
||||
"executors": "user1",
|
||||
"due": due,
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH.subprocess, "run") as run_dws:
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run(
|
||||
[
|
||||
str(source),
|
||||
"--yes",
|
||||
"--confirm-digest",
|
||||
"sha256:" + "0" * 64,
|
||||
]
|
||||
)
|
||||
self.assertEqual(2, code)
|
||||
run_dws.assert_not_called()
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertFalse(payload["complete"])
|
||||
self.assertIn("13-digit", payload["error"])
|
||||
|
||||
def test_modern_epoch_millisecond_due_is_accepted(self):
|
||||
self.assertEqual(
|
||||
"2025-01-01T08:00:00+08:00",
|
||||
BATCH.normalize_due("1735689600000"),
|
||||
)
|
||||
|
||||
def test_possible_commit_is_preserved_as_unknown(self):
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
source = Path(raw) / "todos.json"
|
||||
source.write_text(
|
||||
'[{"title":"x","executors":"u"}]', encoding="utf-8"
|
||||
)
|
||||
failure = BATCH.ScriptError("timeout", commit_unknown=True)
|
||||
items = BATCH.validate(json.loads(source.read_text(encoding="utf-8")))
|
||||
plan_digest = BATCH.batch_plan_digest(items)
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.object(BATCH, "run_dws_json", side_effect=failure):
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
code = BATCH.run(
|
||||
[
|
||||
str(source),
|
||||
"--yes",
|
||||
"--confirm-digest",
|
||||
plan_digest,
|
||||
]
|
||||
)
|
||||
self.assertEqual(2, code)
|
||||
payload = json.loads(stdout.getvalue())
|
||||
self.assertEqual(1, payload["unknownCount"])
|
||||
self.assertEqual("unknown", payload["ledger"][0]["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,21 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTodoSkillScripts(t *testing.T) {
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 is not installed")
|
||||
}
|
||||
cmd := exec.Command(python, "test/scripts/todo_skill_scripts_test.py")
|
||||
cmd.Dir = "../.."
|
||||
cmd.Env = append(os.Environ(), "PYTHONDONTWRITEBYTECODE=1")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("todo skill script tests failed: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user