task-ledger 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SKILL.md +30 -17
- package/package.json +1 -1
- package/references/state-model.md +12 -10
- package/references/troubleshooting.md +19 -7
- package/scripts/ledgerkit/actions/atom.py +2 -2
- package/scripts/ledgerkit/actions/bootstrap.py +9 -2
- package/scripts/ledgerkit/actions/claim.py +7 -6
- package/scripts/ledgerkit/actions/close_task.py +17 -12
- package/scripts/ledgerkit/actions/guard.py +38 -16
- package/scripts/ledgerkit/actions/handoff.py +24 -8
- package/scripts/ledgerkit/actions/init_identity.py +90 -0
- package/scripts/ledgerkit/actions/list_tasks.py +21 -3
- package/scripts/ledgerkit/actions/log_progress.py +5 -2
- package/scripts/ledgerkit/actions/new_task.py +11 -7
- package/scripts/ledgerkit/actions/query.py +4 -2
- package/scripts/ledgerkit/actions/query_history.py +55 -9
- package/scripts/ledgerkit/actions/registry.py +1 -1
- package/scripts/ledgerkit/actions/release.py +25 -4
- package/scripts/ledgerkit/actions/set_task.py +18 -7
- package/scripts/ledgerkit/actions/validate.py +0 -4
- package/scripts/ledgerkit/core/history_md.py +47 -250
- package/scripts/ledgerkit/core/identity.py +34 -7
- package/scripts/ledgerkit/core/labels.py +0 -1
- package/scripts/ledgerkit/core/paths.py +0 -1
- package/scripts/ledgerkit/core/worklog_md.py +23 -3
- package/scripts/ledgerkit/env.py +70 -71
- package/scripts/ledgerkit/shell/cli.py +60 -3
- package/scripts/ledgerkit/shell/gitops.py +14 -1
- package/scripts/ledgerkit/shell/render.py +3 -2
- package/scripts/ledgerkit/shell/runner.py +1 -1
- package/scripts/ledgerkit/store/history_store.py +26 -81
- package/scripts/ledgerkit/store/owners_index.py +16 -3
- package/scripts/ledgerkit/store/task_store.py +2 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""init:初始化本机身份(写 `~/.taskledger/identity.json`,一台机器只一次)。
|
|
2
|
+
|
|
3
|
+
只做逻辑,不打印、不读 stdin;交互/机器读在 shell 层。
|
|
4
|
+
- `claim`:认领名册里已有的 uuid(连 name 一起复制,不输入)。
|
|
5
|
+
- `new` :新建 uuid(必须给 name)。
|
|
6
|
+
- 都不给且名册有人:返回候选列表,交给调用方去问用户。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import uuid as uuidlib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .. import env
|
|
17
|
+
from ..core.identity import Identity, looks_like_uuid, normalize
|
|
18
|
+
from ..core.paths import OWNERS_REL, to_repo_rel
|
|
19
|
+
from ..errors import Fail
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_workers(repo: Path | None) -> dict[str, Any]:
|
|
23
|
+
if repo is None:
|
|
24
|
+
return {}
|
|
25
|
+
try:
|
|
26
|
+
raw = (repo / to_repo_rel(OWNERS_REL)).read_text(encoding="utf-8")
|
|
27
|
+
data = json.loads(raw)
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
return {}
|
|
30
|
+
return (data or {}).get("workers", {}) or {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init_identity(
|
|
34
|
+
repo: Path | None, *, claim: str | None, new: bool, name: str
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
path = env.identity_file()
|
|
37
|
+
existing = env.read_identity()
|
|
38
|
+
if existing is not None:
|
|
39
|
+
# 已初始化 = 幂等空转(与 release/claim/close 的重跑口径一致):退 0、零写。
|
|
40
|
+
# 只有「认领另一个 uuid」才是真冲突——换身份不归 init 管。
|
|
41
|
+
if claim is None or normalize(claim) == existing.uuid:
|
|
42
|
+
return {
|
|
43
|
+
"already": True,
|
|
44
|
+
"uuid": existing.uuid,
|
|
45
|
+
"name": existing.name,
|
|
46
|
+
"path": str(path),
|
|
47
|
+
}
|
|
48
|
+
raise Fail(
|
|
49
|
+
f"这台机器已经是 {existing.uuid}({path}),不能再认领 {claim.strip()}。\n"
|
|
50
|
+
" 处理:要换身份请手动改该文件的 uuid;一台机器一个身份。"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
workers = _read_workers(repo)
|
|
54
|
+
if claim:
|
|
55
|
+
key = normalize(claim) # 名册键一律小写;--claim 抄来的大小写不影响
|
|
56
|
+
# 认领目标必须是 uuid:老台账的键还可能是个邮箱 / 名字,认来会让整台机器
|
|
57
|
+
# 拿非 uuid 当身份写进每一处(--owner 反倒拒收自己)
|
|
58
|
+
if not looks_like_uuid(key):
|
|
59
|
+
raise Fail(
|
|
60
|
+
f"认领目标不是 uuid:{claim.strip()}\n"
|
|
61
|
+
" 处理:老台账(身份键是邮箱 / 名字)请改走 `init --new --name=<名字>`;"
|
|
62
|
+
"先把那本台账迁到 uuid 身份,再谈认领。"
|
|
63
|
+
)
|
|
64
|
+
record = workers.get(key)
|
|
65
|
+
if record is None:
|
|
66
|
+
raise Fail(
|
|
67
|
+
f"名册里没有 uuid={claim}。\n"
|
|
68
|
+
" 处理:不带参数跑一次 init 看可选用户,再 --claim 选一个。"
|
|
69
|
+
)
|
|
70
|
+
identity = Identity(uuid=key, name=str(record.get("name") or ""))
|
|
71
|
+
elif workers and not new:
|
|
72
|
+
return {
|
|
73
|
+
"need_choose": True,
|
|
74
|
+
"path": str(path),
|
|
75
|
+
"options": [
|
|
76
|
+
{"uuid": key, "name": str((rec or {}).get("name") or "")}
|
|
77
|
+
for key, rec in workers.items()
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
else:
|
|
81
|
+
if not name.strip():
|
|
82
|
+
raise Fail("新建身份必须输入一个名字(--name=<名字>)。")
|
|
83
|
+
identity = Identity(uuid=str(uuidlib.uuid4()), name=name.strip())
|
|
84
|
+
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
payload = {"uuid": identity.uuid, "name": identity.name}
|
|
87
|
+
path.write_text(
|
|
88
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
89
|
+
)
|
|
90
|
+
return {"uuid": identity.uuid, "name": identity.name, "path": str(path)}
|
|
@@ -2,20 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from dataclasses import replace
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from ..core.identity import Identity
|
|
10
|
+
from ..core.labels import STATUSES
|
|
9
11
|
from ..core.paths import POOL_REL
|
|
12
|
+
from ..errors import Fail
|
|
10
13
|
from ..store.owners_index import OwnersIndex
|
|
11
14
|
from ..store.task_store import TaskStore
|
|
12
|
-
from .guard import in_scope, owner_of, status_of
|
|
15
|
+
from .guard import in_scope, owner_of, require_uuid, status_of
|
|
13
16
|
from .query import ListQuery
|
|
14
17
|
from .result import ListResult, TaskRow, rel
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
|
|
18
21
|
"""合并顺序定死:先池、再按索引键排序的各分片,同一任务号由分片那份胜出。"""
|
|
22
|
+
# `--owner` 只认 uuid,且**给了就必须生效**:和 --mine / --pool 同传直接报错,
|
|
23
|
+
# 不许出现「传了 uuid 却被范围参数静默吃掉」(--owner "" 也算给了,同样报错)。
|
|
24
|
+
if q.owner is not None:
|
|
25
|
+
if q.scope in ("mine", "pool"):
|
|
26
|
+
raise Fail("--owner 不能和 --mine / --pool 一起用:一次只按一种范围筛。")
|
|
27
|
+
q = replace(q, owner=require_uuid(q.owner, field="--owner"))
|
|
28
|
+
# `--status` 只认白名单:`--status blocked` 这种早该消失的值不许静默返回空表
|
|
29
|
+
unknown = [s for s in q.statuses if s not in STATUSES]
|
|
30
|
+
if unknown:
|
|
31
|
+
raise Fail(
|
|
32
|
+
f"--status 只认 {' / '.join(STATUSES)}(收到 {', '.join(unknown)})。"
|
|
33
|
+
)
|
|
19
34
|
rows: dict[str, dict[str, Any]] = {}
|
|
20
35
|
where: dict[str, Path] = {}
|
|
21
36
|
|
|
@@ -33,13 +48,16 @@ def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
|
|
|
33
48
|
rows[tid] = task
|
|
34
49
|
where[tid] = store.path
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
name_keys = index.keys_of_name(q.owner_name) if q.owner_name else set()
|
|
52
|
+
kept = {
|
|
53
|
+
tid: t for tid, t in rows.items() if in_scope(t, q, me=me, name_keys=name_keys)
|
|
54
|
+
}
|
|
37
55
|
table = tuple(
|
|
38
56
|
TaskRow(
|
|
39
57
|
tid=tid,
|
|
40
58
|
title=str(task.get("title") or ""),
|
|
41
59
|
status=status_of(task),
|
|
42
|
-
owner=owner_of(task),
|
|
60
|
+
owner=task.get("owner_name") or owner_of(task),
|
|
43
61
|
created=task.get("created"),
|
|
44
62
|
started=task.get("started"),
|
|
45
63
|
category=task.get("category"),
|
|
@@ -11,6 +11,7 @@ from ..core.worklog_md import exec_line
|
|
|
11
11
|
from ..errors import Fail
|
|
12
12
|
from ..store.task_store import TaskStore
|
|
13
13
|
from ..store.worklog_store import WorklogStore
|
|
14
|
+
from .guard import require_owner
|
|
14
15
|
from .result import Result, rel
|
|
15
16
|
|
|
16
17
|
|
|
@@ -23,10 +24,12 @@ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
|
|
|
23
24
|
text = " ".join((note or "").split())
|
|
24
25
|
if not text:
|
|
25
26
|
raise Fail("--note 不能为空。")
|
|
26
|
-
mine = TaskStore(repo, shard_rel(me.
|
|
27
|
+
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
27
28
|
task = mine.get(tid) # 锁内重读
|
|
28
29
|
if task is None:
|
|
29
30
|
raise Fail(f"任务 {tid} 不在我的分片里(只能给自己的任务记进展)。")
|
|
31
|
+
# 分片里有、行内 owner 却不是我 ⇒ 台账脏了(validate ⑤),先拦下来
|
|
32
|
+
require_owner(task, tid, me)
|
|
30
33
|
|
|
31
34
|
updated = dict(task)
|
|
32
35
|
updated["updated"] = now()
|
|
@@ -43,7 +46,7 @@ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
|
|
|
43
46
|
"提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
|
|
44
47
|
),
|
|
45
48
|
)
|
|
46
|
-
ok = logs.append_exec(path, exec_line(now(), me.
|
|
49
|
+
ok = logs.append_exec(path, exec_line(now(), me.stamp(), "log", text))
|
|
47
50
|
if not ok:
|
|
48
51
|
return Result(
|
|
49
52
|
changed=("updated",),
|
|
@@ -50,7 +50,7 @@ def new_task(
|
|
|
50
50
|
category = normalize_label(category, field="分类") if category else None
|
|
51
51
|
tag_list = parse_tags(tags)
|
|
52
52
|
|
|
53
|
-
mine_store = TaskStore(repo, shard_rel(me.
|
|
53
|
+
mine_store = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
54
54
|
pool = TaskStore(repo, POOL_REL)
|
|
55
55
|
target = mine_store if mine else pool
|
|
56
56
|
|
|
@@ -66,8 +66,10 @@ def new_task(
|
|
|
66
66
|
# O(1),不必扫历史文件。上面分片 / 池是并发安全闸门,这里补文件级的精确判重。
|
|
67
67
|
if (repo / to_repo_rel(worklog_rel(tid, created))).exists():
|
|
68
68
|
raise Fail(
|
|
69
|
-
f"任务号 {tid} 的过程文档已经存在(同名号的在办 /
|
|
70
|
-
"
|
|
69
|
+
f"任务号 {tid} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)。\n"
|
|
70
|
+
" 处理:换一个 --id,别复用历史号。\n"
|
|
71
|
+
" 若上次 new 崩在中间(文档已写、台账没落),这篇就是它留下的:确认内容要保留就换号重建;"
|
|
72
|
+
"非要复用这一号,先把那篇文档挪走再重跑。"
|
|
71
73
|
)
|
|
72
74
|
|
|
73
75
|
doc_rel = worklog_rel(tid, created) # 别叫 rel:那是 result.rel 的函数名
|
|
@@ -80,8 +82,10 @@ def new_task(
|
|
|
80
82
|
"created": created,
|
|
81
83
|
# --self = 建并认领:直接归自己名下就必须是在做,不留 open 半成品
|
|
82
84
|
"status": "doing" if mine else "open",
|
|
83
|
-
"owner": me.
|
|
84
|
-
"
|
|
85
|
+
"owner": me.uuid if mine else None,
|
|
86
|
+
"owner_name": me.name if mine else None,
|
|
87
|
+
"creator": me.uuid,
|
|
88
|
+
"creator_name": me.name,
|
|
85
89
|
"category": category,
|
|
86
90
|
"tags": list(tag_list),
|
|
87
91
|
"worklog": doc_rel,
|
|
@@ -95,11 +99,11 @@ def new_task(
|
|
|
95
99
|
tid=tid,
|
|
96
100
|
title=clean_title,
|
|
97
101
|
created=created,
|
|
98
|
-
creator=me.
|
|
102
|
+
creator=me.stamp(),
|
|
99
103
|
category=category,
|
|
100
104
|
tags=tag_list,
|
|
101
105
|
first_exec=exec_line(
|
|
102
|
-
created, me.
|
|
106
|
+
created, me.stamp(), "new", "创建任务并认领" if mine else "创建任务"
|
|
103
107
|
),
|
|
104
108
|
)
|
|
105
109
|
target.put(tid, task)
|
|
@@ -8,7 +8,8 @@ from dataclasses import dataclass
|
|
|
8
8
|
@dataclass(frozen=True)
|
|
9
9
|
class ListQuery:
|
|
10
10
|
scope: str = "all" # "all" / "pool" / "mine" / "owner"
|
|
11
|
-
owner: str | None = None # scope="owner"
|
|
11
|
+
owner: str | None = None # scope="owner" 时生效;**只收 uuid**
|
|
12
|
+
owner_name: str | None = None # 显示名模糊筛(子串、不分大小写);只筛不判定
|
|
12
13
|
statuses: tuple[str, ...] = ()
|
|
13
14
|
category: str | None = None
|
|
14
15
|
tag: str | None = None
|
|
@@ -19,7 +20,8 @@ class ListQuery:
|
|
|
19
20
|
|
|
20
21
|
@dataclass(frozen=True)
|
|
21
22
|
class HistoryQuery:
|
|
22
|
-
owner: str | None = None
|
|
23
|
+
owner: str | None = None # **只收 uuid**
|
|
24
|
+
owner_name: str | None = None # 显示名模糊筛(子串、不分大小写)
|
|
23
25
|
mine: bool = False
|
|
24
26
|
category: str | None = None
|
|
25
27
|
tag: str | None = None
|
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
from dataclasses import replace
|
|
9
10
|
from datetime import datetime, timedelta
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
14
|
from ..core import history_md
|
|
14
15
|
from ..core.identity import Identity, normalize
|
|
15
|
-
from ..core.paths import HISTORY_DIR,
|
|
16
|
+
from ..core.paths import HISTORY_DIR, month_of, to_repo_rel
|
|
16
17
|
from ..core.time import now, time_key
|
|
17
18
|
from ..errors import Fail
|
|
18
19
|
from ..store.history_store import HistoryStore
|
|
20
|
+
from ..store.owners_index import OwnersIndex
|
|
21
|
+
from .guard import require_uuid
|
|
19
22
|
from .query import COUNT_CAP, HistoryQuery
|
|
20
23
|
from .result import HistoryResult, rel
|
|
21
24
|
|
|
@@ -102,14 +105,19 @@ def _target_month_keys(q: HistoryQuery) -> list[str]:
|
|
|
102
105
|
|
|
103
106
|
|
|
104
107
|
def _candidate_paths(repo: Path, keys: list[str]) -> list[Path]:
|
|
105
|
-
"""按月键【直接拼】`history/<年月>.md
|
|
106
|
-
|
|
107
|
-
paths.append(repo / to_repo_rel(LEGACY_HISTORY_REL))
|
|
108
|
-
return paths
|
|
108
|
+
"""按月键【直接拼】`history/<年月>.md`(不扫磁盘)。"""
|
|
109
|
+
return [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
|
|
109
110
|
|
|
110
111
|
|
|
111
112
|
def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
112
|
-
"""`--owner` / `--mine`
|
|
113
|
+
"""`--owner` / `--mine` 一律按 **uuid 精确相等**;名字只能走 `--owner-name` 模糊筛。
|
|
114
|
+
|
|
115
|
+
`--since` / `--until` 比 `finished`(结案时间)。
|
|
116
|
+
"""
|
|
117
|
+
if q.owner is not None:
|
|
118
|
+
if q.mine:
|
|
119
|
+
raise Fail("--owner 不能和 --mine 一起用:一次只按一种范围筛。")
|
|
120
|
+
q = replace(q, owner=require_uuid(q.owner, field="--owner"))
|
|
113
121
|
keys = _target_month_keys(q)
|
|
114
122
|
store = HistoryStore(repo)
|
|
115
123
|
records: list[dict[str, Any]] = []
|
|
@@ -126,12 +134,36 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
|
126
134
|
row["finished"] = time_key(row.get("finished") or "") or row.get("finished")
|
|
127
135
|
records.append(row)
|
|
128
136
|
|
|
137
|
+
index = OwnersIndex(repo)
|
|
138
|
+
roster = index.all()
|
|
129
139
|
if q.mine:
|
|
130
|
-
wanted = {normalize(me.
|
|
140
|
+
wanted = {normalize(me.uuid)}
|
|
131
141
|
records = [r for r in records if normalize(r.get("owner") or "") in wanted]
|
|
132
142
|
elif q.owner:
|
|
133
143
|
key = normalize(q.owner)
|
|
134
144
|
records = [r for r in records if normalize(r.get("owner") or "") == key]
|
|
145
|
+
if q.owner_name:
|
|
146
|
+
# 名字已经落在历史行里 ⇒ 直接比它(子串、不分大小写)。落的是**当时**那个名字,
|
|
147
|
+
# 改名后的旧行仍留旧名 ⇒ 按新名筛不到旧行,这是快照语义,不是漏。
|
|
148
|
+
# 老行(升级前落的)没有名字列,退回按 uuid 片段比,不至于一条都筛不出来。
|
|
149
|
+
needle = q.owner_name.strip().lower()
|
|
150
|
+
records = [
|
|
151
|
+
r
|
|
152
|
+
for r in records
|
|
153
|
+
if needle in str(r.get("owner_name") or "").lower()
|
|
154
|
+
or needle in normalize(r.get("owner") or "")
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# 名字**已经落盘**(`owner_name` / `creator_name` 两列)。这里只给没名字的**老行**
|
|
158
|
+
# 按名册兜底补一次 —— 补的是读时展示值,不回写文件。
|
|
159
|
+
for row in records:
|
|
160
|
+
for field, target in (("owner", "owner_name"), ("creator", "creator_name")):
|
|
161
|
+
if row.get(target):
|
|
162
|
+
continue
|
|
163
|
+
key = normalize(row.get(field) or "")
|
|
164
|
+
name = str((roster.get(key) or {}).get("name") or "")
|
|
165
|
+
if name:
|
|
166
|
+
row[target] = name
|
|
135
167
|
if q.category:
|
|
136
168
|
records = [r for r in records if (r.get("category") or "") == q.category]
|
|
137
169
|
if q.tag:
|
|
@@ -142,19 +174,33 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
|
142
174
|
lo_key, hi_key = min(keys), max(keys)
|
|
143
175
|
eff_since = q.since or f"{lo_key[0:4]}-{lo_key[4:6]}-01 00:00:00"
|
|
144
176
|
eff_until = q.until or _month_end(hi_key)
|
|
177
|
+
if eff_since > eff_until:
|
|
178
|
+
raise Fail(
|
|
179
|
+
f"时间区间反了:--since({eff_since})晚于 --until({eff_until})。\n"
|
|
180
|
+
" 处理:把两端换过来;只给一端就只给一端。"
|
|
181
|
+
)
|
|
145
182
|
records = [r for r in records if (r.get("finished") or "") >= eff_since]
|
|
146
183
|
records = [r for r in records if (r.get("finished") or "") <= eff_until]
|
|
147
184
|
|
|
148
185
|
records.sort(key=lambda r: str(r.get("finished") or ""), reverse=True)
|
|
186
|
+
if q.count_only and q.limit is not None:
|
|
187
|
+
raise Fail("--count-only 与 --limit 不能同时用:计数不截断,列表才截断。")
|
|
188
|
+
limit: int | None = None
|
|
189
|
+
if q.limit is not None:
|
|
190
|
+
limit = int(q.limit)
|
|
191
|
+
if limit < 1:
|
|
192
|
+
raise Fail(f"--limit 必须 ≥ 1(收到 {limit})。")
|
|
149
193
|
capped = False
|
|
150
194
|
if q.count_only and len(records) > COUNT_CAP:
|
|
151
195
|
capped = True
|
|
152
196
|
records = records[:COUNT_CAP]
|
|
153
|
-
elif
|
|
154
|
-
|
|
197
|
+
elif limit is not None:
|
|
198
|
+
capped = len(records) > limit # 截断了要让调用方知道「还有更多」
|
|
199
|
+
records = records[:limit]
|
|
155
200
|
|
|
156
201
|
filters = {
|
|
157
202
|
"owner": q.owner,
|
|
203
|
+
"owner_name": q.owner_name,
|
|
158
204
|
"mine": q.mine,
|
|
159
205
|
"category": q.category,
|
|
160
206
|
"tag": q.tag,
|
|
@@ -43,7 +43,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
|
|
43
43
|
"set": ActionSpec(
|
|
44
44
|
set_task.set_task,
|
|
45
45
|
"result",
|
|
46
|
-
params=("tid", "owner"),
|
|
46
|
+
params=("tid", "owner", "release"),
|
|
47
47
|
lock_action="set",
|
|
48
48
|
),
|
|
49
49
|
"log": ActionSpec(log_progress.log_progress, "result", params=("tid", "note")),
|
|
@@ -11,16 +11,25 @@ from ..core.worklog_md import exec_line
|
|
|
11
11
|
from ..errors import Fail
|
|
12
12
|
from ..store.task_store import TaskStore
|
|
13
13
|
from ..store.worklog_store import WorklogStore
|
|
14
|
+
from .guard import owner_of, require_owner, status_of
|
|
14
15
|
from .result import Result, rel
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def release(repo: Path, me: Identity, tid: str) -> Result:
|
|
18
19
|
"""写序:写回池(已有同号就不覆盖)→ 删我分片那行 → 过程文档记一笔。"""
|
|
19
20
|
pool = TaskStore(repo, POOL_REL)
|
|
20
|
-
mine = TaskStore(repo, shard_rel(me.
|
|
21
|
+
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
21
22
|
task = mine.get(tid) # 锁内重读
|
|
22
23
|
if task is None:
|
|
23
|
-
|
|
24
|
+
in_pool = pool.get(tid)
|
|
25
|
+
if in_pool is not None:
|
|
26
|
+
# 幂等分支也要验「池里那条真的已经是没人要的开放任务」,
|
|
27
|
+
# 否则池里混进带 owner 的脏行时,release 会报「无需重复释放」而任务实际仍被占
|
|
28
|
+
if owner_of(in_pool) or status_of(in_pool) != "open":
|
|
29
|
+
raise Fail(
|
|
30
|
+
f"任务 {tid} 在公共池里,但身上还挂着 owner / 不是 open —— 台账脏了。\n"
|
|
31
|
+
" 处理:跑 validate 看清单,别用 release 强行覆盖。"
|
|
32
|
+
)
|
|
24
33
|
return Result(
|
|
25
34
|
already_done=True,
|
|
26
35
|
where=rel(repo, pool.path),
|
|
@@ -28,12 +37,24 @@ def release(repo: Path, me: Identity, tid: str) -> Result:
|
|
|
28
37
|
)
|
|
29
38
|
raise Fail(f"任务 {tid} 不在我的分片里,也没在公共池里。")
|
|
30
39
|
|
|
40
|
+
# 分片里在、但行内 owner 不是我 ⇒ 台账脏了(validate ⑤):不许把别人的行推进池
|
|
41
|
+
require_owner(task, tid, me)
|
|
42
|
+
|
|
31
43
|
back = dict(task)
|
|
32
44
|
back["owner"] = None
|
|
45
|
+
back["owner_name"] = None # 冗余显示名跟着 owner 一起清,否则池里那条仍显示原负责人
|
|
33
46
|
back["status"] = "open"
|
|
34
47
|
back["updated"] = now()
|
|
35
48
|
if pool.get(tid) is None:
|
|
36
49
|
pool.put(tid, back)
|
|
50
|
+
else:
|
|
51
|
+
# 池里已有同号:那份必须是干净的空位,否则不写回(它的 owner/owner_name 会被旧值带脏)
|
|
52
|
+
existing = pool.get(tid) or {}
|
|
53
|
+
if owner_of(existing) or status_of(existing) != "open":
|
|
54
|
+
raise Fail(
|
|
55
|
+
f"公共池里已经有一条 {tid},且它带着 owner / 不是 open —— 台账脏了。\n"
|
|
56
|
+
" 处理:跑 validate 看清单,先确认哪条是真的。"
|
|
57
|
+
)
|
|
37
58
|
mine.drop(tid)
|
|
38
59
|
|
|
39
60
|
logs = WorklogStore(repo)
|
|
@@ -42,9 +63,9 @@ def release(repo: Path, me: Identity, tid: str) -> Result:
|
|
|
42
63
|
if path is None:
|
|
43
64
|
note = f"任务 {tid} 已退回公共池,但过程文档没找到,跳过执行记录。"
|
|
44
65
|
else:
|
|
45
|
-
logs.append_exec(path, exec_line(now(), me.
|
|
66
|
+
logs.append_exec(path, exec_line(now(), me.stamp(), "release", "释放回公共池"))
|
|
46
67
|
return Result(
|
|
47
|
-
changed=("owner", "status", "updated"),
|
|
68
|
+
changed=("owner", "owner_name", "status", "updated"),
|
|
48
69
|
where=rel(repo, pool.path),
|
|
49
70
|
message=f"释放 {tid}:{back.get('title') or ''}".strip(),
|
|
50
71
|
notices=(note,) if note else (),
|
|
@@ -19,21 +19,32 @@ from ..store.task_store import TaskStore
|
|
|
19
19
|
from . import claim as claim_mod
|
|
20
20
|
from . import handoff as handoff_mod
|
|
21
21
|
from . import release as release_mod
|
|
22
|
+
from .guard import require_uuid
|
|
22
23
|
from .result import Result, rel
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def set_task(
|
|
26
|
-
|
|
26
|
+
def set_task(
|
|
27
|
+
repo: Path,
|
|
28
|
+
me: Identity,
|
|
29
|
+
tid: str,
|
|
30
|
+
*,
|
|
31
|
+
owner: str | None = None,
|
|
32
|
+
release: bool = False,
|
|
33
|
+
) -> Result:
|
|
34
|
+
"""路由:`--release` = 释放;别人的 uuid = 交接;自己的 uuid = 已认领则空转、没认领先认领。"""
|
|
35
|
+
if release:
|
|
36
|
+
if owner is not None:
|
|
37
|
+
raise Fail("--release 与 --owner 不能同时给:释放不带人,交接不带释放。")
|
|
38
|
+
return release_mod.release(repo, me, tid)
|
|
27
39
|
if owner is None:
|
|
28
40
|
raise Fail(
|
|
29
|
-
"set
|
|
41
|
+
"set 必须二选一:`--release`(释放回公共池)或 `--owner <对方 uuid>`(交接)。"
|
|
30
42
|
)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return release_mod.release(repo, me, tid)
|
|
43
|
+
# 交接目标只收 uuid:名字查不到就当场报错,绝不去猜「他大概是这个人」
|
|
44
|
+
target = require_uuid(owner, field="--owner")
|
|
34
45
|
if not is_me(me, target):
|
|
35
46
|
return handoff_mod.handoff(repo, me, tid, to=target)
|
|
36
|
-
mine = TaskStore(repo, shard_rel(me.
|
|
47
|
+
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
37
48
|
if mine.get(tid) is not None:
|
|
38
49
|
return Result(
|
|
39
50
|
already_done=True,
|
|
@@ -16,7 +16,6 @@ from ..core.identity import Identity
|
|
|
16
16
|
from ..core.labels import STATUSES
|
|
17
17
|
from ..core.paths import (
|
|
18
18
|
LEDGER_DIR,
|
|
19
|
-
LEGACY_HISTORY_REL,
|
|
20
19
|
OWNERS_REL,
|
|
21
20
|
POOL_REL,
|
|
22
21
|
to_repo_rel,
|
|
@@ -200,9 +199,6 @@ def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
|
|
|
200
199
|
)
|
|
201
200
|
checked += _check_worklog(repo, logs, problems, notices, deep=deep)
|
|
202
201
|
|
|
203
|
-
legacy = repo / to_repo_rel(LEGACY_HISTORY_REL)
|
|
204
|
-
if legacy.exists():
|
|
205
|
-
notices.append(f"存量平铺文件 {rel(repo, legacy)} 只读兼容,不会重写。")
|
|
206
202
|
if not (repo / to_repo_rel(OWNERS_REL)).exists():
|
|
207
203
|
notices.append("还没有 owners.json(索引):跑一条写命令会自动建。")
|
|
208
204
|
stray = repo / LEDGER_DIR / LEDGER_DIR
|