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,232 +0,0 @@
|
|
|
1
|
-
"""七步的执行顺序:唯一创建记账本、唯一决定退出码、唯一统一回滚的地方。
|
|
2
|
-
|
|
3
|
-
七步:绑记账本 → 取最新 → 自举 → 调动作 → 打印 → 提交 → 推送。
|
|
4
|
-
(仓库定位在更前面,见 `run` 开头。)
|
|
5
|
-
|
|
6
|
-
- `already_done` **不**跳过提交与推送(写没写只看记账本)。
|
|
7
|
-
- 记账本为空 ⇒ 提交与推送都跳过(没脏文件就没得提交)。
|
|
8
|
-
- **唯一不回滚的失败分支是 `BrokenPipeError`**:文件已经写完,照常提交推送。
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import argparse
|
|
14
|
-
import sys
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from typing import Any
|
|
17
|
-
|
|
18
|
-
from .. import notices
|
|
19
|
-
from ..actions import registry
|
|
20
|
-
from ..actions.atom import atom
|
|
21
|
-
from ..actions.guard import current_identity
|
|
22
|
-
from ..core.ids import make_id
|
|
23
|
-
from ..core.paths import to_repo_rel
|
|
24
|
-
from ..core.time import now, to_utc
|
|
25
|
-
from ..errors import (
|
|
26
|
-
BUSINESS,
|
|
27
|
-
INTERRUPTED,
|
|
28
|
-
OK,
|
|
29
|
-
UNEXPECTED,
|
|
30
|
-
Fail,
|
|
31
|
-
)
|
|
32
|
-
from ..store import dirty
|
|
33
|
-
from ..store.atomic import snapshot
|
|
34
|
-
from . import render
|
|
35
|
-
from .gitops import GitRepo
|
|
36
|
-
from .presync import presync_scope
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def run(command: str, args: argparse.Namespace) -> int:
|
|
40
|
-
"""跑一条命令,返回退出码。"""
|
|
41
|
-
log = dirty.WriteLog()
|
|
42
|
-
try:
|
|
43
|
-
with dirty.bound(log):
|
|
44
|
-
return _run(command, args, log)
|
|
45
|
-
except Fail as exc:
|
|
46
|
-
if log.commit_started:
|
|
47
|
-
# 提交阶段才失败:数据已落盘,回滚 = 吃掉刚写的台账(2026-10-24 修)
|
|
48
|
-
render.fail_message(exc)
|
|
49
|
-
return BUSINESS
|
|
50
|
-
log.rollback_presync()
|
|
51
|
-
log.discard_created()
|
|
52
|
-
render.fail_message(exc)
|
|
53
|
-
return BUSINESS
|
|
54
|
-
except BrokenPipeError:
|
|
55
|
-
# 唯一不回滚的分支:文件已写,按记账本照常提交推送
|
|
56
|
-
_finalize(command, args, log, broken=True)
|
|
57
|
-
return OK
|
|
58
|
-
except KeyboardInterrupt:
|
|
59
|
-
if log.commit_started:
|
|
60
|
-
return INTERRUPTED
|
|
61
|
-
log.rollback_presync()
|
|
62
|
-
log.discard_created()
|
|
63
|
-
return INTERRUPTED
|
|
64
|
-
except Exception as exc: # noqa: BLE001 —— 兜底:任何未预期异常都要留下可诊断信息
|
|
65
|
-
if log.commit_started:
|
|
66
|
-
render.fail_message(exc, title="未预期错误")
|
|
67
|
-
return UNEXPECTED
|
|
68
|
-
log.rollback_presync()
|
|
69
|
-
log.discard_created()
|
|
70
|
-
render.fail_message(exc, title="未预期错误")
|
|
71
|
-
return UNEXPECTED
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _run(command: str, args: argparse.Namespace, log: dirty.WriteLog) -> int:
|
|
75
|
-
spec = registry.spec_of(command)
|
|
76
|
-
repo = _repo_of(args)
|
|
77
|
-
me = current_identity(repo)
|
|
78
|
-
no_sync = bool(getattr(args, "no_sync", False))
|
|
79
|
-
as_json = bool(getattr(args, "json", False))
|
|
80
|
-
|
|
81
|
-
git = GitRepo(repo)
|
|
82
|
-
syncing = not no_sync
|
|
83
|
-
if syncing:
|
|
84
|
-
# 中间态是**全局前提**,必须早于下面任何读台账的动作(`_params` 的 `new` 快照、
|
|
85
|
-
# `presync_scope` 的锁集都要读台账):冲突标记下的台账不是合法 JSON,读它就抛
|
|
86
|
-
# 「未预期错误」(退出码 3),而文档要的是「仓库处于中间态」这句人话。
|
|
87
|
-
git.check_repo_state()
|
|
88
|
-
|
|
89
|
-
params = _params(command, args, repo, me, spec)
|
|
90
|
-
|
|
91
|
-
# 只读:不建 GitRepo、不进事务、不提交
|
|
92
|
-
if spec.readonly:
|
|
93
|
-
value = spec.func(repo, me, **params)
|
|
94
|
-
# 先自报读的是哪个台账:`--repo` 指错目录时输出会变成一片「没有任务」,不带这一行
|
|
95
|
-
# 就完全看不出读错了地方。`--count-only` 也出 JSON,同样不打(2026-10-25 改)
|
|
96
|
-
render.repo_banner(
|
|
97
|
-
repo, machine=as_json or bool(getattr(args, "count_only", False))
|
|
98
|
-
)
|
|
99
|
-
_render(spec, value, args, as_json=as_json)
|
|
100
|
-
if getattr(value, "problems", ()):
|
|
101
|
-
return BUSINESS # validate 有 problems ⇒ 退出码 1(§六)
|
|
102
|
-
return OK
|
|
103
|
-
|
|
104
|
-
boot_scope, act_scope = presync_scope(repo, me, spec.lock_action or command, params)
|
|
105
|
-
|
|
106
|
-
if syncing:
|
|
107
|
-
git.guard_before_write(boot_scope | act_scope)
|
|
108
|
-
for path, before in git.sync_down(boot_scope | act_scope):
|
|
109
|
-
log.record_presync(path, before)
|
|
110
|
-
|
|
111
|
-
# 自举会登记自己(改 owners.json、缺文件时建池与分片)。它碰过的文件按「命令前的字节」
|
|
112
|
-
# 记进记账本:成功时照常提交(登记必须进仓),失败时由 rollback_presync 一起还回去 ——
|
|
113
|
-
# 否则一条失败的命令会把台账留在「已写未提交」,下一条写命令被写前 guard 拦死
|
|
114
|
-
# (架构文档 §三.5 正是要避免这个)。登记是幂等的,还回去下次再登记,没有损失。
|
|
115
|
-
for rel in boot_scope.files:
|
|
116
|
-
log.record_presync(repo / to_repo_rel(rel), snapshot(repo / to_repo_rel(rel)))
|
|
117
|
-
with atom(repo, *boot_scope.files):
|
|
118
|
-
me = registry.bootstrap_func()(repo, me)
|
|
119
|
-
|
|
120
|
-
with atom(repo, *act_scope.files):
|
|
121
|
-
value = spec.func(repo, me, **params)
|
|
122
|
-
|
|
123
|
-
_render(spec, value, args, as_json=as_json)
|
|
124
|
-
_finalize(command, args, log, message=getattr(value, "message", None))
|
|
125
|
-
return OK
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def _finalize(
|
|
129
|
-
command: str,
|
|
130
|
-
args: argparse.Namespace,
|
|
131
|
-
log: dirty.WriteLog,
|
|
132
|
-
*,
|
|
133
|
-
message: str | None = None,
|
|
134
|
-
broken: bool = False,
|
|
135
|
-
) -> None:
|
|
136
|
-
"""提交 + 推送:要不要做只看记账本(与 `already_done` 无关,只有 `--no-sync` 才整段跳过)。"""
|
|
137
|
-
if broken:
|
|
138
|
-
try:
|
|
139
|
-
sys.stdout.close()
|
|
140
|
-
except Exception: # noqa: BLE001, S110 # 关 stdout 本就无信息可记
|
|
141
|
-
pass
|
|
142
|
-
if getattr(args, "no_sync", False):
|
|
143
|
-
if getattr(args, "no_sync", False) and log.has_writes():
|
|
144
|
-
# 以前这条路径一个字都不打,「已写未提交」就此静默腐烂
|
|
145
|
-
notices.note(
|
|
146
|
-
"提示:--no-sync 本次只写了本地文件、未签入 git(也未推送)。\n"
|
|
147
|
-
" 处理:确认后自行 git add ledger && git commit && git push;"
|
|
148
|
-
"留着不提交的话,下一条写命令会被「本地有未提交的台账改动」拦住。"
|
|
149
|
-
)
|
|
150
|
-
log.clear()
|
|
151
|
-
return
|
|
152
|
-
if not log.has_writes():
|
|
153
|
-
log.clear()
|
|
154
|
-
return
|
|
155
|
-
repo = _repo_of(args)
|
|
156
|
-
git = GitRepo(repo)
|
|
157
|
-
if not git.is_git_repo():
|
|
158
|
-
# 没有 git 也能用:台账照写,只不做同步(2026-10-24 用户拍板)。
|
|
159
|
-
# 以前这里直接 git add,非 git 目录下必失败 ⇒ 整条命令退 1,台账像没写。
|
|
160
|
-
notices.note(
|
|
161
|
-
"提示:当前目录不是 git 仓库,台账只写了本地文件(未提交、未推送)。\n"
|
|
162
|
-
" 处理:想纳入版本管理就 git init;否则继续本地写也行 —— "
|
|
163
|
-
"多人各写各的,后写覆盖先写。"
|
|
164
|
-
)
|
|
165
|
-
log.clear()
|
|
166
|
-
return
|
|
167
|
-
log.commit_started = True
|
|
168
|
-
message = message or f"chore(ledger): {command}"
|
|
169
|
-
git.commit(log.commit_set(), message)
|
|
170
|
-
git.push()
|
|
171
|
-
log.clear()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _render(spec: registry.ActionSpec, value, args, *, as_json: bool) -> None:
|
|
175
|
-
func = getattr(render, spec.render)
|
|
176
|
-
if spec.render in ("list_view", "history_view"):
|
|
177
|
-
func(value, getattr(args, "q", None), as_json=as_json)
|
|
178
|
-
else:
|
|
179
|
-
func(value, as_json=as_json)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _params(
|
|
183
|
-
command: str, args: argparse.Namespace, repo: Path, me, spec: registry.ActionSpec
|
|
184
|
-
) -> dict[str, Any]:
|
|
185
|
-
"""按 `spec.params` 从 args 里挑;**不许把整个 args 透传进业务层**。"""
|
|
186
|
-
out: dict[str, Any] = {}
|
|
187
|
-
for key in spec.params:
|
|
188
|
-
out[key] = getattr(args, key, None)
|
|
189
|
-
if command == "new":
|
|
190
|
-
created = out.get("created") or now()
|
|
191
|
-
if getattr(args, "created", None):
|
|
192
|
-
created = to_utc(args.created, field="--created")
|
|
193
|
-
out["created"] = created
|
|
194
|
-
if not out.get("tid"):
|
|
195
|
-
out["tid"] = make_id(
|
|
196
|
-
out.get("title") or "", _task_snapshot(repo, me), created
|
|
197
|
-
)
|
|
198
|
-
out["tags"] = tuple(out.get("tags") or ())
|
|
199
|
-
if command == "close":
|
|
200
|
-
out["finished"] = (
|
|
201
|
-
to_utc(args.finished, field="--finished")
|
|
202
|
-
if getattr(args, "finished", None)
|
|
203
|
-
else now()
|
|
204
|
-
)
|
|
205
|
-
out["tags"] = tuple(out.get("tags") or ())
|
|
206
|
-
return out
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _task_snapshot(repo: Path, me) -> dict[str, Any]:
|
|
210
|
-
"""建任务查重要用的在办任务快照(**锁外**只读,只用于生成不撞的号)。"""
|
|
211
|
-
from ..core.paths import POOL_REL, shard_rel
|
|
212
|
-
from ..store.task_store import TaskStore
|
|
213
|
-
|
|
214
|
-
tasks: dict[str, Any] = {}
|
|
215
|
-
tasks.update(TaskStore(repo, POOL_REL).all())
|
|
216
|
-
tasks.update(TaskStore(repo, shard_rel(me.uuid)).all())
|
|
217
|
-
return tasks
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _repo_of(args: argparse.Namespace) -> Path:
|
|
221
|
-
"""工作区根:`cli.main` 已经解析好挂在 `args.repo` 上(定位只做一次)。
|
|
222
|
-
|
|
223
|
-
直接调 runner(测试 / 内嵌调用)时没挂过 ⇒ 现解一次,规则与 cli 一致。
|
|
224
|
-
"""
|
|
225
|
-
repo = getattr(args, "repo", None)
|
|
226
|
-
if repo:
|
|
227
|
-
from ..core.roots import as_root # 门 2 兜底:绕过 cli 直调 runner 也不收错位根
|
|
228
|
-
|
|
229
|
-
return as_root(repo, source="--repo")
|
|
230
|
-
from .cli import resolve_repo # 延迟导入:cli 在模块层 import runner
|
|
231
|
-
|
|
232
|
-
return resolve_repo(None)
|
|
File without changes
|
|
@@ -1,130 +0,0 @@
|
|
|
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)
|
|
@@ -1,108 +0,0 @@
|
|
|
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
|
|
@@ -1,134 +0,0 @@
|
|
|
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, to_repo_rel
|
|
13
|
-
from .atomic import write_text_atomic
|
|
14
|
-
|
|
15
|
-
HIT = "hit"
|
|
16
|
-
MISS = "miss"
|
|
17
|
-
UNDECIDABLE = "undecidable"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class HistoryStore:
|
|
21
|
-
def __init__(self, repo: Path):
|
|
22
|
-
self.repo = Path(repo)
|
|
23
|
-
|
|
24
|
-
# ---- 读 ----
|
|
25
|
-
|
|
26
|
-
def sources(self) -> list[Path]:
|
|
27
|
-
"""按月的 `history/*.md`(按文件名排序)。"""
|
|
28
|
-
base = self.repo / to_repo_rel(HISTORY_DIR)
|
|
29
|
-
return sorted(base.glob("*.md")) if base.is_dir() else []
|
|
30
|
-
|
|
31
|
-
def month_key(self, path: Path) -> str:
|
|
32
|
-
"""按月文件的 `YYYYMM`。"""
|
|
33
|
-
stem = Path(path).stem
|
|
34
|
-
return stem if len(stem) == 6 and stem.isdigit() else ""
|
|
35
|
-
|
|
36
|
-
def read(self, path: Path) -> str:
|
|
37
|
-
try:
|
|
38
|
-
return path.read_text(encoding="utf-8-sig")
|
|
39
|
-
except FileNotFoundError:
|
|
40
|
-
return ""
|
|
41
|
-
except OSError as exc:
|
|
42
|
-
raise ValueError(f"读 {path} 失败:{exc}") from exc
|
|
43
|
-
|
|
44
|
-
# ---- 判重 ----
|
|
45
|
-
|
|
46
|
-
def has_log(
|
|
47
|
-
self,
|
|
48
|
-
path: Path,
|
|
49
|
-
*,
|
|
50
|
-
log_name: str | None,
|
|
51
|
-
created: str | None,
|
|
52
|
-
title: str | None,
|
|
53
|
-
) -> str:
|
|
54
|
-
"""这个月的历史里已经有这条了吗?三态:`hit` / `miss` / `undecidable`。
|
|
55
|
-
|
|
56
|
-
有末列就比**文件名**(不比整条路径)。末列为空的行改按「创建时间 + 标题」两列比
|
|
57
|
-
—— 那种行在调用方给不出 `created` / `title` 时判不了,明说 `undecidable`,不拿 miss 冒充。
|
|
58
|
-
"""
|
|
59
|
-
records, _ = history_md.parse_history(self.read(path))
|
|
60
|
-
undecidable = False
|
|
61
|
-
for row in records:
|
|
62
|
-
worklog = (row.get("worklog") or "").strip()
|
|
63
|
-
if log_name and worklog:
|
|
64
|
-
if Path(worklog).name == Path(log_name).name:
|
|
65
|
-
return HIT
|
|
66
|
-
continue
|
|
67
|
-
if log_name is None and worklog:
|
|
68
|
-
# 历史行里有链接,但这次判不出对应的是哪篇文档(任务的过程文档丢了 / 被移走):
|
|
69
|
-
# 判不了就明说。拿 miss 冒充会再追加一行 ⇒ 同一条任务两条结案记录。
|
|
70
|
-
undecidable = True
|
|
71
|
-
continue
|
|
72
|
-
if not worklog:
|
|
73
|
-
if created and title:
|
|
74
|
-
if row.get("created") == created and row.get("title") == title:
|
|
75
|
-
return HIT
|
|
76
|
-
else:
|
|
77
|
-
undecidable = True
|
|
78
|
-
return UNDECIDABLE if undecidable else MISS
|
|
79
|
-
|
|
80
|
-
# ---- 算 ----
|
|
81
|
-
|
|
82
|
-
def append_row(self, path: Path, row: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
-
"""算出「追加这一行后的新全文」,**不落盘**。返回 `text` / `duplicate`。"""
|
|
84
|
-
original = self.read(path)
|
|
85
|
-
duplicate = (
|
|
86
|
-
self.has_log(
|
|
87
|
-
path,
|
|
88
|
-
log_name=_name_of(row.get("worklog")),
|
|
89
|
-
created=row.get("created"),
|
|
90
|
-
title=row.get("title"),
|
|
91
|
-
)
|
|
92
|
-
== HIT
|
|
93
|
-
)
|
|
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"),
|
|
107
|
-
)
|
|
108
|
-
return {"text": _append_line(text, line), "duplicate": duplicate}
|
|
109
|
-
|
|
110
|
-
# ---- 写 ----
|
|
111
|
-
|
|
112
|
-
def write(self, path: Path, text: str) -> None:
|
|
113
|
-
"""历史表唯一的写回口:只用于「结案追加」的结果落盘。"""
|
|
114
|
-
write_text_atomic(path, text)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _name_of(worklog: str | None) -> str | None:
|
|
118
|
-
return Path(worklog).name if worklog else None
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _fresh_table() -> str:
|
|
122
|
-
"""当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
|
|
123
|
-
return (
|
|
124
|
-
f"{history_md.HISTORY_TITLE}\n\n"
|
|
125
|
-
f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _append_line(text: str, line: str) -> str:
|
|
130
|
-
body = text.rstrip("\n")
|
|
131
|
-
if body and not body.splitlines()[-1].strip().startswith("|"):
|
|
132
|
-
# 表前有散文:补一个空行,别把表头贴上去
|
|
133
|
-
return body + "\n\n" + line
|
|
134
|
-
return body + "\n" + line
|