-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtasks.rs
More file actions
636 lines (603 loc) · 21.9 KB
/
Copy pathtasks.rs
File metadata and controls
636 lines (603 loc) · 21.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
//! 任务域命令(工作会话层):tasks 列表 / task 详情 / create 建任务 /
//! update 改字段 / claim 认领 / complete 完成 / log 过程留痕。
use anyhow::{Context, Result, bail};
use serde_json::json;
use super::project_ctx;
use crate::cli::UpdateArgs;
use crate::client::encode_query;
use crate::config::Config;
use crate::model::agent::{AgentTasks, TaskBrief};
use crate::model::task::{AiLogList, CommentList, CreatedId, TaskDetail};
use crate::output::{Out, pad_display};
/// `bcode tasks [--status s] [--keyword kw] [--priority p] [--sort s]`(F06):
/// 免参任务列表。status 透传平台语义:缺省=未完成三态,all=全部。
/// 默认按优先级升序(P1 在前)+ id 降序,`--sort id` 恢复平台原始顺序。
pub async fn tasks(
cfg: &Config,
profile: &str,
a: &crate::cli::TasksArgs,
out: &Out,
) -> Result<()> {
// 跨项目聚合(identity 即可,不依赖目录指向;多项目 agent 的高频视角)
if a.mine {
let client = super::identity_client(cfg, profile)?;
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct MyTask {
#[serde(default)]
id: i64,
#[serde(default)]
title: String,
#[serde(default)]
status: String,
#[serde(default)]
priority: i64,
#[serde(default)]
project_name: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct MyTasks {
#[serde(default)]
total: i64,
#[serde(default, deserialize_with = "crate::model::null_to_default")]
list: Vec<MyTask>,
}
let res: MyTasks = client.get_as("/v1/my-tasks?size=100").await?;
if res.list.is_empty() {
out.line("(跨项目无进行中任务)");
}
for t in &res.list {
out.line(&format!(
" {} {} [{}] {}",
crate::output::pad_display(&format!("#{}", t.id), 7),
crate::output::pad_display(&t.project_name, 16),
t.status,
t.title
));
}
out.emit_value(&serde_json::to_value(&res)?);
return Ok(());
}
let ctx = project_ctx(cfg, profile).await?;
let mut path = String::from("/v1/agent/tasks");
let mut sep = '?';
if let Some(s) = a.status.as_deref() {
path.push(sep);
path.push_str(&format!("status={}", encode_query(s)));
sep = '&';
}
if let Some(kw) = a.keyword.as_deref() {
path.push(sep);
path.push_str(&format!("keyword={}", encode_query(kw)));
}
// 免参端点走 sessioned_get:缓存会话失效时自愈重建(见 Ctx::sessioned_get)
let mut page: AgentTasks = ctx.sessioned_get(&path).await?;
if let Some(p) = a.priority {
page.list.retain(|t| t.priority == p);
page.total = page.list.len() as i64;
}
sort_tasks(&mut page.list, &a.sort);
if page.list.is_empty() {
out.line(&format!("(无任务,共 {} 条记录)", page.total));
} else {
// 列:id/状态/类型/优先/截止/标签/标题(type/tags 为 v3 平台新增字段)
let head = format!(
" {} {} {} {} {} {} {}",
pad_display("id", 7),
pad_display("状态", 13),
pad_display("类型", 8),
pad_display("优先", 4),
pad_display("截止", 12),
pad_display("标签", 10),
"标题"
);
out.line(&head);
for t in &page.list {
let due = t.due_date.as_deref().unwrap_or("—");
let tags = if t.tags.is_empty() {
"—".to_string()
} else {
t.tags.join(",")
};
out.line(&format!(
" {} {} {} {} {} {} {}",
pad_display(&t.id.to_string(), 7),
pad_display(&t.status, 13),
pad_display(
if t.r#type.is_empty() {
"—"
} else {
&t.r#type
},
8
),
pad_display(&format!("P{}", t.priority), 4),
pad_display(due, 12),
pad_display(&tags, 10),
t.title
));
}
out.line(&format!("({} 条)", page.total));
}
out.emit_value(&serde_json::to_value(&page)?);
Ok(())
}
/// `bcode task <id>`(F10):单任务详情——描述 / artifacts / 执行日志 / 评论摘要。
pub async fn task(cfg: &Config, profile: &str, id: i64, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
let detail: TaskDetail = ctx.client.get_as(&format!("/v1/tasks/{id}")).await?;
let logs: AiLogList = ctx
.client
.get_as(&format!("/v1/tasks/{id}/ai-logs"))
.await?;
let comments: CommentList = ctx
.client
.get_as(&format!("/v1/tasks/{id}/comments"))
.await?;
out.line("");
out.line(&format!(
"#{} {} [{}] P{}",
detail.id, detail.title, detail.status, detail.priority
));
out.kv("类型", &detail.r#type);
out.kv(
"认领人",
if detail.assignee_name.is_empty() {
"—"
} else {
&detail.assignee_name
},
);
out.kv("创建人", &detail.creator_name);
if !detail.created_at.is_empty() {
out.kv("创建时间", &detail.created_at);
}
if !detail.updated_at.is_empty() {
out.kv("更新时间", &detail.updated_at);
}
out.kv(
"截止",
if detail.due_date.is_empty() {
"—"
} else {
&detail.due_date
},
);
if !detail.tags.is_empty() {
out.kv("标签", &detail.tags.join(", "));
}
// 步骤清单(长任务工作流:恢复上下文时据此知道做到第几步)
if let Ok(steps) = serde_json::from_str::<Vec<serde_json::Value>>(&detail.checklist)
&& !steps.is_empty()
{
let done = steps
.iter()
.filter(|s| s.get("done").and_then(|d| d.as_bool()).unwrap_or(false))
.count();
out.kv("步骤", &format!("{}/{} 已完成", done, steps.len()));
for s in &steps {
let mark = if s.get("done").and_then(|d| d.as_bool()).unwrap_or(false) {
"✓"
} else {
"·"
};
let text = s.get("text").and_then(|t| t.as_str()).unwrap_or("");
out.line(&format!(" {mark} {text}"));
}
}
if !detail.description.is_empty() {
out.line("");
out.line("── 描述 ──");
out.line(&detail.description);
}
if !detail.artifacts.is_empty() {
out.line("");
out.line("── Artifacts ──");
out.line(&detail.artifacts);
}
if detail.watcher_count > 0 {
let self_mark = if detail.watching {
"(已关注)"
} else {
""
};
out.kv(
"关注",
&format!(
"{} 人{self_mark}:{}",
detail.watcher_count,
detail.watchers.join("、")
),
);
}
if !detail.sub_tasks.is_empty() {
out.line("");
out.line(&format!("── 子任务({})──", detail.sub_tasks.len()));
for st in &detail.sub_tasks {
out.line(&format!(
" #{} [{}] {}(P{})",
st.id, st.status, st.title, st.priority
));
}
}
out.line("");
out.line(&format!(
"── 执行日志(最近 {} / 共 {})──",
logs.list.len().min(5),
logs.list.len()
));
for l in logs.list.iter().rev().take(5).rev() {
out.line(&format!(
" [{}] {} · {} ({})",
l.created_at, l.ai_username, l.action, l.status
));
if !l.detail.is_empty() {
out.line(&format!(" {}", l.detail));
}
}
out.line("");
out.line(&format!(
"── 评论(最近 {} / 共 {})──",
comments.list.len().min(5),
comments.list.len()
));
for c in comments.list.iter().rev().take(5).rev() {
out.line(&format!(
" [{}] {}: {}",
c.created_at, c.real_name, c.content
));
}
out.emit_value(&json!({
"task": serde_json::to_value(&detail)?,
"logs": serde_json::to_value(&logs)?,
"comments": serde_json::to_value(&comments)?,
}));
Ok(())
}
/// `bcode claim <id>`(F07):原子认领;被抢则业务错(退出码 6)。
/// 租约契约:认领后 2 小时无平台侧动作自动释放——长任务周期 `bcode log` 保活。
pub async fn claim(cfg: &Config, profile: &str, id: i64, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
// 归属预检:不属于当前指向项目的任务直接早失败给明确文案——
// 平台侧会话门禁是最终裁决,这里只求错误体验(免一次注定失败的业务错)
if let Ok(d) = ctx
.client
.get_as::<TaskDetail>(&format!("/v1/tasks/{id}"))
.await
&& d.project_id != 0
&& d.project_id != ctx.project.project_id
{
bail!(
"任务 #{id} 属于其它项目(当前指向 {} #{}):请在对应该项目的目录执行",
ctx.project.project_name,
ctx.project.project_id
);
}
ctx.client
.post(&format!("/v1/tasks/{id}/claim"), json!({}))
.await?;
let mut payload = json!({ "claimed": true, "task_id": id });
// 回显要点为增强体验;失败不影响认领结果
match ctx
.client
.get_as::<TaskDetail>(&format!("/v1/tasks/{id}"))
.await
{
Ok(d) => {
out.kv("已认领", &d.one_line());
payload["task"] = serde_json::to_value(&d)?;
}
Err(_) => out.kv("已认领", &format!("#{id}")),
}
out.kv(
"租约",
"2 小时无动作将自动释放,长任务请周期 bcode log 保活",
);
out.emit_value(&payload);
Ok(())
}
/// `bcode release <id>`(v3 新增,平台 316fbd5):认领人主动放回任务池——
/// 认领错了/依赖阻塞时即刻释出,不必干等 2h 租约;仅 assignee 本人,留痕 released。
pub async fn release(cfg: &Config, profile: &str, id: i64, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(&format!("/v1/tasks/{id}/release"), json!({}))
.await?;
out.kv("已释放", &format!("#{id} → open(任务回池,他人可认领)"));
out.emit_value(&json!({ "released": true, "task_id": id }));
Ok(())
}
/// `bcode block <id> --reason <原因>`:上报阻塞(in_progress→blocked)。
/// blocked 豁免租约回收——等 CI/等人/等环境时主动举手,不会被 2h 超时误回收。
pub async fn block(cfg: &Config, profile: &str, id: i64, reason: &str, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(
&format!("/v1/tasks/{id}/block"),
json!({ "reason": reason }),
)
.await?;
out.kv(
"已阻塞",
&format!("#{id} → blocked(豁免租约回收;原因已留痕)"),
);
out.emit_value(&json!({ "blocked": true, "task_id": id, "reason": reason }));
Ok(())
}
/// `bcode unblock <id>`:解除阻塞(blocked→in_progress,恢复执行)。
pub async fn unblock(cfg: &Config, profile: &str, id: i64, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(&format!("/v1/tasks/{id}/unblock"), json!({}))
.await?;
out.kv("已解除", &format!("#{id} → in_progress(恢复执行)"));
out.emit_value(&json!({ "unblocked": true, "task_id": id }));
Ok(())
}
/// `bcode watch <id>`:关注任务(订阅动态通知)。平台 watcher API:
/// 评论/认领/完成/阻塞/解除/重开/审核七类事件向关注者扇出通知(操作者本人除外)。
/// 幂等——重复关注无副作用;读语义,只读能力即可关注。
pub async fn watch(cfg: &Config, profile: &str, id: i64, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(&format!("/v1/tasks/{id}/watch"), json!({}))
.await?;
out.kv(
"已关注",
&format!("#{id}(动态将进通知中心:评论/认领/完成/阻塞/解除/重开/审核)"),
);
out.emit_value(&json!({ "watching": true, "task_id": id }));
Ok(())
}
/// `bcode unwatch <id>`:取消关注任务(停止订阅动态通知)。
pub async fn unwatch(cfg: &Config, profile: &str, id: i64, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(&format!("/v1/tasks/{id}/unwatch"), json!({}))
.await?;
out.kv("已取关", &format!("#{id}(不再接收该任务动态)"));
out.emit_value(&json!({ "watching": false, "task_id": id }));
Ok(())
}
/// `bcode reopen <id> --reason <原因>`(v3 新增):终态任务(done/closed)重开 → open,
/// 原因必填(留痕)。复核不通过/回归问题时的回退路径。
pub async fn reopen(cfg: &Config, profile: &str, id: i64, reason: &str, out: &Out) -> Result<()> {
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(
&format!("/v1/tasks/{id}/reopen"),
json!({ "reason": reason }),
)
.await?;
out.kv("已重开", &format!("#{id} → open(原因已留痕)"));
out.emit_value(&json!({ "reopened": true, "task_id": id, "reason": reason }));
Ok(())
}
/// `bcode complete <id>`(F08):完成任务进 review;artifacts 为 markdown 产出。
pub async fn complete(
cfg: &Config,
profile: &str,
id: i64,
artifacts: Option<&str>,
artifacts_file: Option<&str>,
note: Option<&str>,
out: &Out,
) -> Result<()> {
let mut text = match (artifacts_file, artifacts) {
(Some(path), _) => std::fs::read_to_string(path)
.with_context(|| format!("读取 artifacts 文件 {path} 失败"))?,
(None, Some(a)) => a.to_string(),
(None, None) => String::new(),
};
if let Some(n) = note {
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str(&format!("## 备注\n{n}"));
}
// 平台侧无 artifacts 长度校验(DB TEXT),CLI 兜底防误传超大文件整段上传
const MAX_ARTIFACTS_BYTES: usize = 1024 * 1024;
if text.len() > MAX_ARTIFACTS_BYTES {
bail!(
"artifacts 过大({} 字节,上限 1 MiB):请精简正文或拆分后用 log 补充",
text.len()
);
}
let ctx = project_ctx(cfg, profile).await?;
ctx.client
.post(
&format!("/v1/tasks/{id}/complete"),
json!({ "artifacts": text }),
)
.await?;
out.kv("已完成", &format!("#{id} → review(等待人工审核)"));
out.emit_value(&json!({ "completed": true, "task_id": id }));
Ok(())
}
/// `bcode log <id> <message>`(F09):过程留痕(执行日志流)。
/// 同时是认领租约的心跳——平台侧动作会刷新 2h 租约。
pub async fn log(
cfg: &Config,
profile: &str,
id: i64,
message: &str,
status: &str,
action: &str,
out: &Out,
) -> Result<()> {
// 平台 schema 约束 CHECK(status IN ('success','failed'))——"running" 不可入库
if !matches!(status, "success" | "failed") {
bail!("--status 仅支持 success / failed(平台约束)");
}
let ctx = project_ctx(cfg, profile).await?;
let created: CreatedId = ctx
.client
.post_as(
&format!("/v1/tasks/{id}/ai-logs"),
json!({
"aiUserId": ctx.credential.agent_id,
"action": action,
"detail": message,
"status": status,
}),
)
.await?;
out.kv("留痕", &format!("#{id} action={action} status={status}"));
out.emit_value(&json!({ "logged": true, "log_id": created.id, "task_id": id }));
Ok(())
}
/// `bcode create`(实验性,v1 反馈新增):建任务——CLI 侧补齐建任务入口,
/// 绕开 Windows 下 curl 内联中文的编码坑(--file 由 Rust 按 UTF-8 读取、
/// argv 天然无代码页问题)。请求体字段即平台 TaskCreateReq。
pub async fn create(
cfg: &Config,
profile: &str,
a: &crate::cli::CreateArgs,
out: &Out,
) -> Result<()> {
// 请求体组装在联网前:缺 title 立即报错,不浪费一次握手
let mut body = match a.file.as_deref() {
Some(p) => serde_json::from_str(
&std::fs::read_to_string(p).with_context(|| format!("读取 {p} 失败"))?,
)
.with_context(|| format!("解析 {p} 为 JSON 失败"))?,
None => json!({}),
};
if let Some(t) = a.title.as_deref() {
body["title"] = json!(t);
}
if let Some(d) = a.description.as_deref() {
body["description"] = json!(d);
}
if let Some(t) = a.r#type.as_deref() {
body["type"] = json!(t);
}
if let Some(p) = a.priority {
body["priority"] = json!(p);
}
if let Some(d) = a.due.as_deref() {
body["dueDate"] = json!(d);
}
if let Some(p) = a.parent {
body["parentTaskId"] = json!(p);
}
if let Some(sp) = a.sprint {
body["sprintId"] = json!(sp);
}
let title = body
.get("title")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("title 必填:--title <标题> 或 --file JSON 内提供"))?;
// 类型纪律(平台反馈 #4):缺省 feature 会让类型统计失真——stderr 提示,不污染 --json 契约
if body
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("")
.is_empty()
{
eprintln!("bcode: ⚠ 未指定 --type,默认 feature(按实际语义传 feature/bug/chore/test)");
}
let ctx = project_ctx(cfg, profile).await?;
let created: CreatedId = ctx
.client
.post_as(
&format!("/v1/projects/{}/tasks", ctx.project.project_id),
body.clone(),
)
.await?;
out.kv("已创建", &format!("#{} {title}", created.id));
out.emit_value(&json!({ "created": true, "task_id": created.id, "title": title }));
Ok(())
}
/// `bcode update <id>`(v2 反馈新增):改任务字段。平台门禁:agent 禁改
/// status/assigneeId(状态流转必须走 claim/complete/block),CLI 不暴露这两项。
/// 指针语义:只提交出现的字段,其余不动;--due "" 传空串=清除截止;
/// --checklist-file 传步骤清单 JSON([{text,done}],打勾=进展顺带续租约)。
pub async fn update(cfg: &Config, profile: &str, a: &UpdateArgs, out: &Out) -> Result<()> {
let mut body = json!({});
if let Some(t) = a.title.as_deref() {
body["title"] = json!(t);
}
if let Some(d) = a.description.as_deref() {
body["description"] = json!(d);
}
if let Some(t) = a.r#type.as_deref() {
body["type"] = json!(t);
}
if let Some(p) = a.priority {
body["priority"] = json!(p);
}
if let Some(d) = a.due.as_deref() {
body["dueDate"] = json!(d);
}
if let Some(p) = a.parent {
body["parentTaskId"] = json!(p);
}
if let Some(sp) = a.sprint {
body["sprintId"] = json!(sp);
}
if let Some(f) = a.checklist_file.as_deref() {
let raw = std::fs::read_to_string(f).with_context(|| format!("读取 {f} 失败"))?;
// 校验为 JSON 数组即透传(平台解析 [{text,done}]),坏文件在联网前报错
serde_json::from_str::<Vec<serde_json::Value>>(&raw)
.with_context(|| format!("{f} 不是合法的 JSON 数组(应为 [{{text,done}}])"))?;
body["checklist"] = json!(raw);
}
if body.as_object().is_none_or(|m| m.is_empty()) {
bail!(
"未指定修改项:--title/--description/--type/--priority/--due/--checklist-file 至少一个(状态流转走 claim/complete/block,改派是 owner 权限)"
);
}
let ctx = project_ctx(cfg, profile).await?;
ctx.client.put(&format!("/v1/tasks/{}", a.id), body).await?;
// 回显要点(失败不影响更新结果)
let mut payload = json!({ "updated": true, "task_id": a.id });
match ctx
.client
.get_as::<TaskDetail>(&format!("/v1/tasks/{}", a.id))
.await
{
Ok(d) => {
out.kv("已更新", &d.one_line());
payload["task"] = serde_json::to_value(&d)?;
}
Err(_) => out.kv("已更新", &format!("#{}", a.id)),
}
out.emit_value(&payload);
Ok(())
}
/// 列表排序:priority=优先级升序(P1 在前)+ id 降序;id=平台原始顺序(id 降序)
fn sort_tasks(list: &mut [TaskBrief], sort: &str) {
match sort {
"id" => list.sort_by_key(|t| std::cmp::Reverse(t.id)),
_ => list.sort_by_key(|t| (t.priority, std::cmp::Reverse(t.id))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn brief(id: i64, priority: i64) -> TaskBrief {
TaskBrief {
id,
title: format!("t{id}"),
status: "open".into(),
r#type: "chore".into(),
priority,
tags: vec![],
due_date: None,
updated_at: String::new(),
}
}
#[test]
fn sort_puts_low_priority_number_first() {
let mut list = vec![brief(30, 3), brief(31, 1), brief(25, 1), brief(28, 2)];
sort_tasks(&mut list, "priority");
let ids: Vec<i64> = list.iter().map(|t| t.id).collect();
// P1 组内 id 降序,然后 P2、P3
assert_eq!(ids, vec![31, 25, 28, 30]);
let mut raw = vec![brief(1, 5), brief(9, 1)];
sort_tasks(&mut raw, "id");
assert_eq!(raw[0].id, 9);
}
}