task-ledger 1.3.0 → 1.3.2
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 +29 -247
- package/SKILL.md +173 -88
- package/package.json +22 -22
- package/references/log-note.md +3 -1
- package/references/state-model.md +29 -27
- package/references/troubleshooting.md +97 -32
- package/scripts/ledgerkit/__init__.py +0 -0
- package/scripts/ledgerkit/actions/__init__.py +0 -0
- package/scripts/ledgerkit/actions/atom.py +154 -0
- package/scripts/ledgerkit/actions/bootstrap.py +26 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +136 -0
- package/scripts/ledgerkit/actions/handoff.py +74 -0
- package/scripts/ledgerkit/actions/list_tasks.py +53 -0
- package/scripts/ledgerkit/actions/log_progress.py +60 -0
- package/scripts/ledgerkit/actions/new_task.py +120 -0
- package/scripts/ledgerkit/actions/query.py +33 -0
- package/scripts/ledgerkit/actions/query_history.py +172 -0
- package/scripts/ledgerkit/actions/registry.py +82 -0
- package/scripts/ledgerkit/actions/release.py +51 -0
- package/scripts/ledgerkit/actions/result.py +88 -0
- package/scripts/ledgerkit/actions/set_task.py +43 -0
- package/scripts/ledgerkit/actions/show_task.py +27 -0
- package/scripts/ledgerkit/actions/validate.py +224 -0
- package/scripts/ledgerkit/core/__init__.py +0 -0
- package/scripts/ledgerkit/core/history_md.py +350 -0
- package/scripts/ledgerkit/core/identity.py +27 -0
- package/scripts/ledgerkit/core/ids.py +58 -0
- package/scripts/ledgerkit/core/labels.py +79 -0
- package/scripts/ledgerkit/core/paths.py +93 -0
- package/scripts/ledgerkit/core/time.py +102 -0
- package/scripts/ledgerkit/core/worklog_md.py +232 -0
- package/scripts/ledgerkit/env.py +93 -0
- package/scripts/ledgerkit/errors.py +15 -0
- package/scripts/ledgerkit/notices.py +28 -0
- package/scripts/ledgerkit/shell/__init__.py +0 -0
- package/scripts/ledgerkit/shell/cli.py +259 -0
- package/scripts/ledgerkit/shell/gitops.py +655 -0
- package/scripts/ledgerkit/shell/merge.py +266 -0
- package/scripts/ledgerkit/shell/presync.py +23 -0
- package/scripts/ledgerkit/shell/render.py +165 -0
- package/scripts/ledgerkit/shell/runner.py +225 -0
- package/scripts/ledgerkit/store/__init__.py +0 -0
- package/scripts/ledgerkit/store/atomic.py +130 -0
- package/scripts/ledgerkit/store/dirty.py +108 -0
- package/scripts/ledgerkit/store/history_store.py +189 -0
- package/scripts/ledgerkit/store/json_store.py +166 -0
- package/scripts/ledgerkit/store/locks.py +64 -0
- package/scripts/ledgerkit/store/owners_index.py +114 -0
- package/scripts/ledgerkit/store/task_store.py +28 -0
- package/scripts/ledgerkit/store/worklog_store.py +168 -0
- package/scripts/progress.py +8 -3109
- package/references/taxonomy.md +0 -26
- package/references/worklog.md +0 -39
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""怎么安全地写一个文件:唯一写盘出口。
|
|
2
|
+
|
|
3
|
+
进门规则:不打印、不跑 git、不判断 owner/status、不自己加锁。
|
|
4
|
+
写完顺手通知 `dirty` 记一笔(那是记账本唯一被写的地方之一)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..errors import Fail
|
|
16
|
+
from . import dirty
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_dir(path: Path) -> None:
|
|
20
|
+
"""建目录(`mkdir -p` 语义)。**全仓唯一建目录的地方**,写文件不替人建父目录。"""
|
|
21
|
+
try:
|
|
22
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
except OSError as exc:
|
|
24
|
+
raise Fail(f"建目录 {path} 失败:{exc}") from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_text_atomic(path: Path, text: str, *, track: bool = True) -> None:
|
|
28
|
+
"""原子写文本:同目录临时文件 → 写 → fsync → 继承权限 → replace → fsync 父目录。
|
|
29
|
+
|
|
30
|
+
`track=False` 只给 `restore`(复原世界)与外壳两条受控通道用 —— 那三处都不是业务改动。
|
|
31
|
+
两个属性必须保住:mkstemp 建的是 0600,直接 replace 会把 644 降成 600;
|
|
32
|
+
符号链接一律拒绝,否则 replace 会把链接替换成普通文件。
|
|
33
|
+
"""
|
|
34
|
+
fd = None
|
|
35
|
+
tmp: Path | None = None
|
|
36
|
+
existed = False
|
|
37
|
+
try:
|
|
38
|
+
# 不替调用方建父目录:建目录只有 ensure_dir 一处,业务层要自己先 ensure_dir
|
|
39
|
+
if not path.parent.exists():
|
|
40
|
+
raise Fail(
|
|
41
|
+
f"写 {path} 失败:父目录 {path.parent} 不存在(建目录要显式调 ensure_dir)。"
|
|
42
|
+
)
|
|
43
|
+
if path.is_symlink():
|
|
44
|
+
raise Fail(f"拒绝写入 {path}:它是符号链接,写入会把它替换成普通文件")
|
|
45
|
+
try:
|
|
46
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
47
|
+
existed = True
|
|
48
|
+
except OSError:
|
|
49
|
+
mode = 0o644 # 新文件:与 git 默认对齐
|
|
50
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
51
|
+
dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
|
|
52
|
+
)
|
|
53
|
+
tmp = Path(tmp_name)
|
|
54
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
|
|
55
|
+
fd = None # 所有权已交给文件对象
|
|
56
|
+
fh.write(text)
|
|
57
|
+
fh.flush()
|
|
58
|
+
os.fsync(fh.fileno())
|
|
59
|
+
os.chmod(tmp, mode)
|
|
60
|
+
os.replace(tmp, path)
|
|
61
|
+
_fsync_dir(path.parent)
|
|
62
|
+
tmp = None
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise Fail(
|
|
65
|
+
f"写 {path} 失败:{exc}\n"
|
|
66
|
+
" 处理:确认该路径可写、磁盘没满,然后重跑这条命令(文件是整份替换,不会写坏)。"
|
|
67
|
+
) from exc
|
|
68
|
+
finally:
|
|
69
|
+
if fd is not None:
|
|
70
|
+
try:
|
|
71
|
+
os.close(fd)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
if tmp is not None and tmp.exists():
|
|
75
|
+
try:
|
|
76
|
+
tmp.unlink()
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
dirty.current().record(path, track=track, created=not existed)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fsync_dir(dirpath: Path) -> None:
|
|
83
|
+
"""刷父目录:掉电时 rename 的目录项可能丢,只刷文件是不够的。"""
|
|
84
|
+
try:
|
|
85
|
+
fd = os.open(str(dirpath), os.O_RDONLY)
|
|
86
|
+
except OSError:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
os.fsync(fd)
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
finally:
|
|
93
|
+
os.close(fd)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fingerprint(path: Path) -> str | None:
|
|
97
|
+
"""文件内容 sha256,用来判「我读完以后别人动过没有」。
|
|
98
|
+
|
|
99
|
+
三种返回值互不相等:正常 → sha256 串;不存在 → None;读不了 → `"unreadable"`。
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
data = path.read_bytes()
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
return None
|
|
105
|
+
except OSError:
|
|
106
|
+
return "unreadable"
|
|
107
|
+
return hashlib.sha256(data).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def snapshot(path: Path) -> bytes | None:
|
|
111
|
+
"""写之前把原文留一份字节;原本不存在返回 None。"""
|
|
112
|
+
try:
|
|
113
|
+
return path.read_bytes()
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
return None
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise Fail(f"读 {path} 失败:{exc}") from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def restore(path: Path, before: bytes | None) -> None:
|
|
121
|
+
"""按字节还原:`None` 表示原本不存在 ⇒ 删掉。恢复本身不进记账本(`track=False`)。"""
|
|
122
|
+
if before is None:
|
|
123
|
+
try:
|
|
124
|
+
path.unlink()
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
return
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise Fail(f"撤销 {path} 失败:{exc}") from exc
|
|
129
|
+
return
|
|
130
|
+
write_text_atomic(path, before.decode("utf-8"), track=False)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""记账本:这一轮到底动了哪些文件。**提交集合只看它**,不看 `Result.changed`。
|
|
2
|
+
|
|
3
|
+
三类记录各有各的用途:
|
|
4
|
+
- `changed`:业务改动,最后要提交;
|
|
5
|
+
- `created`:本次新建的文件,失败时要删掉(不能留下孤儿);
|
|
6
|
+
- `presync`:取最新时刷过的文件(连"刷之前"的字节一起)——它们**反而必须**一起提交,
|
|
7
|
+
否则拉下来的新内容留在工作树里,下一条写命令会被写前 guard 拦死。
|
|
8
|
+
|
|
9
|
+
只返回清单、不打印(打印归外壳,否则 dirty 要 import shell,依赖反向)。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class WriteLog:
|
|
22
|
+
changed: list[Path] = field(default_factory=list)
|
|
23
|
+
created: list[Path] = field(default_factory=list)
|
|
24
|
+
presync: list[tuple[Path, bytes | None]] = field(default_factory=list)
|
|
25
|
+
# 进入提交阶段(_finalize 开始 git add/commit)后置位:此时台账数据已落盘,
|
|
26
|
+
# 失败 / 中断都不许回滚或删除 —— 那会吃掉刚写的台账(2026-10-24 修)。
|
|
27
|
+
commit_started: bool = False
|
|
28
|
+
|
|
29
|
+
def record(self, path: Path, *, track: bool = True, created: bool = False) -> None:
|
|
30
|
+
"""记一次写。`track=False`(复原 / 外壳刷盘)不进改动集合。"""
|
|
31
|
+
if created and path not in self.created:
|
|
32
|
+
self.created.append(path)
|
|
33
|
+
if track and path not in self.changed:
|
|
34
|
+
self.changed.append(path)
|
|
35
|
+
|
|
36
|
+
def record_presync(self, path: Path, before: bytes | None) -> None:
|
|
37
|
+
"""记「取最新刷过这个文件」+ 刷之前的字节(失败时按它恢复)。"""
|
|
38
|
+
for i, (known, _) in enumerate(self.presync):
|
|
39
|
+
if known == path:
|
|
40
|
+
self.presync[i] = (path, before)
|
|
41
|
+
return
|
|
42
|
+
self.presync.append((path, before))
|
|
43
|
+
|
|
44
|
+
def commit_set(self) -> list[Path]:
|
|
45
|
+
"""最后要提交的文件:本次改动 + 取最新刷过的(去重保序)。"""
|
|
46
|
+
out: list[Path] = []
|
|
47
|
+
for path in [*self.changed, *(p for p, _ in self.presync)]:
|
|
48
|
+
if path not in out:
|
|
49
|
+
out.append(path)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
def has_writes(self) -> bool:
|
|
53
|
+
return bool(self.changed or self.presync)
|
|
54
|
+
|
|
55
|
+
def rollback_presync(self) -> list[Path]:
|
|
56
|
+
"""按备份把「被取最新覆盖掉」的内容恢复回去(刷之前不存在 ⇒ 删掉)。"""
|
|
57
|
+
from .atomic import write_text_atomic
|
|
58
|
+
|
|
59
|
+
done: list[Path] = []
|
|
60
|
+
for path, before in reversed(self.presync):
|
|
61
|
+
if before is None:
|
|
62
|
+
try:
|
|
63
|
+
path.unlink()
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
pass
|
|
66
|
+
else:
|
|
67
|
+
write_text_atomic(path, before.decode("utf-8"), track=False)
|
|
68
|
+
done.append(path)
|
|
69
|
+
self.presync = []
|
|
70
|
+
return done
|
|
71
|
+
|
|
72
|
+
def discard_created(self) -> list[Path]:
|
|
73
|
+
"""删掉本次新建的文件(失败收尾用)。"""
|
|
74
|
+
done: list[Path] = []
|
|
75
|
+
for path in self.created:
|
|
76
|
+
try:
|
|
77
|
+
path.unlink()
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
pass
|
|
80
|
+
done.append(path)
|
|
81
|
+
self.created = []
|
|
82
|
+
return done
|
|
83
|
+
|
|
84
|
+
def clear(self) -> None:
|
|
85
|
+
"""成功路径收尾:全部清零。"""
|
|
86
|
+
self.changed = []
|
|
87
|
+
self.created = []
|
|
88
|
+
self.presync = []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_CURRENT: WriteLog = WriteLog()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def current() -> WriteLog:
|
|
95
|
+
"""当前这一轮的记账本(没绑定时给一个空账本)。"""
|
|
96
|
+
return _CURRENT
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@contextlib.contextmanager
|
|
100
|
+
def bound(log: WriteLog) -> Iterator[None]:
|
|
101
|
+
"""绑定这一轮的记账本(生产只在 runner 里绑一次;测试用)。"""
|
|
102
|
+
global _CURRENT
|
|
103
|
+
saved = _CURRENT
|
|
104
|
+
_CURRENT = log
|
|
105
|
+
try:
|
|
106
|
+
yield
|
|
107
|
+
finally:
|
|
108
|
+
_CURRENT = saved
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""已结案 `history/<年月>.md`:列全部文件、读一篇、算「追加一行后的新全文」、判重、落盘。
|
|
2
|
+
|
|
3
|
+
「算」和「落」是两个方法 —— 结案要控顺序(历史 → 封卷 → 删分片),落盘时机由业务层定。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..core import history_md
|
|
12
|
+
from ..core.paths import HISTORY_DIR, LEGACY_HISTORY_REL, to_repo_rel
|
|
13
|
+
from .atomic import write_text_atomic
|
|
14
|
+
from .worklog_store import WorklogStore
|
|
15
|
+
|
|
16
|
+
HIT = "hit"
|
|
17
|
+
MISS = "miss"
|
|
18
|
+
UNDECIDABLE = "undecidable"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HistoryStore:
|
|
22
|
+
def __init__(self, repo: Path):
|
|
23
|
+
self.repo = Path(repo)
|
|
24
|
+
|
|
25
|
+
# ---- 读 ----
|
|
26
|
+
|
|
27
|
+
def sources(self) -> list[Path]:
|
|
28
|
+
"""按月的 `history/*.md`(按文件名排序)+ 存量平铺文件(只读,永不重写)。"""
|
|
29
|
+
base = self.repo / to_repo_rel(HISTORY_DIR)
|
|
30
|
+
files = sorted(base.glob("*.md")) if base.is_dir() else []
|
|
31
|
+
legacy = self.repo / to_repo_rel(LEGACY_HISTORY_REL)
|
|
32
|
+
if legacy.is_file():
|
|
33
|
+
files.append(legacy)
|
|
34
|
+
return files
|
|
35
|
+
|
|
36
|
+
def month_key(self, path: Path) -> str:
|
|
37
|
+
"""按月文件的 `YYYYMM`;存量平铺文件没有月份键,返回空串。"""
|
|
38
|
+
stem = Path(path).stem
|
|
39
|
+
return stem if len(stem) == 6 and stem.isdigit() else ""
|
|
40
|
+
|
|
41
|
+
def read(self, path: Path) -> str:
|
|
42
|
+
try:
|
|
43
|
+
return path.read_text(encoding="utf-8-sig")
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
return ""
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise ValueError(f"读 {path} 失败:{exc}") from exc
|
|
48
|
+
|
|
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
|
+
# ---- 判重 ----
|
|
60
|
+
|
|
61
|
+
def has_log(
|
|
62
|
+
self,
|
|
63
|
+
path: Path,
|
|
64
|
+
*,
|
|
65
|
+
log_name: str | None,
|
|
66
|
+
created: str | None,
|
|
67
|
+
title: str | None,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""这个月的历史里已经有这条了吗?三态:`hit` / `miss` / `undecidable`。
|
|
70
|
+
|
|
71
|
+
有末列就比**文件名**(不比整条路径)。末列为空的行改按「创建时间 + 工作包」两列比
|
|
72
|
+
—— 那种行在调用方给不出 `created` / `title` 时判不了,明说 `undecidable`,不拿 miss 冒充。
|
|
73
|
+
"""
|
|
74
|
+
records, _ = history_md.parse_history(self.read(path))
|
|
75
|
+
undecidable = False
|
|
76
|
+
for row in records:
|
|
77
|
+
worklog = (row.get("worklog") or "").strip()
|
|
78
|
+
if log_name and worklog:
|
|
79
|
+
if Path(worklog).name == Path(log_name).name:
|
|
80
|
+
return HIT
|
|
81
|
+
continue
|
|
82
|
+
if not worklog:
|
|
83
|
+
if created and title:
|
|
84
|
+
if row.get("created") == created and row.get("title") == title:
|
|
85
|
+
return HIT
|
|
86
|
+
else:
|
|
87
|
+
undecidable = True
|
|
88
|
+
return UNDECIDABLE if undecidable else MISS
|
|
89
|
+
|
|
90
|
+
# ---- 算 ----
|
|
91
|
+
|
|
92
|
+
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
|
+
"""
|
|
98
|
+
original = self.read(path)
|
|
99
|
+
duplicate = (
|
|
100
|
+
self.has_log(
|
|
101
|
+
path,
|
|
102
|
+
log_name=_name_of(row.get("worklog")),
|
|
103
|
+
created=row.get("created"),
|
|
104
|
+
title=row.get("title"),
|
|
105
|
+
)
|
|
106
|
+
== HIT
|
|
107
|
+
)
|
|
108
|
+
if not original.strip():
|
|
109
|
+
text = _fresh_table()
|
|
110
|
+
upgraded = False
|
|
111
|
+
fallback_cols = None
|
|
112
|
+
else:
|
|
113
|
+
cols = history_md.history_cols(original)
|
|
114
|
+
if cols == 9:
|
|
115
|
+
text = original
|
|
116
|
+
upgraded = False
|
|
117
|
+
fallback_cols = None
|
|
118
|
+
else:
|
|
119
|
+
upgraded_text = history_md.upgrade_history(
|
|
120
|
+
original,
|
|
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
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return {
|
|
159
|
+
"text": _append_line(text, line),
|
|
160
|
+
"upgraded": upgraded,
|
|
161
|
+
"duplicate": duplicate,
|
|
162
|
+
"fallback_cols": fallback_cols,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# ---- 写 ----
|
|
166
|
+
|
|
167
|
+
def write(self, path: Path, text: str) -> None:
|
|
168
|
+
"""历史表唯一的写回口:只用于「结案追加」与「8→9 列升级」的结果落盘。"""
|
|
169
|
+
write_text_atomic(path, text)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _name_of(worklog: str | None) -> str | None:
|
|
173
|
+
return Path(worklog).name if worklog else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _fresh_table() -> str:
|
|
177
|
+
"""当月第一次结案:按九列表头起表(表头的真源只有 `history_md` 一处)。"""
|
|
178
|
+
return (
|
|
179
|
+
f"{history_md.HISTORY_TITLE}\n\n"
|
|
180
|
+
f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _append_line(text: str, line: str) -> str:
|
|
185
|
+
body = text.rstrip("\n")
|
|
186
|
+
if body and not body.splitlines()[-1].strip().startswith("|"):
|
|
187
|
+
# 表前有散文:补一个空行,别把表头贴上去
|
|
188
|
+
return body + "\n\n" + line
|
|
189
|
+
return body + "\n" + line
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""所有 JSON 台账的共用底座:读、按条取、按条写、按条删、指纹、一条一行的写盘格式。
|
|
2
|
+
|
|
3
|
+
**版本字段的落点就在这里**:读盘发现与 `FORMAT_VERSION` 不符 ⇒ `notices.note` 一次(不拦);
|
|
4
|
+
写盘一律带上当前 `FORMAT_VERSION`(缺就补)。业务层各自判断就是散落。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .. import notices
|
|
14
|
+
from ..core.labels import FORMAT_VERSION
|
|
15
|
+
from ..errors import Fail
|
|
16
|
+
from .atomic import fingerprint, write_text_atomic
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def dump_ledger_json(data: dict[str, Any], *, expand: str = "tasks") -> str:
|
|
20
|
+
"""「一条一行」的台账文本(`expand` 指定的那个字典一条一行,其余字段各占一行)。
|
|
21
|
+
|
|
22
|
+
键排序、值也 `sort_keys`:**同一份逻辑内容永远产出同一串字节** ——
|
|
23
|
+
多人各写各的才能按行合并,重写也不会产出无意义 diff。这是**格式契约**,
|
|
24
|
+
别改成缩进美化格式,也别按插入序输出(老文件会整篇翻面)。
|
|
25
|
+
"""
|
|
26
|
+
body: dict[str, Any] = dict(data)
|
|
27
|
+
body["version"] = FORMAT_VERSION # 写盘一律带当前版本(缺就补、旧就更新)
|
|
28
|
+
rows: list[str] = []
|
|
29
|
+
for key in sorted(body):
|
|
30
|
+
value = body[key]
|
|
31
|
+
if key == expand and isinstance(value, dict):
|
|
32
|
+
inner = [
|
|
33
|
+
f" {json.dumps(k, ensure_ascii=False)}: "
|
|
34
|
+
f"{json.dumps(v, ensure_ascii=False, sort_keys=True)}"
|
|
35
|
+
for k, v in sorted(value.items())
|
|
36
|
+
]
|
|
37
|
+
block = (",\n".join(inner) + "\n") if inner else ""
|
|
38
|
+
rows.append(f' "{key}": {{\n{block} }}')
|
|
39
|
+
continue
|
|
40
|
+
rows.append(
|
|
41
|
+
f' "{key}": {json.dumps(value, ensure_ascii=False, sort_keys=True)}'
|
|
42
|
+
)
|
|
43
|
+
return "{\n" + ",\n".join(rows) + "\n}\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_ledger_json(
|
|
47
|
+
path: Path, data: dict[str, Any], *, expand: str = "tasks"
|
|
48
|
+
) -> None:
|
|
49
|
+
"""按「一条一行」写台账(格式文本由 `dump_ledger_json` 出)。"""
|
|
50
|
+
write_text_atomic(path, dump_ledger_json(data, expand=expand))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class JSONStore:
|
|
54
|
+
"""一份 JSON 台账的读改写。首次读盘后缓存在实例里。"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, path: Path, *, default: dict[str, Any], expand: str = "tasks"):
|
|
57
|
+
self.path = Path(path)
|
|
58
|
+
self.expand = expand
|
|
59
|
+
self._default = dict(default)
|
|
60
|
+
self._data: dict[str, Any] | None = None
|
|
61
|
+
|
|
62
|
+
# ---- 读 ----
|
|
63
|
+
|
|
64
|
+
def _load(self) -> dict[str, Any]:
|
|
65
|
+
if self._data is None:
|
|
66
|
+
data = self._read_raw()
|
|
67
|
+
if data is None:
|
|
68
|
+
# 文件还没有:按默认起一份,**不算版本不符**(没文件就没版本可比)
|
|
69
|
+
data = dict(self._default)
|
|
70
|
+
data["version"] = FORMAT_VERSION
|
|
71
|
+
else:
|
|
72
|
+
self._check_version(data)
|
|
73
|
+
rows = data.get(self.expand)
|
|
74
|
+
if rows is not None and not isinstance(rows, dict):
|
|
75
|
+
# 版本不符只告警,但「tasks 不是对象」是读不了的真错(2026-10-22 数据契约)
|
|
76
|
+
raise Fail(
|
|
77
|
+
f"{self._rel()} 的 {self.expand} 不是对象"
|
|
78
|
+
f"(是{type(rows).__name__}),这份台账读不了。\n"
|
|
79
|
+
" 处理:用 git 恢复它(git checkout -- <文件>),或手工改成对象。"
|
|
80
|
+
)
|
|
81
|
+
self._data = data
|
|
82
|
+
return self._data
|
|
83
|
+
|
|
84
|
+
def _read_raw(self) -> dict[str, Any] | None:
|
|
85
|
+
try:
|
|
86
|
+
text = self.path.read_text(encoding="utf-8-sig")
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
return None
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise ValueError(f"读 {self.path} 失败:{exc}") from exc
|
|
91
|
+
if not text.strip():
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(text)
|
|
95
|
+
except json.JSONDecodeError as exc:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{self.path} 不是合法 JSON(第 {exc.lineno} 行):{exc.msg}"
|
|
98
|
+
) from exc
|
|
99
|
+
if not isinstance(data, dict):
|
|
100
|
+
raise TypeError(f"{self.path} 顶层不是对象。")
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
def _check_version(self, data: dict[str, Any]) -> None:
|
|
104
|
+
version = data.get("version")
|
|
105
|
+
if isinstance(version, str) and version == FORMAT_VERSION:
|
|
106
|
+
return
|
|
107
|
+
notices.note(
|
|
108
|
+
f"提示:{self._rel()} 的格式版本是 {version or '未知'},"
|
|
109
|
+
f"当前是 {FORMAT_VERSION};不拦,但字段可能有出入。"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _rel(self) -> str:
|
|
113
|
+
try:
|
|
114
|
+
return self.path.name
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
116
|
+
return str(self.path)
|
|
117
|
+
|
|
118
|
+
def data(self) -> dict[str, Any]:
|
|
119
|
+
"""整份只读视图(升级 / 体检用)。"""
|
|
120
|
+
return dict(self._load())
|
|
121
|
+
|
|
122
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
123
|
+
value = self._load().get(self.expand, {}).get(key)
|
|
124
|
+
return dict(value) if isinstance(value, dict) else None
|
|
125
|
+
|
|
126
|
+
def all(self) -> dict[str, dict[str, Any]]:
|
|
127
|
+
rows = self._load().get(self.expand, {}) or {}
|
|
128
|
+
return {k: dict[str, Any](v) for k, v in rows.items() if isinstance(v, dict)}
|
|
129
|
+
|
|
130
|
+
def fingerprint(self) -> str | None:
|
|
131
|
+
return fingerprint(self.path)
|
|
132
|
+
|
|
133
|
+
# ---- 写 ----
|
|
134
|
+
|
|
135
|
+
def put(self, key: str, value: dict[str, Any]) -> None:
|
|
136
|
+
"""改一条,其余原样保留。"""
|
|
137
|
+
data = self._load()
|
|
138
|
+
rows = data.setdefault(self.expand, {})
|
|
139
|
+
if not isinstance(rows, dict):
|
|
140
|
+
rows = {}
|
|
141
|
+
data[self.expand] = rows
|
|
142
|
+
rows[key] = dict(value)
|
|
143
|
+
self._flush(data)
|
|
144
|
+
|
|
145
|
+
def drop(self, key: str) -> None:
|
|
146
|
+
"""删一条;本来就没有就什么都不做(不改变文件)。"""
|
|
147
|
+
data = self._load()
|
|
148
|
+
rows = data.get(self.expand, {})
|
|
149
|
+
if not isinstance(rows, dict) or key not in rows:
|
|
150
|
+
return
|
|
151
|
+
del rows[key]
|
|
152
|
+
self._flush(data)
|
|
153
|
+
|
|
154
|
+
def _flush(self, data: dict[str, Any]) -> None:
|
|
155
|
+
write_ledger_json(self.path, data, expand=self.expand)
|
|
156
|
+
self._data = data
|
|
157
|
+
|
|
158
|
+
def ensure_file(self) -> bool:
|
|
159
|
+
"""文件不存在就按 `default` 写一份;存在就一个字节都不动。返回「这次建了没有」。"""
|
|
160
|
+
if self.path.exists():
|
|
161
|
+
return False
|
|
162
|
+
data = dict(self._default)
|
|
163
|
+
data.setdefault("version", FORMAT_VERSION)
|
|
164
|
+
write_ledger_json(self.path, data, expand=self.expand)
|
|
165
|
+
self._data = data
|
|
166
|
+
return True
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""文件锁:只负责定义,**调用权归 actions/atom.py**(数据层禁止调用)。
|
|
2
|
+
|
|
3
|
+
锁的不是目标文件本身:按绝对路径 hash 在系统临时目录开 `<hash>.lock` 再 flock。
|
|
4
|
+
这样目标文件还不存在也能锁(锁集里本来就允许"还不存在"的路径),仓库里也不多出锁文件。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..core.paths import to_repo_rel
|
|
17
|
+
|
|
18
|
+
try: # pragma: no cover —— 平台差异
|
|
19
|
+
import fcntl
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
fcntl = None # type: ignore[assignment]
|
|
22
|
+
try: # pragma: no cover
|
|
23
|
+
import msvcrt
|
|
24
|
+
except ImportError: # pragma: no cover
|
|
25
|
+
msvcrt = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextlib.contextmanager
|
|
29
|
+
def progress_lock(path: Path) -> Iterator[None]:
|
|
30
|
+
"""给一个台账文件加互斥锁:阻塞等待、没有超时、**不可重入**。"""
|
|
31
|
+
if fcntl is None and msvcrt is None:
|
|
32
|
+
yield
|
|
33
|
+
return
|
|
34
|
+
key = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
|
|
35
|
+
lock_path = Path(tempfile.gettempdir()) / f"koc-progress-{key}.lock"
|
|
36
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
|
|
37
|
+
try:
|
|
38
|
+
if fcntl is not None:
|
|
39
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
40
|
+
elif msvcrt is not None:
|
|
41
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # type: ignore[attr-defined]
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
try:
|
|
45
|
+
if fcntl is not None:
|
|
46
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
47
|
+
elif msvcrt is not None:
|
|
48
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
|
|
49
|
+
finally:
|
|
50
|
+
os.close(fd)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def ledger_lock(repo: Path, *rels: Path) -> Iterator[None]:
|
|
55
|
+
"""多个文件一起锁:去重 → 按路径排序 → 依次加(避免两人互相等死)。"""
|
|
56
|
+
# rels 相对 `ledger/`(与磁盘 / JSON 同一基准),换算只走 to_repo_rel
|
|
57
|
+
paths = sorted({(repo / to_repo_rel(rel)) for rel in rels}, key=lambda p: str(p))
|
|
58
|
+
if not paths:
|
|
59
|
+
yield
|
|
60
|
+
return
|
|
61
|
+
with contextlib.ExitStack() as stack:
|
|
62
|
+
for path in paths:
|
|
63
|
+
stack.enter_context(progress_lock(path))
|
|
64
|
+
yield
|