dsh-task-ledger 0.0.78 → 0.0.80
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 +29 -14
- package/dist/client.js.map +1 -1
- package/dist/host/board.d.ts +2 -2
- package/dist/host/board.js +2 -2
- package/dist/host/repositories/cache.js +18 -2
- package/dist/host/repositories/identity.repository.d.ts +3 -8
- package/dist/host/repositories/identity.repository.js +32 -23
- package/dist/host/repositories/ledger.repository.d.ts +7 -3
- package/dist/host/repositories/ledger.repository.js +23 -14
- package/dist/host/repositories/records.js +20 -1
- package/dist/host/repositories/runner.d.ts +2 -2
- package/dist/host/repositories/runner.js +1 -1
- package/dist/host/routes.js +40 -11
- package/dist/host/services/home.service.d.ts +2 -2
- package/dist/host/services/home.service.js +7 -4
- package/dist/host/services/ledger-write.service.d.ts +4 -4
- package/dist/host/services/ledger-write.service.js +7 -7
- package/dist/host/services/trend.service.d.ts +2 -2
- package/dist/host/services/trend.service.js +9 -10
- package/dist/host/services/uuid.d.ts +11 -0
- package/dist/host/services/uuid.js +15 -0
- package/dist/host/types.d.ts +21 -5
- package/package.json +1 -1
- package/skill/README.md +1 -1
- package/skill/SKILL.md +30 -17
- package/skill/package.json +1 -1
- package/skill/references/state-model.md +12 -10
- package/skill/references/troubleshooting.md +19 -7
- package/skill/scripts/ledgerkit/actions/atom.py +2 -2
- package/skill/scripts/ledgerkit/actions/bootstrap.py +9 -2
- package/skill/scripts/ledgerkit/actions/claim.py +7 -6
- package/skill/scripts/ledgerkit/actions/close_task.py +17 -12
- package/skill/scripts/ledgerkit/actions/guard.py +38 -16
- package/skill/scripts/ledgerkit/actions/handoff.py +24 -8
- package/skill/scripts/ledgerkit/actions/init_identity.py +90 -0
- package/skill/scripts/ledgerkit/actions/list_tasks.py +21 -3
- package/skill/scripts/ledgerkit/actions/log_progress.py +5 -2
- package/skill/scripts/ledgerkit/actions/new_task.py +11 -7
- package/skill/scripts/ledgerkit/actions/query.py +4 -2
- package/skill/scripts/ledgerkit/actions/query_history.py +55 -9
- package/skill/scripts/ledgerkit/actions/registry.py +1 -1
- package/skill/scripts/ledgerkit/actions/release.py +25 -4
- package/skill/scripts/ledgerkit/actions/set_task.py +18 -7
- package/skill/scripts/ledgerkit/actions/validate.py +0 -4
- package/skill/scripts/ledgerkit/core/history_md.py +47 -250
- package/skill/scripts/ledgerkit/core/identity.py +34 -7
- package/skill/scripts/ledgerkit/core/labels.py +0 -1
- package/skill/scripts/ledgerkit/core/paths.py +0 -1
- package/skill/scripts/ledgerkit/core/worklog_md.py +23 -3
- package/skill/scripts/ledgerkit/env.py +70 -71
- package/skill/scripts/ledgerkit/shell/cli.py +60 -3
- package/skill/scripts/ledgerkit/shell/gitops.py +14 -1
- package/skill/scripts/ledgerkit/shell/render.py +3 -2
- package/skill/scripts/ledgerkit/shell/runner.py +1 -1
- package/skill/scripts/ledgerkit/store/history_store.py +26 -81
- package/skill/scripts/ledgerkit/store/owners_index.py +16 -3
- package/skill/scripts/ledgerkit/store/task_store.py +2 -2
|
@@ -1,93 +1,92 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""确定「我是谁」的唯一地方:只读机器级身份文件,不读 git、不读环境变量。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
兜底身份这件事本身只 `notices.note("提示:…")`,不打印。
|
|
3
|
+
身份文件:`$TASK_LEDGER_HOME/.taskledger/identity.json`(默认 `~/.taskledger/identity.json`),
|
|
4
|
+
内容是 `{"uuid": "...", "name": "..."}`。没有就报错,由 `init` 命令创建。
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
9
8
|
|
|
10
|
-
import
|
|
9
|
+
import json
|
|
11
10
|
import os
|
|
12
|
-
import re
|
|
13
|
-
import socket
|
|
14
|
-
import subprocess
|
|
15
11
|
from pathlib import Path
|
|
16
12
|
|
|
17
|
-
from . import
|
|
18
|
-
from .
|
|
13
|
+
from .core.identity import Identity, looks_like_uuid, normalize
|
|
14
|
+
from .errors import Fail
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
IDENTITY_HOME_ENV = "TASK_LEDGER_HOME"
|
|
21
17
|
|
|
22
18
|
|
|
23
|
-
def
|
|
24
|
-
|
|
19
|
+
def identity_file() -> Path:
|
|
20
|
+
"""机器级身份文件路径;`TASK_LEDGER_HOME` 可覆写主目录(容器/测试用)。"""
|
|
21
|
+
base = os.environ.get(IDENTITY_HOME_ENV)
|
|
22
|
+
if base:
|
|
23
|
+
return Path(base) / ".taskledger" / "identity.json"
|
|
24
|
+
try:
|
|
25
|
+
home = Path.home()
|
|
26
|
+
except RuntimeError as exc:
|
|
27
|
+
raise Fail(
|
|
28
|
+
"找不到用户主目录(HOME)。\n"
|
|
29
|
+
" 处理:设置环境变量 TASK_LEDGER_HOME 指向一个可写目录,再重试。"
|
|
30
|
+
) from exc
|
|
31
|
+
return home / ".taskledger" / "identity.json"
|
|
25
32
|
|
|
26
33
|
|
|
27
|
-
def
|
|
28
|
-
"""
|
|
34
|
+
def read_identity() -> Identity | None:
|
|
35
|
+
"""读机器身份;文件不存在、空文件、坏 JSON、没 uuid 一律返回 None。
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
uuid 先归一化(去空白 + 小写,与 `--owner` 入参同一口径)。**形态不对直接报错**:
|
|
38
|
+
身份键只能是 uuid,手改进去一个邮箱 / 名字会让每条命令都写错 owner,
|
|
39
|
+
与其静默用下去,不如当场停下让人改文件。
|
|
32
40
|
"""
|
|
33
|
-
|
|
41
|
+
path = identity_file()
|
|
34
42
|
try:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
except FileNotFoundError:
|
|
43
|
+
raw = path.read_text(encoding="utf-8")
|
|
44
|
+
except (OSError, ValueError):
|
|
39
45
|
return None
|
|
40
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
41
|
-
return proc.stdout.strip()
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def git_email(repo: Path | None) -> str | None:
|
|
46
|
-
"""GIT_COMMITTER_IDENT 优先,退回 user.email。"""
|
|
47
|
-
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
48
|
-
if ident:
|
|
49
|
-
m = _IDENT_RE.search(ident)
|
|
50
|
-
if m and m.group(1).strip():
|
|
51
|
-
return m.group(1).strip()
|
|
52
|
-
return git_value(repo, "config", "user.email")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def git_name(repo: Path | None) -> str | None:
|
|
56
|
-
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
57
|
-
if ident:
|
|
58
|
-
head = ident.split("<", 1)[0].strip()
|
|
59
|
-
if head:
|
|
60
|
-
return head
|
|
61
|
-
return git_value(repo, "config", "user.name")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def fallback_identity() -> str:
|
|
65
|
-
"""不在 git 仓库里时的兜底链:系统用户名 → getpass → 主机名 → 字面 default。"""
|
|
66
|
-
for key in ("USER", "LOGNAME"):
|
|
67
|
-
value = os.environ.get(key, "").strip()
|
|
68
|
-
if value:
|
|
69
|
-
return value
|
|
70
|
-
try:
|
|
71
|
-
name = getpass.getuser().strip()
|
|
72
|
-
except Exception: # noqa: BLE001 —— 拿不到用户名是正常分支,不该炸
|
|
73
|
-
name = ""
|
|
74
|
-
if name:
|
|
75
|
-
return name
|
|
76
46
|
try:
|
|
77
|
-
|
|
78
|
-
except
|
|
79
|
-
|
|
80
|
-
|
|
47
|
+
data = json.loads(raw)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
uuid = normalize(str((data or {}).get("uuid") or ""))
|
|
51
|
+
if not uuid:
|
|
52
|
+
return None
|
|
53
|
+
if not looks_like_uuid(uuid):
|
|
54
|
+
raise Fail(
|
|
55
|
+
f"身份文件里的 uuid 不合法:{path}\n"
|
|
56
|
+
f" 读到:{uuid}(身份键只能是 uuid,不能是邮箱 / 名字)\n"
|
|
57
|
+
" 处理:把该文件的 uuid 改成 uuid(或删掉这个文件重跑 init)。"
|
|
58
|
+
)
|
|
59
|
+
name = str((data or {}).get("name") or "").strip()
|
|
60
|
+
return Identity(uuid=uuid, name=name)
|
|
81
61
|
|
|
82
62
|
|
|
83
63
|
def current(repo: Path | None = None) -> Identity:
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
"""当前操作者;没初始化就报错,提示去 init。
|
|
65
|
+
|
|
66
|
+
name 的取值规则:工作区 owners.json 里同 uuid 的 name 优先,没有就用总体的 name。
|
|
67
|
+
"""
|
|
68
|
+
identity = read_identity()
|
|
69
|
+
if identity is None:
|
|
70
|
+
raise Fail(
|
|
71
|
+
"这台机器还没有初始化身份。\n"
|
|
72
|
+
" 处理:先运行 `progress.py init --repo <工作区根>`,输入一个名字,再执行本命令。"
|
|
91
73
|
)
|
|
92
|
-
name =
|
|
93
|
-
|
|
74
|
+
name = identity.name
|
|
75
|
+
if repo is not None:
|
|
76
|
+
name = _workspace_name(repo, identity.uuid) or name
|
|
77
|
+
return Identity(uuid=identity.uuid, name=name)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _workspace_name(repo: Path, uuid: str) -> str:
|
|
81
|
+
"""当前工作区 owners.json 里这个 uuid 的 name;没有/读不到返回空串。"""
|
|
82
|
+
from .core.paths import OWNERS_REL, to_repo_rel
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
raw = (repo / to_repo_rel(OWNERS_REL)).read_text(encoding="utf-8")
|
|
86
|
+
workers = (json.loads(raw) or {}).get("workers", {}) or {}
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return ""
|
|
89
|
+
record = workers.get(uuid) or workers.get(uuid.lower())
|
|
90
|
+
if not isinstance(record, dict):
|
|
91
|
+
return ""
|
|
92
|
+
return str(record.get("name") or "").strip()
|
|
@@ -47,7 +47,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
47
47
|
|
|
48
48
|
setp = sub.add_parser("set", help="释放 / 交接任务")
|
|
49
49
|
setp.add_argument("--id", required=True, dest="tid")
|
|
50
|
-
setp.add_argument("--owner")
|
|
50
|
+
setp.add_argument("--owner", help="交给别人的 uuid(交接)")
|
|
51
|
+
setp.add_argument("--release", action="store_true", help="释放回公共池")
|
|
51
52
|
|
|
52
53
|
logp = sub.add_parser("log", help="记一条进展")
|
|
53
54
|
logp.add_argument("--id", required=True, dest="tid")
|
|
@@ -63,7 +64,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
63
64
|
listp = sub.add_parser("list", help="查在办任务")
|
|
64
65
|
listp.add_argument("--pool", action="store_true")
|
|
65
66
|
listp.add_argument("--mine", action="store_true")
|
|
66
|
-
listp.add_argument(
|
|
67
|
+
listp.add_argument(
|
|
68
|
+
"--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
|
|
69
|
+
)
|
|
70
|
+
listp.add_argument(
|
|
71
|
+
"--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
|
|
72
|
+
)
|
|
67
73
|
listp.add_argument("--status")
|
|
68
74
|
listp.add_argument("--category")
|
|
69
75
|
listp.add_argument("--tag")
|
|
@@ -76,7 +82,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
76
82
|
showp.add_argument("--json", action="store_true")
|
|
77
83
|
|
|
78
84
|
hist = sub.add_parser("history", help="查已结案")
|
|
79
|
-
hist.add_argument(
|
|
85
|
+
hist.add_argument(
|
|
86
|
+
"--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
|
|
87
|
+
)
|
|
88
|
+
hist.add_argument(
|
|
89
|
+
"--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
|
|
90
|
+
)
|
|
80
91
|
hist.add_argument("--mine", action="store_true")
|
|
81
92
|
hist.add_argument("--category")
|
|
82
93
|
hist.add_argument("--tag")
|
|
@@ -93,6 +104,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
93
104
|
valp.add_argument("--deep", action="store_true")
|
|
94
105
|
valp.add_argument("--json", action="store_true")
|
|
95
106
|
|
|
107
|
+
initp = sub.add_parser("init", help="初始化本机身份(一台机器只一次)")
|
|
108
|
+
initp.add_argument("--claim", help="认领名册里已有的 uuid")
|
|
109
|
+
initp.add_argument("--new", action="store_true", dest="new_id", help="新建身份")
|
|
110
|
+
initp.add_argument("--name", help="新建身份时的显示名字(必填)")
|
|
111
|
+
|
|
96
112
|
# 宿主(插件 / SKILL.md)的调用写法是 `progress.py list --repo <仓库>`:
|
|
97
113
|
# `--repo` 挂在**子命令后面**,所以每个子命令都要认;子命令给的值覆盖顶层那个。
|
|
98
114
|
for parser_ in sub.choices.values():
|
|
@@ -119,6 +135,7 @@ def to_query(args: argparse.Namespace) -> None:
|
|
|
119
135
|
args.q = ListQuery(
|
|
120
136
|
scope=scope,
|
|
121
137
|
owner=getattr(args, "owner", None),
|
|
138
|
+
owner_name=getattr(args, "owner_name", None),
|
|
122
139
|
statuses=tuple(
|
|
123
140
|
s.strip()
|
|
124
141
|
for s in (getattr(args, "status", "") or "").split(",")
|
|
@@ -134,6 +151,7 @@ def to_query(args: argparse.Namespace) -> None:
|
|
|
134
151
|
elif command == "history":
|
|
135
152
|
args.q = HistoryQuery(
|
|
136
153
|
owner=getattr(args, "owner", None),
|
|
154
|
+
owner_name=getattr(args, "owner_name", None),
|
|
137
155
|
mine=bool(getattr(args, "mine", False)),
|
|
138
156
|
category=getattr(args, "category", None),
|
|
139
157
|
tag=getattr(args, "tag", None),
|
|
@@ -206,6 +224,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
206
224
|
to_query(args)
|
|
207
225
|
|
|
208
226
|
command = command_of(args)
|
|
227
|
+
if command == "init":
|
|
228
|
+
return _run_init(args)
|
|
209
229
|
try:
|
|
210
230
|
# 定位只做一次:解析好的工作区根挂回 args,runner 与整命令锁都拿它
|
|
211
231
|
args.repo = str(resolve_repo(getattr(args, "repo", None)))
|
|
@@ -249,6 +269,43 @@ def _lock_dir(repo: Path) -> Path:
|
|
|
249
269
|
return lock_dir
|
|
250
270
|
|
|
251
271
|
|
|
272
|
+
def _run_init(args: argparse.Namespace) -> int:
|
|
273
|
+
"""init 不进 runner、不解析台账、不碰 git:只写机器身份文件。"""
|
|
274
|
+
from ..actions.init_identity import init_identity
|
|
275
|
+
|
|
276
|
+
repo: Path | None = None
|
|
277
|
+
raw = getattr(args, "repo", None)
|
|
278
|
+
try:
|
|
279
|
+
repo = as_root(raw, source="--repo") if raw else root_of(Path.cwd().resolve())
|
|
280
|
+
except Fail:
|
|
281
|
+
repo = None
|
|
282
|
+
try:
|
|
283
|
+
res = init_identity(
|
|
284
|
+
repo,
|
|
285
|
+
claim=getattr(args, "claim", None),
|
|
286
|
+
new=bool(getattr(args, "new_id", False)),
|
|
287
|
+
name=getattr(args, "name", "") or "",
|
|
288
|
+
)
|
|
289
|
+
except Fail as exc:
|
|
290
|
+
render.fail_message(exc)
|
|
291
|
+
return BUSINESS
|
|
292
|
+
if res.get("need_choose"):
|
|
293
|
+
print("这台机器还没初始化。当前仓库里已有的用户:")
|
|
294
|
+
for opt in res.get("options", []):
|
|
295
|
+
print(f" {opt['uuid']} {opt['name']}")
|
|
296
|
+
print("请重跑:init --claim=<uuid> 认领,或 init --new --name=<名字> 新建")
|
|
297
|
+
return 0
|
|
298
|
+
if res.get("already"):
|
|
299
|
+
print(
|
|
300
|
+
f"已经初始化过,未做改动:{res['name'] or res['uuid'][:8]}({res['uuid']})"
|
|
301
|
+
)
|
|
302
|
+
print(f"文件:{res['path']}")
|
|
303
|
+
return 0
|
|
304
|
+
print(f"已初始化:{res['name'] or res['uuid'][:8]}({res['uuid']})")
|
|
305
|
+
print(f"文件:{res['path']}")
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
252
309
|
def _readonly(command: str) -> bool:
|
|
253
310
|
from ..actions.registry import spec_of
|
|
254
311
|
|
|
@@ -30,7 +30,13 @@ GIT_TIMEOUT = 30
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
33
|
-
"""跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。
|
|
33
|
+
"""跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。
|
|
34
|
+
|
|
35
|
+
**超时也折成返回码 124**,不许抛出去:`sync_down` 的 fetch 分支、`push` 的重试、
|
|
36
|
+
`_rebase_retry` 全按返回码判「远端不通」,抛异常会被 runner 兜底成「未预期错误」
|
|
37
|
+
(退出码 3),把「拉取失败、按本地最新写」这条既定降级路径整条跳过。触发场景是
|
|
38
|
+
弱网 / 网络黑洞 / 代理挂住 —— 远端不立刻拒绝,fetch 一直挂到超时(2026-10-25 修)。
|
|
39
|
+
"""
|
|
34
40
|
try:
|
|
35
41
|
return subprocess.run(
|
|
36
42
|
list(args), capture_output=True, text=True, timeout=timeout, check=False
|
|
@@ -39,6 +45,13 @@ def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedP
|
|
|
39
45
|
return subprocess.CompletedProcess(
|
|
40
46
|
list(args), 127, stdout="", stderr="git: command not found"
|
|
41
47
|
)
|
|
48
|
+
except subprocess.TimeoutExpired:
|
|
49
|
+
return subprocess.CompletedProcess(
|
|
50
|
+
list(args),
|
|
51
|
+
124,
|
|
52
|
+
stdout="",
|
|
53
|
+
stderr=f"git: timed out after {timeout}s",
|
|
54
|
+
)
|
|
42
55
|
|
|
43
56
|
|
|
44
57
|
@contextlib.contextmanager
|
|
@@ -101,7 +101,8 @@ def show_view(res: ShowResult, *, as_json: bool) -> None:
|
|
|
101
101
|
task = res.task
|
|
102
102
|
_out(f"{task.get('tid', '')}:{task.get('title', '')}")
|
|
103
103
|
_out(
|
|
104
|
-
f" 状态:{task.get('status', '')}
|
|
104
|
+
f" 状态:{task.get('status', '')} "
|
|
105
|
+
f"负责人:{task.get('owner_name') or task.get('owner') or '(未认领)'}"
|
|
105
106
|
)
|
|
106
107
|
_out(
|
|
107
108
|
f" 创建:{fmt_local(task.get('created'))} "
|
|
@@ -143,7 +144,7 @@ def history_view(res: HistoryResult, q: HistoryQuery | None, *, as_json: bool) -
|
|
|
143
144
|
for row in res.records:
|
|
144
145
|
_out(
|
|
145
146
|
f" {fmt_local(row.get('finished'))} "
|
|
146
|
-
f"{row.get('owner') or '(无人)':<12} {row.get('title', '')}"
|
|
147
|
+
f"{row.get('owner_name') or row.get('owner') or '(无人)':<12} {row.get('title', '')}"
|
|
147
148
|
)
|
|
148
149
|
|
|
149
150
|
|
|
@@ -213,7 +213,7 @@ def _task_snapshot(repo: Path, me) -> dict[str, Any]:
|
|
|
213
213
|
|
|
214
214
|
tasks: dict[str, Any] = {}
|
|
215
215
|
tasks.update(TaskStore(repo, POOL_REL).all())
|
|
216
|
-
tasks.update(TaskStore(repo, shard_rel(me.
|
|
216
|
+
tasks.update(TaskStore(repo, shard_rel(me.uuid)).all())
|
|
217
217
|
return tasks
|
|
218
218
|
|
|
219
219
|
|
|
@@ -9,9 +9,8 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from ..core import history_md
|
|
12
|
-
from ..core.paths import HISTORY_DIR,
|
|
12
|
+
from ..core.paths import HISTORY_DIR, to_repo_rel
|
|
13
13
|
from .atomic import write_text_atomic
|
|
14
|
-
from .worklog_store import WorklogStore
|
|
15
14
|
|
|
16
15
|
HIT = "hit"
|
|
17
16
|
MISS = "miss"
|
|
@@ -25,16 +24,12 @@ class HistoryStore:
|
|
|
25
24
|
# ---- 读 ----
|
|
26
25
|
|
|
27
26
|
def sources(self) -> list[Path]:
|
|
28
|
-
"""按月的 `history/*.md
|
|
27
|
+
"""按月的 `history/*.md`(按文件名排序)。"""
|
|
29
28
|
base = self.repo / to_repo_rel(HISTORY_DIR)
|
|
30
|
-
|
|
31
|
-
legacy = self.repo / to_repo_rel(LEGACY_HISTORY_REL)
|
|
32
|
-
if legacy.is_file():
|
|
33
|
-
files.append(legacy)
|
|
34
|
-
return files
|
|
29
|
+
return sorted(base.glob("*.md")) if base.is_dir() else []
|
|
35
30
|
|
|
36
31
|
def month_key(self, path: Path) -> str:
|
|
37
|
-
"""按月文件的 `YYYYMM
|
|
32
|
+
"""按月文件的 `YYYYMM`。"""
|
|
38
33
|
stem = Path(path).stem
|
|
39
34
|
return stem if len(stem) == 6 and stem.isdigit() else ""
|
|
40
35
|
|
|
@@ -46,16 +41,6 @@ class HistoryStore:
|
|
|
46
41
|
except OSError as exc:
|
|
47
42
|
raise ValueError(f"读 {path} 失败:{exc}") from exc
|
|
48
43
|
|
|
49
|
-
def _read_worklog(self, path: Path):
|
|
50
|
-
"""升级要回链的 worklog 头部:读文件这件事留在 store,core 只消费返回的字典。"""
|
|
51
|
-
logs = WorklogStore(self.repo)
|
|
52
|
-
|
|
53
|
-
def read(log_name: str) -> dict[str, Any] | None:
|
|
54
|
-
found = logs.by_name(log_name)
|
|
55
|
-
return logs.head_meta(found) if found else None
|
|
56
|
-
|
|
57
|
-
return read
|
|
58
|
-
|
|
59
44
|
# ---- 判重 ----
|
|
60
45
|
|
|
61
46
|
def has_log(
|
|
@@ -79,6 +64,11 @@ class HistoryStore:
|
|
|
79
64
|
if Path(worklog).name == Path(log_name).name:
|
|
80
65
|
return HIT
|
|
81
66
|
continue
|
|
67
|
+
if log_name is None and worklog:
|
|
68
|
+
# 历史行里有链接,但这次判不出对应的是哪篇文档(任务的过程文档丢了 / 被移走):
|
|
69
|
+
# 判不了就明说。拿 miss 冒充会再追加一行 ⇒ 同一条任务两条结案记录。
|
|
70
|
+
undecidable = True
|
|
71
|
+
continue
|
|
82
72
|
if not worklog:
|
|
83
73
|
if created and title:
|
|
84
74
|
if row.get("created") == created and row.get("title") == title:
|
|
@@ -90,11 +80,7 @@ class HistoryStore:
|
|
|
90
80
|
# ---- 算 ----
|
|
91
81
|
|
|
92
82
|
def append_row(self, path: Path, row: dict[str, Any]) -> dict[str, Any]:
|
|
93
|
-
"""
|
|
94
|
-
|
|
95
|
-
返回四个键:`text` / `upgraded` / `duplicate` / `fallback_cols`(`None` = 正常 9 列)。
|
|
96
|
-
老表列少又不敢整体升级时,按现有列数追加并让业务层提示,绝不硬写 9 列把表格挤歪。
|
|
97
|
-
"""
|
|
83
|
+
"""算出「追加这一行后的新全文」,**不落盘**。返回 `text` / `duplicate`。"""
|
|
98
84
|
original = self.read(path)
|
|
99
85
|
duplicate = (
|
|
100
86
|
self.has_log(
|
|
@@ -105,67 +91,26 @@ class HistoryStore:
|
|
|
105
91
|
)
|
|
106
92
|
== HIT
|
|
107
93
|
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
read_worklog=self._read_worklog(path),
|
|
122
|
-
fallback_created=row.get("created") or "",
|
|
123
|
-
)
|
|
124
|
-
if upgraded_text is None:
|
|
125
|
-
text = original
|
|
126
|
-
upgraded = False
|
|
127
|
-
fallback_cols = cols
|
|
128
|
-
else:
|
|
129
|
-
text, _ = upgraded_text
|
|
130
|
-
upgraded = True
|
|
131
|
-
fallback_cols = None
|
|
132
|
-
line = (
|
|
133
|
-
history_md.history_line(
|
|
134
|
-
row.get("finished") or "",
|
|
135
|
-
row.get("created"),
|
|
136
|
-
row.get("title") or "",
|
|
137
|
-
row.get("owner"),
|
|
138
|
-
row.get("summary") or "",
|
|
139
|
-
row.get("worklog"),
|
|
140
|
-
row.get("category"),
|
|
141
|
-
row.get("tags"),
|
|
142
|
-
row.get("creator"),
|
|
143
|
-
)
|
|
144
|
-
if fallback_cols is None
|
|
145
|
-
else history_md.history_line_for_cols(
|
|
146
|
-
fallback_cols,
|
|
147
|
-
row.get("finished") or "",
|
|
148
|
-
row.get("created"),
|
|
149
|
-
row.get("title") or "",
|
|
150
|
-
row.get("owner"),
|
|
151
|
-
row.get("summary") or "",
|
|
152
|
-
row.get("worklog"),
|
|
153
|
-
row.get("category"),
|
|
154
|
-
row.get("tags"),
|
|
155
|
-
row.get("creator"),
|
|
156
|
-
)
|
|
94
|
+
text = original if original.strip() else _fresh_table()
|
|
95
|
+
line = history_md.history_line(
|
|
96
|
+
row.get("finished") or "",
|
|
97
|
+
row.get("created"),
|
|
98
|
+
row.get("title") or "",
|
|
99
|
+
row.get("owner"),
|
|
100
|
+
row.get("summary") or "",
|
|
101
|
+
row.get("worklog"),
|
|
102
|
+
category=row.get("category"),
|
|
103
|
+
tags=row.get("tags"),
|
|
104
|
+
creator=row.get("creator"),
|
|
105
|
+
owner_name=row.get("owner_name"),
|
|
106
|
+
creator_name=row.get("creator_name"),
|
|
157
107
|
)
|
|
158
|
-
return {
|
|
159
|
-
"text": _append_line(text, line),
|
|
160
|
-
"upgraded": upgraded,
|
|
161
|
-
"duplicate": duplicate,
|
|
162
|
-
"fallback_cols": fallback_cols,
|
|
163
|
-
}
|
|
108
|
+
return {"text": _append_line(text, line), "duplicate": duplicate}
|
|
164
109
|
|
|
165
110
|
# ---- 写 ----
|
|
166
111
|
|
|
167
112
|
def write(self, path: Path, text: str) -> None:
|
|
168
|
-
"""
|
|
113
|
+
"""历史表唯一的写回口:只用于「结案追加」的结果落盘。"""
|
|
169
114
|
write_text_atomic(path, text)
|
|
170
115
|
|
|
171
116
|
|
|
@@ -174,7 +119,7 @@ def _name_of(worklog: str | None) -> str | None:
|
|
|
174
119
|
|
|
175
120
|
|
|
176
121
|
def _fresh_table() -> str:
|
|
177
|
-
"""
|
|
122
|
+
"""当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
|
|
178
123
|
return (
|
|
179
124
|
f"{history_md.HISTORY_TITLE}\n\n"
|
|
180
125
|
f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
|
|
@@ -32,7 +32,7 @@ class OwnersIndex:
|
|
|
32
32
|
|
|
33
33
|
def ensure(self, me: Identity) -> tuple[bool, dict[str, Any]]:
|
|
34
34
|
"""登记 / 补 `joined`。`name` 只在首次登记时写入,之后不回写(名字是给你做对照的被动字段,不该被任何逻辑读)。返回 (这次写了没有, 记录)。"""
|
|
35
|
-
key = normalize(me.
|
|
35
|
+
key = normalize(me.uuid)
|
|
36
36
|
with self._open() as session:
|
|
37
37
|
data = session.data
|
|
38
38
|
workers = data.setdefault("workers", {})
|
|
@@ -48,6 +48,21 @@ class OwnersIndex:
|
|
|
48
48
|
return True, dict(record)
|
|
49
49
|
return False, dict(record)
|
|
50
50
|
|
|
51
|
+
def keys_of_name(self, fragment: str) -> set[str]:
|
|
52
|
+
"""显示名模糊匹配(不分大小写的子串)⇒ uuid 集合。**只给查询筛选**,不参与归属判定。
|
|
53
|
+
|
|
54
|
+
比对的是名册里的名字(`workers[<uuid>].name`),不是每条任务上那份冗余的
|
|
55
|
+
`owner_name`:改名后名册先变,筛选结果跟着变,任务里的旧名字不会干扰。
|
|
56
|
+
"""
|
|
57
|
+
needle = normalize(fragment)
|
|
58
|
+
if not needle:
|
|
59
|
+
return set()
|
|
60
|
+
return {
|
|
61
|
+
key
|
|
62
|
+
for key, record in self.all().items()
|
|
63
|
+
if needle in normalize(str((record or {}).get("name") or ""))
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
def shards(self) -> dict[str, Path]:
|
|
52
67
|
"""身份 → 分片绝对路径(只信索引)。"""
|
|
53
68
|
out: dict[str, Path] = {}
|
|
@@ -110,5 +125,3 @@ class _IndexSession:
|
|
|
110
125
|
def __exit__(self, *exc) -> None:
|
|
111
126
|
if self.changed:
|
|
112
127
|
self.store._flush(self.data) # 同一个包内的受控写回口
|
|
113
|
-
|
|
114
|
-
|
|
@@ -22,7 +22,7 @@ class TaskStore(JSONStore):
|
|
|
22
22
|
self.identity = identity
|
|
23
23
|
|
|
24
24
|
def _flush(self, data: dict[str, Any]) -> None:
|
|
25
|
-
"""写盘时带上分片顶部的 `identity
|
|
25
|
+
"""写盘时带上分片顶部的 `identity`(只存 uuid;显示名在索引里)。"""
|
|
26
26
|
if self.identity is not None:
|
|
27
|
-
data["identity"] = {"
|
|
27
|
+
data["identity"] = {"uuid": self.identity.uuid}
|
|
28
28
|
super()._flush(data)
|