dsh-task-ledger 0.0.101 → 0.0.103
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/dist/client.js +3 -3
- package/dist/client.js.map +1 -1
- package/dist/host/errors.d.ts +2 -2
- package/dist/host/errors.js +2 -2
- package/dist/host/repositories/runner.d.ts +2 -2
- package/dist/host/repositories/runner.js +13 -10
- package/dist/host/services/session.service.js +1 -1
- package/dist/host/skill/registration.d.ts +2 -1
- package/dist/host/skill/registration.js +4 -3
- package/package.json +2 -2
- package/skill/README.md +4 -2
- package/skill/SKILL.md +5 -3
- package/skill/assets/worklog.md +1 -1
- package/skill/package.json +1 -1
- package/skill/references/init-identity.md +3 -3
- package/skill/references/log-note.md +1 -1
- package/skill/references/troubleshooting.md +1 -1
- package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
- package/skill/scripts/bin/ledger-linux-amd64 +0 -0
- package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
- package/skill/scripts/progress +44 -0
- package/skill/scripts/ledgerkit/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/atom.py +0 -154
- package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
- package/skill/scripts/ledgerkit/actions/claim.py +0 -88
- package/skill/scripts/ledgerkit/actions/close_task.py +0 -191
- package/skill/scripts/ledgerkit/actions/guard.py +0 -158
- package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
- package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
- package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
- package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
- package/skill/scripts/ledgerkit/actions/new_task.py +0 -129
- package/skill/scripts/ledgerkit/actions/query.py +0 -35
- package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
- package/skill/scripts/ledgerkit/actions/registry.py +0 -82
- package/skill/scripts/ledgerkit/actions/relabel.py +0 -89
- package/skill/scripts/ledgerkit/actions/release.py +0 -76
- package/skill/scripts/ledgerkit/actions/result.py +0 -90
- package/skill/scripts/ledgerkit/actions/set_task.py +0 -71
- package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
- package/skill/scripts/ledgerkit/actions/validate.py +0 -189
- package/skill/scripts/ledgerkit/core/__init__.py +0 -0
- package/skill/scripts/ledgerkit/core/history_md.py +0 -152
- package/skill/scripts/ledgerkit/core/identity.py +0 -54
- package/skill/scripts/ledgerkit/core/ids.py +0 -58
- package/skill/scripts/ledgerkit/core/labels.py +0 -89
- package/skill/scripts/ledgerkit/core/paths.py +0 -92
- package/skill/scripts/ledgerkit/core/roots.py +0 -74
- package/skill/scripts/ledgerkit/core/time.py +0 -103
- package/skill/scripts/ledgerkit/core/worklog_md.py +0 -435
- package/skill/scripts/ledgerkit/env.py +0 -94
- package/skill/scripts/ledgerkit/errors.py +0 -15
- package/skill/scripts/ledgerkit/notices.py +0 -28
- package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
- package/skill/scripts/ledgerkit/shell/cli.py +0 -532
- package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
- package/skill/scripts/ledgerkit/shell/merge.py +0 -266
- package/skill/scripts/ledgerkit/shell/presync.py +0 -23
- package/skill/scripts/ledgerkit/shell/render.py +0 -216
- package/skill/scripts/ledgerkit/shell/runner.py +0 -232
- package/skill/scripts/ledgerkit/store/__init__.py +0 -0
- package/skill/scripts/ledgerkit/store/atomic.py +0 -130
- package/skill/scripts/ledgerkit/store/dirty.py +0 -108
- package/skill/scripts/ledgerkit/store/history_store.py +0 -134
- package/skill/scripts/ledgerkit/store/json_store.py +0 -166
- package/skill/scripts/ledgerkit/store/locks.py +0 -64
- package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
- package/skill/scripts/ledgerkit/store/task_store.py +0 -28
- package/skill/scripts/ledgerkit/store/worklog_store.py +0 -178
- package/skill/scripts/progress.py +0 -20
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
"""结案:历史加一行 → 封卷过程文档 → **最后**删分片那行。
|
|
2
|
-
|
|
3
|
-
`finished` 必填、由外壳算好(同一个值也用于算锁集与刷取范围)—— 业务层不许再调时钟,
|
|
4
|
-
这是跨月刷错月份的唯一根因。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from ..core.identity import Identity
|
|
12
|
-
from ..core.labels import parse_tags
|
|
13
|
-
from ..core.paths import history_path_for, shard_rel, to_repo_rel, worklog_name
|
|
14
|
-
from ..core.worklog_md import (
|
|
15
|
-
StatusLine,
|
|
16
|
-
append_exec_log,
|
|
17
|
-
exec_line,
|
|
18
|
-
has_frozen_line,
|
|
19
|
-
rewrite_label_line,
|
|
20
|
-
rewrite_status_line,
|
|
21
|
-
stamp_frozen,
|
|
22
|
-
)
|
|
23
|
-
from ..errors import Fail
|
|
24
|
-
from ..store.atomic import ensure_dir, fingerprint, restore, snapshot
|
|
25
|
-
from ..store.history_store import HIT, UNDECIDABLE, HistoryStore
|
|
26
|
-
from ..store.task_store import TaskStore
|
|
27
|
-
from ..store.worklog_store import WorklogStore
|
|
28
|
-
from .guard import require_owner
|
|
29
|
-
from .result import Result, rel
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def close_task(
|
|
33
|
-
repo: Path,
|
|
34
|
-
me: Identity,
|
|
35
|
-
tid: str,
|
|
36
|
-
*,
|
|
37
|
-
summary: str,
|
|
38
|
-
finished: str,
|
|
39
|
-
tags: tuple[str, ...] | None = None,
|
|
40
|
-
) -> Result:
|
|
41
|
-
"""写序四步:改标签行 → 历史加一行 → 封卷 → 删分片(永远是最后一步)。"""
|
|
42
|
-
if not finished:
|
|
43
|
-
raise Fail("结案必须由外壳先算好时间(内部错误:finished 为空)。")
|
|
44
|
-
text_summary = " ".join((summary or "").split())
|
|
45
|
-
if not text_summary:
|
|
46
|
-
raise Fail("结案要写一句「完成了什么」(--summary 不能为空)。")
|
|
47
|
-
new_tags = parse_tags(tags) if tags else None
|
|
48
|
-
|
|
49
|
-
logs = WorklogStore(repo)
|
|
50
|
-
history = HistoryStore(repo)
|
|
51
|
-
hist_path = repo / to_repo_rel(history_path_for(finished))
|
|
52
|
-
|
|
53
|
-
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
54
|
-
task = mine.get(tid) # 锁内重读
|
|
55
|
-
if task is None:
|
|
56
|
-
# 分片里已经没有这条:上次崩在「删分片」之后。只按过程文档名判重,命中即已结案
|
|
57
|
-
state = history.has_log(
|
|
58
|
-
hist_path, log_name=worklog_name(tid), created=None, title=None
|
|
59
|
-
)
|
|
60
|
-
if state == HIT:
|
|
61
|
-
return Result(
|
|
62
|
-
already_done=True,
|
|
63
|
-
where=rel(repo, hist_path),
|
|
64
|
-
notices=(f"任务 {tid} 已经结过案,无需重复。",),
|
|
65
|
-
)
|
|
66
|
-
raise Fail(f"任务 {tid} 不在我的分片里(只能结案自己的任务)。")
|
|
67
|
-
|
|
68
|
-
# 分片里在、但行内 owner 不是我 ⇒ 台账脏了(validate 会报⑤):先拦下来,别用我的 uuid 记历史
|
|
69
|
-
require_owner(task, tid, me)
|
|
70
|
-
|
|
71
|
-
# 分类取任务原值(**不可改**);标签给了参数用新值,没给沿用原值
|
|
72
|
-
final_category = task.get("category")
|
|
73
|
-
final_tags = tuple(new_tags) if new_tags else tuple(task.get("tags") or ())
|
|
74
|
-
|
|
75
|
-
doc_path = logs.find(tid, task.get("worklog"))
|
|
76
|
-
|
|
77
|
-
# 写前核对:指纹任一变了就中止,同时留三份字节备用
|
|
78
|
-
paths = [p for p in (mine.path, hist_path, doc_path) if p is not None]
|
|
79
|
-
stamps = {p: fingerprint(p) for p in paths}
|
|
80
|
-
backups = {p: snapshot(p) for p in paths}
|
|
81
|
-
|
|
82
|
-
try:
|
|
83
|
-
log_name = Path(doc_path).name if doc_path else None
|
|
84
|
-
state = history.has_log(
|
|
85
|
-
hist_path,
|
|
86
|
-
log_name=log_name,
|
|
87
|
-
created=task.get("created"),
|
|
88
|
-
title=task.get("title"),
|
|
89
|
-
)
|
|
90
|
-
if state == HIT:
|
|
91
|
-
# 崩在②(历史行已写)之后、③/④之前:历史里已有,但分片行可能还在、
|
|
92
|
-
# 过程文档可能还没封卷。不补齐就留下「已结案却仍在办」的僵尸 ——
|
|
93
|
-
# 它还能收 log,甚至被释放推回公共池让别人认领(2026-10-24 修)。
|
|
94
|
-
notes = [f"任务 {tid} 已经结过案,无需重复。"]
|
|
95
|
-
if doc_path is not None:
|
|
96
|
-
text = logs.read(doc_path)
|
|
97
|
-
if not has_frozen_line(text):
|
|
98
|
-
frozen = stamp_frozen(text, finished, me.stamp())
|
|
99
|
-
frozen = append_exec_log(
|
|
100
|
-
frozen, exec_line(finished, me.stamp(), "close", "结案封卷")
|
|
101
|
-
)
|
|
102
|
-
frozen = rewrite_status_line(
|
|
103
|
-
frozen, StatusLine("closed", me.name, me.uuid, finished)
|
|
104
|
-
)
|
|
105
|
-
logs.write(doc_path, frozen)
|
|
106
|
-
notes.append("补齐上次未完成的封卷。")
|
|
107
|
-
if mine.get(tid) is not None:
|
|
108
|
-
mine.drop(tid)
|
|
109
|
-
notes.append("补齐上次未完成的移出分片。")
|
|
110
|
-
return Result(
|
|
111
|
-
already_done=True,
|
|
112
|
-
where=rel(repo, hist_path),
|
|
113
|
-
notices=tuple(notes),
|
|
114
|
-
)
|
|
115
|
-
if state == UNDECIDABLE:
|
|
116
|
-
raise Fail(
|
|
117
|
-
f"历史里有一条没有过程文档链接的旧行,判不出是不是 {tid} ⇒ 已中止,"
|
|
118
|
-
f"请人工看一眼 {rel(repo, hist_path)} 再重跑。"
|
|
119
|
-
)
|
|
120
|
-
# 写前核对(2026-10-23 补):三个文件的指纹与读到时不一致 ⇒ 有人在锁外动过,
|
|
121
|
-
# 一个字节都不写(snapshot 那份留着给下面的异常分支按字节退回)。
|
|
122
|
-
for path in paths:
|
|
123
|
-
if fingerprint(path) != stamps[path]:
|
|
124
|
-
raise Fail(
|
|
125
|
-
f"{rel(repo, path)} 在本命令执行期间被改动过"
|
|
126
|
-
"(可能是编辑器或 git 操作),已中止、未写入任何内容;重跑一次即可。"
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
# ① 改标签行(给了 --tags 才做,且必须在封卷之前)
|
|
130
|
-
if doc_path is not None and new_tags:
|
|
131
|
-
text = logs.read(doc_path)
|
|
132
|
-
logs.write(doc_path, rewrite_label_line(text, final_tags))
|
|
133
|
-
|
|
134
|
-
# ② 历史加一行(先 ensure_dir:首次结案时 history/ 可能还不存在)
|
|
135
|
-
ensure_dir(hist_path.parent)
|
|
136
|
-
out = history.append_row(
|
|
137
|
-
hist_path,
|
|
138
|
-
{
|
|
139
|
-
"finished": finished,
|
|
140
|
-
"created": task.get("created"),
|
|
141
|
-
"title": task.get("title") or "",
|
|
142
|
-
"category": final_category,
|
|
143
|
-
"tags": final_tags,
|
|
144
|
-
# 历史里 uuid 与名字**都落盘**:uuid 是判定键(`@` + 裸 uuid,
|
|
145
|
-
# `--owner` / `--mine` 比它),名字是**结案那一刻的快照** —— 之后改名
|
|
146
|
-
# 不回写旧行,看历史看到的就是当时那个名字。
|
|
147
|
-
# 取值优先用任务行里认领 / 建任务时写下的那份;老任务没有就留空,不编造。
|
|
148
|
-
"owner": me.uuid,
|
|
149
|
-
"owner_name": task.get("owner_name") or me.name,
|
|
150
|
-
"creator": task.get("creator") or me.uuid,
|
|
151
|
-
"creator_name": task.get("creator_name"),
|
|
152
|
-
"summary": text_summary,
|
|
153
|
-
"worklog": task.get("worklog") if doc_path else None,
|
|
154
|
-
},
|
|
155
|
-
)
|
|
156
|
-
history.write(hist_path, out["text"])
|
|
157
|
-
|
|
158
|
-
# ③ 封卷(没有过程文档就跳过)
|
|
159
|
-
if doc_path is not None:
|
|
160
|
-
text = logs.read(doc_path)
|
|
161
|
-
if not has_frozen_line(text):
|
|
162
|
-
frozen = stamp_frozen(text, finished, me.stamp())
|
|
163
|
-
frozen = append_exec_log(
|
|
164
|
-
frozen, exec_line(finished, me.stamp(), "close", "结案封卷")
|
|
165
|
-
)
|
|
166
|
-
frozen = rewrite_status_line(
|
|
167
|
-
frozen, StatusLine("closed", me.name, me.uuid, finished)
|
|
168
|
-
)
|
|
169
|
-
logs.write(doc_path, frozen)
|
|
170
|
-
|
|
171
|
-
# ④ 删我分片那行(永远最后)
|
|
172
|
-
mine.drop(tid)
|
|
173
|
-
except Exception:
|
|
174
|
-
for path in paths:
|
|
175
|
-
if path in backups:
|
|
176
|
-
restore(path, backups[path])
|
|
177
|
-
raise
|
|
178
|
-
|
|
179
|
-
notices: list[str] = []
|
|
180
|
-
if doc_path is None:
|
|
181
|
-
notices.append(f"提示:任务 {tid} 没有过程文档,已跳过封卷,历史末列留空。")
|
|
182
|
-
return Result(
|
|
183
|
-
changed=("status", "finished"),
|
|
184
|
-
where=rel(repo, hist_path),
|
|
185
|
-
message=f"结案 {tid}:{task.get('title') or ''}".strip(),
|
|
186
|
-
payload={
|
|
187
|
-
"history_path": rel(repo, hist_path),
|
|
188
|
-
"worklog_path": rel(repo, doc_path) if doc_path else None,
|
|
189
|
-
},
|
|
190
|
-
notices=tuple(notices),
|
|
191
|
-
)
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
"""业务校验的共用件:任务号形态、任务存不存在、「这条是不是你的」。
|
|
2
|
-
|
|
3
|
-
**`owner` / `status` 的读取只走这里的四个谓词**:`store/` 里不许出现 `get("owner")`,
|
|
4
|
-
`actions/` 里除本文件外也不许裸写字段名比较。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from collections.abc import Mapping
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
from ..core.identity import Identity, is_me, looks_like_uuid, normalize
|
|
14
|
-
from ..core.paths import POOL_REL
|
|
15
|
-
from ..errors import Fail
|
|
16
|
-
from ..store.owners_index import OwnersIndex
|
|
17
|
-
from ..store.task_store import TaskStore
|
|
18
|
-
from .query import ListQuery
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def current_identity(repo: Path) -> Identity:
|
|
22
|
-
"""env 的本机身份(不写盘、不登记)。全站只认 uuid 这一个身份键。"""
|
|
23
|
-
from .. import env
|
|
24
|
-
|
|
25
|
-
return env.current(repo)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def require_uuid(value: str, *, field: str) -> str:
|
|
29
|
-
"""身份参数只收 UUID:名字 / 邮箱一律当场报错,绝不悄悄比不到东西。
|
|
30
|
-
|
|
31
|
-
名字只能用于查询筛选(`--owner-name`),这一点要在报错里说清楚,
|
|
32
|
-
否则用户会以为「名字也行,只是没查到」。
|
|
33
|
-
"""
|
|
34
|
-
text = normalize(value)
|
|
35
|
-
if not looks_like_uuid(text):
|
|
36
|
-
raise Fail(
|
|
37
|
-
f"{field} 只收身份 uuid(形如 00000000-0000-4000-8000-000000000000),"
|
|
38
|
-
f"不接受名字 / 邮箱:{value.strip()}\n"
|
|
39
|
-
" 按显示名模糊筛请用 --owner-name <片段>;"
|
|
40
|
-
"不知道对方 uuid 就先跑 list --json 看,或让本人跑 list --mine。"
|
|
41
|
-
)
|
|
42
|
-
return text
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def require_task(tasks: Mapping[str, Any], tid: str, *, where: Path) -> dict[str, Any]:
|
|
46
|
-
"""取一条任务;缺失时给可读原因(带所在文件)。"""
|
|
47
|
-
task = tasks.get(tid)
|
|
48
|
-
if task is None:
|
|
49
|
-
raise Fail(f"任务 {tid} 不在 {_show(where)} 里。")
|
|
50
|
-
if not isinstance(task, dict):
|
|
51
|
-
raise Fail(f"任务 {tid} 在 {_show(where)} 里不是一条对象,台账可能坏了。")
|
|
52
|
-
return dict(task)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def require_owner(task: Mapping[str, Any], tid: str, me: Identity) -> None:
|
|
56
|
-
"""只有当前 owner 能改。"""
|
|
57
|
-
owner = owner_of(task)
|
|
58
|
-
if not owner:
|
|
59
|
-
raise Fail(f"任务 {tid} 还没有人认领(在公共池里),先 claim 再改。")
|
|
60
|
-
if not is_me(me, owner):
|
|
61
|
-
raise Fail(f"任务 {tid} 的负责人是 {owner},不是你({me.label()})。")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def same_task_ignoring_stamp(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
|
|
65
|
-
"""两份内容是不是只差 `updated`(交接重跑时以对方那份为准的判据)。"""
|
|
66
|
-
left = {k: v for k, v in a.items() if k not in ("updated", "updated_by")}
|
|
67
|
-
right = {k: v for k, v in b.items() if k not in ("updated", "updated_by")}
|
|
68
|
-
return left == right
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def owner_of(task: Mapping[str, Any]) -> str | None:
|
|
72
|
-
"""读 `owner` 的唯一入口。"""
|
|
73
|
-
value = task.get("owner")
|
|
74
|
-
return normalize(value) if isinstance(value, str) and value.strip() else None
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def status_of(task: Mapping[str, Any]) -> str:
|
|
78
|
-
"""读 `status` 的唯一入口。"""
|
|
79
|
-
value = task.get("status")
|
|
80
|
-
if not isinstance(value, str) or not value.strip():
|
|
81
|
-
return "open"
|
|
82
|
-
return value.strip().lower()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def is_mine(task: Mapping[str, Any], me: Identity) -> bool:
|
|
86
|
-
"""这条任务是不是我的(只比 uuid)。"""
|
|
87
|
-
return is_me(me, owner_of(task) or "")
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def in_scope(
|
|
91
|
-
task: Mapping[str, Any],
|
|
92
|
-
q: ListQuery,
|
|
93
|
-
*,
|
|
94
|
-
me: Identity,
|
|
95
|
-
name_keys: set[str] | frozenset[str] = frozenset(),
|
|
96
|
-
) -> bool:
|
|
97
|
-
"""`list` 的过滤谓词:范围、状态、分类、标签、创建时间闭区间。
|
|
98
|
-
|
|
99
|
-
`name_keys` 是 `--owner-name` 先在名册里模糊匹配出来的 uuid 集合(由调用方查好传进来):
|
|
100
|
-
**名字只用来缩小 uuid 集合,判定本身仍按 uuid 比。**
|
|
101
|
-
"""
|
|
102
|
-
if q.scope == "mine" and not is_mine(task, me):
|
|
103
|
-
return False
|
|
104
|
-
if q.scope == "pool" and owner_of(task):
|
|
105
|
-
return False
|
|
106
|
-
if q.scope == "owner" and q.owner and normalize(q.owner) != owner_of(task):
|
|
107
|
-
return False
|
|
108
|
-
if q.owner_name and owner_of(task) not in name_keys:
|
|
109
|
-
return False
|
|
110
|
-
if q.statuses and status_of(task) not in set(q.statuses):
|
|
111
|
-
return False
|
|
112
|
-
if q.category and (task.get("category") or "") != q.category:
|
|
113
|
-
return False
|
|
114
|
-
if q.tag and q.tag not in (task.get("tags") or ()):
|
|
115
|
-
return False
|
|
116
|
-
created = task.get("created")
|
|
117
|
-
if q.since and isinstance(created, str) and created[:19] < q.since[:19]:
|
|
118
|
-
return False
|
|
119
|
-
return not (q.until and isinstance(created, str) and created[:19] > q.until[:19])
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _show(path: Path) -> str:
|
|
123
|
-
try:
|
|
124
|
-
return str(Path(path).name)
|
|
125
|
-
except Exception: # noqa: BLE001
|
|
126
|
-
return str(path)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def load_task(repo: Path, tid: str) -> tuple[dict[str, Any], list[Path]]:
|
|
130
|
-
"""在池 + 索引登记的全部分片里找这条任务(只读),返回 (任务, 出现过的文件)。"""
|
|
131
|
-
index = OwnersIndex(repo)
|
|
132
|
-
index.assert_present()
|
|
133
|
-
found: dict[str, Any] | None = None
|
|
134
|
-
where: list[Path] = []
|
|
135
|
-
pool = TaskStore(repo, POOL_REL)
|
|
136
|
-
if pool.get(tid):
|
|
137
|
-
found = pool.get(tid)
|
|
138
|
-
where.append(pool.path)
|
|
139
|
-
for rel in _shard_rels(repo, index):
|
|
140
|
-
store = TaskStore(repo, rel)
|
|
141
|
-
task = store.get(tid)
|
|
142
|
-
if task:
|
|
143
|
-
where.append(store.path)
|
|
144
|
-
if found is None:
|
|
145
|
-
found = task
|
|
146
|
-
if found is None:
|
|
147
|
-
raise Fail(f"找不到任务 {tid}(池与已登记的分片里都没有)。")
|
|
148
|
-
return found, where
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def _shard_rels(repo: Path, index: OwnersIndex) -> list[Path]:
|
|
152
|
-
out: list[Path] = []
|
|
153
|
-
for path in index.shards().values():
|
|
154
|
-
try:
|
|
155
|
-
out.append(path.relative_to(repo / "ledger"))
|
|
156
|
-
except ValueError: # pragma: no cover
|
|
157
|
-
continue
|
|
158
|
-
return out
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"""交接:先写进对方分片,再删我分片那行,再记一笔。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from ..core.identity import Identity, normalize
|
|
8
|
-
from ..core.paths import shard_rel
|
|
9
|
-
from ..core.time import now
|
|
10
|
-
from ..core.worklog_md import StatusLine, exec_line
|
|
11
|
-
from ..errors import Fail
|
|
12
|
-
from ..store.owners_index import OwnersIndex
|
|
13
|
-
from ..store.task_store import TaskStore
|
|
14
|
-
from ..store.worklog_store import WorklogStore
|
|
15
|
-
from .guard import owner_of, same_task_ignoring_stamp
|
|
16
|
-
from .result import Result, rel
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
|
|
20
|
-
"""写序:对方分片 → 删我分片那行 → 过程文档记一笔。"""
|
|
21
|
-
index = OwnersIndex(repo)
|
|
22
|
-
record = index.get(to)
|
|
23
|
-
if record is None:
|
|
24
|
-
raise Fail(
|
|
25
|
-
f"{to} 还没在索引里登记过 ⇒ 不能交接给他(写出一份对别人不存在的分片,他自己也看不到)。"
|
|
26
|
-
)
|
|
27
|
-
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
28
|
-
task = mine.get(tid) # 锁内重读
|
|
29
|
-
if task is None:
|
|
30
|
-
raise Fail(f"任务 {tid} 不在我的分片里,交接不了。")
|
|
31
|
-
|
|
32
|
-
to_key = normalize(to)
|
|
33
|
-
# 行内 owner 只能是我(正常)或已经是对方(上次交接崩在半途,这次要补齐):
|
|
34
|
-
# 别的一律拒绝——分片里有、owner 却写着第三个人 ⇒ 台账脏了
|
|
35
|
-
owner = owner_of(task)
|
|
36
|
-
if owner not in (normalize(me.uuid), to_key):
|
|
37
|
-
raise Fail(
|
|
38
|
-
f"任务 {tid} 的负责人是 {owner},不是你({me.label()}),交接不了。\n"
|
|
39
|
-
" 处理:跑 validate 看台账是不是脏了。"
|
|
40
|
-
)
|
|
41
|
-
to_name = str(record.get("name") or "") or None # 只用于显示
|
|
42
|
-
who = to_name or to_key # 给人看:命令输出、提示语
|
|
43
|
-
# 落盘用「名字 · uuid」:接收人拿什么键去分片里找,比执行者更需要 uuid
|
|
44
|
-
who_stamp = Identity(uuid=to_key, name=to_name or "").stamp()
|
|
45
|
-
to_store = TaskStore(repo, shard_rel(to_key))
|
|
46
|
-
moved = dict(task)
|
|
47
|
-
moved["owner"] = to_key
|
|
48
|
-
moved["owner_name"] = to_name
|
|
49
|
-
moved["updated"] = now()
|
|
50
|
-
|
|
51
|
-
existing = to_store.get(tid)
|
|
52
|
-
if existing is not None:
|
|
53
|
-
if not same_task_ignoring_stamp(existing, moved):
|
|
54
|
-
raise Fail(
|
|
55
|
-
f"任务 {tid} 在 {to_key} 的分片里已经存在,且内容与本次不一致 ⇒ 拒绝交接。"
|
|
56
|
-
)
|
|
57
|
-
# 重跑:以对方那份为准,只补删我这边
|
|
58
|
-
mine.drop(tid)
|
|
59
|
-
logs = WorklogStore(repo)
|
|
60
|
-
path = logs.find(tid, moved.get("worklog"))
|
|
61
|
-
if path is not None:
|
|
62
|
-
logs.append_exec(
|
|
63
|
-
path,
|
|
64
|
-
exec_line(now(), me.stamp(), "set", f"交接给 @{who_stamp}(补齐)"),
|
|
65
|
-
status=StatusLine("doing", to_name, to_key, now()),
|
|
66
|
-
)
|
|
67
|
-
return Result(
|
|
68
|
-
already_done=True,
|
|
69
|
-
where=rel(repo, to_store.path),
|
|
70
|
-
message=f"交接 {tid} → {who}:补齐上次未完成的清理",
|
|
71
|
-
notices=(f"任务 {tid} 已在对方分片里(内容一致),本次只补删我分片那行。",),
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
to_store.put(tid, moved)
|
|
75
|
-
mine.drop(tid)
|
|
76
|
-
|
|
77
|
-
logs = WorklogStore(repo)
|
|
78
|
-
path = logs.find(tid, moved.get("worklog"))
|
|
79
|
-
note = None
|
|
80
|
-
if path is None:
|
|
81
|
-
note = f"任务 {tid} 已交给 {who},但过程文档没找到,跳过执行记录。"
|
|
82
|
-
else:
|
|
83
|
-
logs.append_exec(
|
|
84
|
-
path,
|
|
85
|
-
exec_line(now(), me.stamp(), "set", f"交接给 @{who_stamp}"),
|
|
86
|
-
status=StatusLine("doing", to_name, to_key, now()),
|
|
87
|
-
)
|
|
88
|
-
return Result(
|
|
89
|
-
changed=("owner", "owner_name", "updated"),
|
|
90
|
-
where=rel(repo, to_store.path),
|
|
91
|
-
message=f"交接 {tid} → {who}",
|
|
92
|
-
notices=(note,) if note else (),
|
|
93
|
-
)
|
|
@@ -1,90 +0,0 @@
|
|
|
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)}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"""只读:查在办任务(池 + 分片,后读到的为准)。不拿锁、不跑自举、不做取最新。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import replace
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from ..core.identity import Identity
|
|
10
|
-
from ..core.labels import STATUSES
|
|
11
|
-
from ..core.paths import POOL_REL
|
|
12
|
-
from ..errors import Fail
|
|
13
|
-
from ..store.owners_index import OwnersIndex
|
|
14
|
-
from ..store.task_store import TaskStore
|
|
15
|
-
from .guard import in_scope, is_mine, owner_of, require_uuid, status_of
|
|
16
|
-
from .query import ListQuery
|
|
17
|
-
from .result import ListResult, TaskRow, rel
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
|
|
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
|
-
)
|
|
34
|
-
rows: dict[str, dict[str, Any]] = {}
|
|
35
|
-
where: dict[str, Path] = {}
|
|
36
|
-
|
|
37
|
-
pool = TaskStore(repo, POOL_REL)
|
|
38
|
-
for tid, task in pool.all().items():
|
|
39
|
-
rows[tid] = task
|
|
40
|
-
where[tid] = pool.path
|
|
41
|
-
|
|
42
|
-
index = OwnersIndex(repo)
|
|
43
|
-
index.assert_present()
|
|
44
|
-
for key in sorted(index.shards()):
|
|
45
|
-
path = index.shards()[key]
|
|
46
|
-
store = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
|
|
47
|
-
for tid, task in store.all().items():
|
|
48
|
-
rows[tid] = task
|
|
49
|
-
where[tid] = store.path
|
|
50
|
-
|
|
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
|
-
}
|
|
55
|
-
# 每行自带 `mine`(只比 uuid,零额外 IO):调用方不必自己拿 uuid 再比一次
|
|
56
|
-
for task in kept.values():
|
|
57
|
-
task["mine"] = is_mine(task, me)
|
|
58
|
-
table = tuple(
|
|
59
|
-
TaskRow(
|
|
60
|
-
tid=tid,
|
|
61
|
-
title=str(task.get("title") or ""),
|
|
62
|
-
status=status_of(task),
|
|
63
|
-
owner=task.get("owner_name") or owner_of(task),
|
|
64
|
-
created=task.get("created"),
|
|
65
|
-
started=task.get("started"),
|
|
66
|
-
category=task.get("category"),
|
|
67
|
-
tags=tuple(task.get("tags") or ()),
|
|
68
|
-
where=rel(repo, where[tid]),
|
|
69
|
-
)
|
|
70
|
-
for tid, task in sorted(
|
|
71
|
-
kept.items(), key=lambda kv: str(kv[1].get("created") or "")
|
|
72
|
-
)
|
|
73
|
-
)
|
|
74
|
-
return ListResult(rows=kept, table=table)
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""记进展:刷分片的「最后更新」,再往过程文档追加一行。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from ..core.identity import Identity
|
|
8
|
-
from ..core.paths import shard_rel
|
|
9
|
-
from ..core.time import now
|
|
10
|
-
from ..core.worklog_md import StatusLine, exec_line
|
|
11
|
-
from ..errors import Fail
|
|
12
|
-
from ..store.task_store import TaskStore
|
|
13
|
-
from ..store.worklog_store import WorklogStore
|
|
14
|
-
from .guard import require_owner
|
|
15
|
-
from .result import Result, rel
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
|
|
19
|
-
"""写序:刷 `updated` → 过程文档追加一行。
|
|
20
|
-
|
|
21
|
-
文档写不进(`append_exec` 回 False)⇒ **不算失败**:分片已经刷了就照常提交,
|
|
22
|
-
只留一条警告 —— 抛错让外壳回滚会变成「改了又不提交」,下一条命令被写前 guard 拦死。
|
|
23
|
-
"""
|
|
24
|
-
text = " ".join((note or "").split())
|
|
25
|
-
if not text:
|
|
26
|
-
raise Fail("--note 不能为空。")
|
|
27
|
-
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
28
|
-
task = mine.get(tid) # 锁内重读
|
|
29
|
-
if task is None:
|
|
30
|
-
raise Fail(f"任务 {tid} 不在我的分片里(只能给自己的任务记进展)。")
|
|
31
|
-
# 分片里有、行内 owner 却不是我 ⇒ 台账脏了(validate ⑤),先拦下来
|
|
32
|
-
require_owner(task, tid, me)
|
|
33
|
-
|
|
34
|
-
updated = dict(task)
|
|
35
|
-
updated["updated"] = now()
|
|
36
|
-
mine.put(tid, updated)
|
|
37
|
-
|
|
38
|
-
logs = WorklogStore(repo)
|
|
39
|
-
path = logs.find(tid, updated.get("worklog"))
|
|
40
|
-
if path is None:
|
|
41
|
-
return Result(
|
|
42
|
-
changed=("updated",),
|
|
43
|
-
where=rel(repo, mine.path),
|
|
44
|
-
message=f"{tid}:记进展",
|
|
45
|
-
notices=(
|
|
46
|
-
"提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
|
|
47
|
-
),
|
|
48
|
-
)
|
|
49
|
-
ok = logs.append_exec(
|
|
50
|
-
path,
|
|
51
|
-
exec_line(now(), me.stamp(), "log", text),
|
|
52
|
-
status=StatusLine("doing", me.name, me.uuid, now()),
|
|
53
|
-
)
|
|
54
|
-
if not ok:
|
|
55
|
-
return Result(
|
|
56
|
-
changed=("updated",),
|
|
57
|
-
where=rel(repo, mine.path),
|
|
58
|
-
message=f"{tid}:记进展",
|
|
59
|
-
notices=(
|
|
60
|
-
"警告:分片的最后更新已刷,过程文档补不上(文件不可写);已写的分片照常提交,文档那行要手动补。",
|
|
61
|
-
),
|
|
62
|
-
)
|
|
63
|
-
return Result(
|
|
64
|
-
changed=("updated",),
|
|
65
|
-
where=rel(repo, mine.path),
|
|
66
|
-
message=f"{tid}:记进展",
|
|
67
|
-
)
|