task-ledger 1.3.0 → 1.3.1
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 +27 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +140 -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 +173 -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 +43 -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 +135 -0
- package/scripts/ledgerkit/store/task_store.py +31 -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,140 @@
|
|
|
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, normalize
|
|
14
|
+
from ..core.labels import LEGACY_STATUSES
|
|
15
|
+
from ..core.paths import POOL_REL
|
|
16
|
+
from ..errors import Fail
|
|
17
|
+
from ..store.owners_index import OwnersIndex
|
|
18
|
+
from ..store.task_store import TaskStore
|
|
19
|
+
from .query import ListQuery
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def current_identity(repo: Path) -> Identity:
|
|
23
|
+
"""env 的本机身份 + `owners.json` 里这个身份的 `aliases`(不写盘、不登记)。"""
|
|
24
|
+
from .. import env
|
|
25
|
+
from ..core.identity import with_aliases
|
|
26
|
+
|
|
27
|
+
me = env.current(repo)
|
|
28
|
+
record = OwnersIndex(repo).get(me.email) or {}
|
|
29
|
+
aliases = tuple(record.get("aliases") or ())
|
|
30
|
+
return with_aliases(me, aliases) if aliases else me
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def require_task(tasks: Mapping[str, Any], tid: str, *, where: Path) -> dict[str, Any]:
|
|
34
|
+
"""取一条任务;缺失时给可读原因(带所在文件)。"""
|
|
35
|
+
task = tasks.get(tid)
|
|
36
|
+
if task is None:
|
|
37
|
+
raise Fail(f"任务 {tid} 不在 {_show(where)} 里。")
|
|
38
|
+
if not isinstance(task, dict):
|
|
39
|
+
raise Fail(f"任务 {tid} 在 {_show(where)} 里不是一条对象,台账可能坏了。")
|
|
40
|
+
return dict(task)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def require_owner(task: Mapping[str, Any], tid: str, me: Identity) -> None:
|
|
44
|
+
"""只有当前 owner 能改。"""
|
|
45
|
+
owner = owner_of(task)
|
|
46
|
+
if not owner:
|
|
47
|
+
raise Fail(f"任务 {tid} 还没有人认领(在公共池里),先 claim 再改。")
|
|
48
|
+
if not is_me(me, owner):
|
|
49
|
+
raise Fail(f"任务 {tid} 的负责人是 {owner},不是你({me.label()})。")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def same_task_ignoring_stamp(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
|
|
53
|
+
"""两份内容是不是只差 `updated`(交接重跑时以对方那份为准的判据)。"""
|
|
54
|
+
left = {k: v for k, v in a.items() if k not in ("updated", "updated_by")}
|
|
55
|
+
right = {k: v for k, v in b.items() if k not in ("updated", "updated_by")}
|
|
56
|
+
return left == right
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def owner_of(task: Mapping[str, Any]) -> str | None:
|
|
60
|
+
"""读 `owner` 的唯一入口。"""
|
|
61
|
+
value = task.get("owner")
|
|
62
|
+
return normalize(value) if isinstance(value, str) and value.strip() else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def status_of(task: Mapping[str, Any]) -> str:
|
|
66
|
+
"""读 `status` 的唯一入口;老数据 `blocked` 当进行中。"""
|
|
67
|
+
value = task.get("status")
|
|
68
|
+
if not isinstance(value, str) or not value.strip():
|
|
69
|
+
return "open"
|
|
70
|
+
value = value.strip().lower()
|
|
71
|
+
return "doing" if value in LEGACY_STATUSES else value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_mine(task: Mapping[str, Any], me: Identity) -> bool:
|
|
75
|
+
"""这条任务是不是我的(含 aliases)。"""
|
|
76
|
+
return is_me(me, owner_of(task) or "")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def in_scope(task: Mapping[str, Any], q: ListQuery, *, me: Identity) -> bool:
|
|
80
|
+
"""`list` 的过滤谓词:范围、状态、分类、标签、创建时间闭区间。"""
|
|
81
|
+
if q.scope == "mine" and not is_mine(task, me):
|
|
82
|
+
return False
|
|
83
|
+
if q.scope == "pool" and owner_of(task):
|
|
84
|
+
return False
|
|
85
|
+
if q.scope == "owner" and q.owner and normalize(q.owner) != owner_of(task):
|
|
86
|
+
return False
|
|
87
|
+
if q.statuses:
|
|
88
|
+
status = status_of(task)
|
|
89
|
+
wanted = {s for s in q.statuses}
|
|
90
|
+
if status not in wanted and not (
|
|
91
|
+
"doing" in wanted and status in LEGACY_STATUSES
|
|
92
|
+
):
|
|
93
|
+
return False
|
|
94
|
+
if q.category and (task.get("category") or "") != q.category:
|
|
95
|
+
return False
|
|
96
|
+
if q.tag and q.tag not in (task.get("tags") or ()):
|
|
97
|
+
return False
|
|
98
|
+
created = task.get("created")
|
|
99
|
+
if q.since and isinstance(created, str) and created[:19] < q.since[:19]:
|
|
100
|
+
return False
|
|
101
|
+
return not (q.until and isinstance(created, str) and created[:19] > q.until[:19])
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _show(path: Path) -> str:
|
|
105
|
+
try:
|
|
106
|
+
return str(Path(path).name)
|
|
107
|
+
except Exception: # noqa: BLE001
|
|
108
|
+
return str(path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_task(repo: Path, tid: str) -> tuple[dict[str, Any], list[Path]]:
|
|
112
|
+
"""在池 + 索引登记的全部分片里找这条任务(只读),返回 (任务, 出现过的文件)。"""
|
|
113
|
+
index = OwnersIndex(repo)
|
|
114
|
+
index.assert_present()
|
|
115
|
+
found: dict[str, Any] | None = None
|
|
116
|
+
where: list[Path] = []
|
|
117
|
+
pool = TaskStore(repo, POOL_REL)
|
|
118
|
+
if pool.get(tid):
|
|
119
|
+
found = pool.get(tid)
|
|
120
|
+
where.append(pool.path)
|
|
121
|
+
for rel in _shard_rels(repo, index):
|
|
122
|
+
store = TaskStore(repo, rel)
|
|
123
|
+
task = store.get(tid)
|
|
124
|
+
if task:
|
|
125
|
+
where.append(store.path)
|
|
126
|
+
if found is None:
|
|
127
|
+
found = task
|
|
128
|
+
if found is None:
|
|
129
|
+
raise Fail(f"找不到任务 {tid}(池与已登记的分片里都没有)。")
|
|
130
|
+
return found, where
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _shard_rels(repo: Path, index: OwnersIndex) -> list[Path]:
|
|
134
|
+
out: list[Path] = []
|
|
135
|
+
for path in index.shards().values():
|
|
136
|
+
try:
|
|
137
|
+
out.append(path.relative_to(repo / "ledger"))
|
|
138
|
+
except ValueError: # pragma: no cover
|
|
139
|
+
continue
|
|
140
|
+
return out
|
|
@@ -0,0 +1,74 @@
|
|
|
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 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 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.email), identity=me)
|
|
28
|
+
task = mine.get(tid) # 锁内重读
|
|
29
|
+
if task is None:
|
|
30
|
+
raise Fail(f"任务 {tid} 不在我的分片里,交接不了。")
|
|
31
|
+
|
|
32
|
+
to_key = normalize(to)
|
|
33
|
+
to_store = TaskStore(repo, shard_rel(to_key))
|
|
34
|
+
moved = dict(task)
|
|
35
|
+
moved["owner"] = to_key
|
|
36
|
+
moved["updated"] = now()
|
|
37
|
+
|
|
38
|
+
existing = to_store.get(tid)
|
|
39
|
+
if existing is not None:
|
|
40
|
+
if not same_task_ignoring_stamp(existing, moved):
|
|
41
|
+
raise Fail(
|
|
42
|
+
f"任务 {tid} 在 {to_key} 的分片里已经存在,且内容与本次不一致 ⇒ 拒绝交接。"
|
|
43
|
+
)
|
|
44
|
+
# 重跑:以对方那份为准,只补删我这边
|
|
45
|
+
mine.drop(tid)
|
|
46
|
+
logs = WorklogStore(repo)
|
|
47
|
+
path = logs.find(tid, moved.get("worklog"))
|
|
48
|
+
if path is not None:
|
|
49
|
+
logs.append_exec(
|
|
50
|
+
path, exec_line(now(), me.email, "set", f"交接给 {to_key}(补齐)")
|
|
51
|
+
)
|
|
52
|
+
return Result(
|
|
53
|
+
already_done=True,
|
|
54
|
+
where=rel(repo, to_store.path),
|
|
55
|
+
message=f"交接 {tid} → {to_key}:补齐上次未完成的清理",
|
|
56
|
+
notices=(f"任务 {tid} 已在对方分片里(内容一致),本次只补删我分片那行。",),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
to_store.put(tid, moved)
|
|
60
|
+
mine.drop(tid)
|
|
61
|
+
|
|
62
|
+
logs = WorklogStore(repo)
|
|
63
|
+
path = logs.find(tid, moved.get("worklog"))
|
|
64
|
+
note = None
|
|
65
|
+
if path is None:
|
|
66
|
+
note = f"任务 {tid} 已交给 {to_key},但过程文档没找到,跳过执行记录。"
|
|
67
|
+
else:
|
|
68
|
+
logs.append_exec(path, exec_line(now(), me.email, "set", f"交接给 {to_key}"))
|
|
69
|
+
return Result(
|
|
70
|
+
changed=("owner", "updated"),
|
|
71
|
+
where=rel(repo, to_store.path),
|
|
72
|
+
message=f"交接 {tid} → {to_key}",
|
|
73
|
+
notices=(note,) if note else (),
|
|
74
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""只读:查在办任务(池 + 分片,后读到的为准)。不拿锁、不跑自举、不做取最新。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..core.identity import Identity
|
|
9
|
+
from ..core.paths import POOL_REL
|
|
10
|
+
from ..store.owners_index import OwnersIndex
|
|
11
|
+
from ..store.task_store import TaskStore
|
|
12
|
+
from .guard import in_scope, owner_of, status_of
|
|
13
|
+
from .query import ListQuery
|
|
14
|
+
from .result import ListResult, TaskRow, rel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
|
|
18
|
+
"""合并顺序定死:先池、再按索引键排序的各分片,同一任务号由分片那份胜出。"""
|
|
19
|
+
rows: dict[str, dict[str, Any]] = {}
|
|
20
|
+
where: dict[str, Path] = {}
|
|
21
|
+
|
|
22
|
+
pool = TaskStore(repo, POOL_REL)
|
|
23
|
+
for tid, task in pool.all().items():
|
|
24
|
+
rows[tid] = task
|
|
25
|
+
where[tid] = pool.path
|
|
26
|
+
|
|
27
|
+
index = OwnersIndex(repo)
|
|
28
|
+
index.assert_present()
|
|
29
|
+
for key in sorted(index.shards()):
|
|
30
|
+
path = index.shards()[key]
|
|
31
|
+
store = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
|
|
32
|
+
for tid, task in store.all().items():
|
|
33
|
+
rows[tid] = task
|
|
34
|
+
where[tid] = store.path
|
|
35
|
+
|
|
36
|
+
kept = {tid: t for tid, t in rows.items() if in_scope(t, q, me=me)}
|
|
37
|
+
table = tuple(
|
|
38
|
+
TaskRow(
|
|
39
|
+
tid=tid,
|
|
40
|
+
title=str(task.get("title") or ""),
|
|
41
|
+
status=status_of(task),
|
|
42
|
+
owner=owner_of(task),
|
|
43
|
+
created=task.get("created"),
|
|
44
|
+
started=task.get("started"),
|
|
45
|
+
category=task.get("category"),
|
|
46
|
+
tags=tuple(task.get("tags") or ()),
|
|
47
|
+
where=rel(repo, where[tid]),
|
|
48
|
+
)
|
|
49
|
+
for tid, task in sorted(
|
|
50
|
+
kept.items(), key=lambda kv: str(kv[1].get("created") or "")
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return ListResult(rows=kept, table=table)
|
|
@@ -0,0 +1,60 @@
|
|
|
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 exec_line
|
|
11
|
+
from ..errors import Fail
|
|
12
|
+
from ..store.task_store import TaskStore
|
|
13
|
+
from ..store.worklog_store import WorklogStore
|
|
14
|
+
from .result import Result, rel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
|
|
18
|
+
"""写序:刷 `updated` → 过程文档追加一行。
|
|
19
|
+
|
|
20
|
+
文档写不进(`append_exec` 回 False)⇒ **不算失败**:分片已经刷了就照常提交,
|
|
21
|
+
只留一条警告 —— 抛错让外壳回滚会变成「改了又不提交」,下一条命令被写前 guard 拦死。
|
|
22
|
+
"""
|
|
23
|
+
text = " ".join((note or "").split())
|
|
24
|
+
if not text:
|
|
25
|
+
raise Fail("--note 不能为空。")
|
|
26
|
+
mine = TaskStore(repo, shard_rel(me.email), identity=me)
|
|
27
|
+
task = mine.get(tid) # 锁内重读
|
|
28
|
+
if task is None:
|
|
29
|
+
raise Fail(f"任务 {tid} 不在我的分片里(只能给自己的任务记进展)。")
|
|
30
|
+
|
|
31
|
+
updated = dict(task)
|
|
32
|
+
updated["updated"] = now()
|
|
33
|
+
mine.put(tid, updated)
|
|
34
|
+
|
|
35
|
+
logs = WorklogStore(repo)
|
|
36
|
+
path = logs.find(tid, updated.get("worklog"))
|
|
37
|
+
if path is None:
|
|
38
|
+
return Result(
|
|
39
|
+
changed=("updated",),
|
|
40
|
+
where=rel(repo, mine.path),
|
|
41
|
+
message=f"{tid}:记进展",
|
|
42
|
+
notices=(
|
|
43
|
+
"提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
ok = logs.append_exec(path, exec_line(now(), me.email, "log", text))
|
|
47
|
+
if not ok:
|
|
48
|
+
return Result(
|
|
49
|
+
changed=("updated",),
|
|
50
|
+
where=rel(repo, mine.path),
|
|
51
|
+
message=f"{tid}:记进展",
|
|
52
|
+
notices=(
|
|
53
|
+
"警告:分片的最后更新已刷,过程文档补不上(文件不可写);已写的分片照常提交,文档那行要手动补。",
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
return Result(
|
|
57
|
+
changed=("updated",),
|
|
58
|
+
where=rel(repo, mine.path),
|
|
59
|
+
message=f"{tid}:记进展",
|
|
60
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""建任务:拿外壳算好的任务号写过程文档、写进池(或直接写进自己的分片)。
|
|
2
|
+
|
|
3
|
+
`tid` / `created` 由外壳算好传进来(锁层要靠它们算出那篇文档的路径),
|
|
4
|
+
业务层不许现算现用;缺了就是外壳的 bug,直接 Fail。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..core.identity import Identity
|
|
12
|
+
from ..core.ids import require_task_id
|
|
13
|
+
from ..core.labels import normalize_label, parse_tags
|
|
14
|
+
from ..core.paths import (
|
|
15
|
+
POOL_REL,
|
|
16
|
+
shard_rel,
|
|
17
|
+
to_repo_rel,
|
|
18
|
+
worklog_rel,
|
|
19
|
+
)
|
|
20
|
+
from ..core.worklog_md import exec_line
|
|
21
|
+
from ..errors import Fail
|
|
22
|
+
from ..store.atomic import ensure_dir, restore, snapshot
|
|
23
|
+
from ..store.task_store import TaskStore
|
|
24
|
+
from ..store.worklog_store import WorklogStore
|
|
25
|
+
from .result import Result, WorklogView, rel
|
|
26
|
+
|
|
27
|
+
FIELDS = ("created", "title", "status", "owner", "creator", "worklog")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_task(
|
|
31
|
+
repo: Path,
|
|
32
|
+
me: Identity,
|
|
33
|
+
*,
|
|
34
|
+
title: str,
|
|
35
|
+
tid: str,
|
|
36
|
+
created: str,
|
|
37
|
+
category: str | None = None,
|
|
38
|
+
tags: tuple[str, ...] = (),
|
|
39
|
+
mine: bool = False,
|
|
40
|
+
) -> Result:
|
|
41
|
+
"""写序:`ensure_dir(当月文档目录)` → 过程文档(含首条记录)→ 台账。"""
|
|
42
|
+
if not (title or "").strip():
|
|
43
|
+
raise Fail("标题不能为空。")
|
|
44
|
+
if not tid:
|
|
45
|
+
raise Fail("建任务必须由外壳先算好任务号(内部错误:tid 为空)。")
|
|
46
|
+
if not created:
|
|
47
|
+
raise Fail("建任务必须由外壳先算好创建时间(内部错误:created 为空)。")
|
|
48
|
+
tid = require_task_id(tid)
|
|
49
|
+
clean_title = " ".join(title.strip().split())
|
|
50
|
+
category = normalize_label(category, field="分类") if category else None
|
|
51
|
+
tag_list = parse_tags(tags)
|
|
52
|
+
|
|
53
|
+
mine_store = TaskStore(repo, shard_rel(me.email), identity=me)
|
|
54
|
+
pool = TaskStore(repo, POOL_REL)
|
|
55
|
+
target = mine_store if mine else pool
|
|
56
|
+
|
|
57
|
+
# 查重:池 + 我自己的分片,**并且在锁内重读**(锁外的快照只配决定锁多宽)
|
|
58
|
+
if target.get(tid) is not None:
|
|
59
|
+
raise Fail(f"任务号 {tid} 已经存在了(这不是重跑,是重号)。")
|
|
60
|
+
other = pool if mine else mine_store
|
|
61
|
+
if other.get(tid) is not None:
|
|
62
|
+
raise Fail(f"任务号 {tid} 已经存在了(这不是重跑,是重号)。")
|
|
63
|
+
# 已结案的号也不能复用,且跨月同号互不干扰:直接看过程文档路径在不在。
|
|
64
|
+
# 在办任务的文档在磁盘上、已结案任务的文档也留在磁盘上 ⇒ 文档存在即这个号被占过
|
|
65
|
+
# (撞的是同一篇 worklog 文件,会覆盖 / 日后让 close 误判 already_done 变僵尸)。
|
|
66
|
+
# O(1),不必扫历史文件。上面分片 / 池是并发安全闸门,这里补文件级的精确判重。
|
|
67
|
+
if (repo / to_repo_rel(worklog_rel(tid, created))).exists():
|
|
68
|
+
raise Fail(
|
|
69
|
+
f"任务号 {tid} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)"
|
|
70
|
+
"⇒ 换一个 --id,别复用历史号。"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
doc_rel = worklog_rel(tid, created) # 别叫 rel:那是 result.rel 的函数名
|
|
74
|
+
path = repo / to_repo_rel(doc_rel)
|
|
75
|
+
logs = WorklogStore(repo)
|
|
76
|
+
before_doc = snapshot(path)
|
|
77
|
+
|
|
78
|
+
task = {
|
|
79
|
+
"title": clean_title,
|
|
80
|
+
"created": created,
|
|
81
|
+
# --self = 建并认领:直接归自己名下就必须是在做,不留 open 半成品
|
|
82
|
+
"status": "doing" if mine else "open",
|
|
83
|
+
"owner": me.email if mine else None,
|
|
84
|
+
"creator": me.email,
|
|
85
|
+
"category": category,
|
|
86
|
+
"tags": list(tag_list),
|
|
87
|
+
"worklog": doc_rel,
|
|
88
|
+
"started": created if mine else None,
|
|
89
|
+
"updated": created,
|
|
90
|
+
"finished": None,
|
|
91
|
+
}
|
|
92
|
+
try:
|
|
93
|
+
ensure_dir(path.parent)
|
|
94
|
+
_ = logs.create(
|
|
95
|
+
tid=tid,
|
|
96
|
+
title=clean_title,
|
|
97
|
+
created=created,
|
|
98
|
+
creator=me.email,
|
|
99
|
+
category=category,
|
|
100
|
+
tags=tag_list,
|
|
101
|
+
first_exec=exec_line(
|
|
102
|
+
created, me.email, "new", "创建任务并认领" if mine else "创建任务"
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
target.put(tid, task)
|
|
106
|
+
except Exception:
|
|
107
|
+
# 同一个 atom 内收尾:台账那条 drop 掉,文档原本不存在 ⇒ restore(None) 删掉
|
|
108
|
+
target.drop(tid)
|
|
109
|
+
restore(path, before_doc)
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
text = logs.read(path)
|
|
113
|
+
return Result(
|
|
114
|
+
changed=FIELDS,
|
|
115
|
+
where=rel(repo, target.path),
|
|
116
|
+
message=f"新建任务 {tid}:{clean_title}",
|
|
117
|
+
worklog=WorklogView(path=rel(repo, path), text=text, error=None),
|
|
118
|
+
payload={"tid": tid},
|
|
119
|
+
notices=(f"新建过程文档:{rel(repo, path)}",),
|
|
120
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""查询条件长什么样。外壳把命令行装成它,界面直接构造它 —— **动作层不认识 argparse**。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ListQuery:
|
|
10
|
+
scope: str = "all" # "all" / "pool" / "mine" / "owner"
|
|
11
|
+
owner: str | None = None # scope="owner" 时生效
|
|
12
|
+
statuses: tuple[str, ...] = ()
|
|
13
|
+
category: str | None = None
|
|
14
|
+
tag: str | None = None
|
|
15
|
+
since: str | None = None # UTC 串,闭区间
|
|
16
|
+
until: str | None = None # 同上
|
|
17
|
+
# `since` / `until` **一律比 `created`**(含边界,只给其一也行),不比 started / updated。
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class HistoryQuery:
|
|
22
|
+
owner: str | None = None
|
|
23
|
+
mine: bool = False
|
|
24
|
+
category: str | None = None
|
|
25
|
+
tag: str | None = None
|
|
26
|
+
since: str | None = None # UTC 串,闭区间;比 finished(结案时间),不比 created
|
|
27
|
+
until: str | None = None # 同上
|
|
28
|
+
limit: int | None = None
|
|
29
|
+
months: int | None = None # 最近 N 个自然月;与 since/until 互斥
|
|
30
|
+
count_only: bool = False # 只要计数,不要 records
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
COUNT_CAP = 10_000 # --count-only 的封顶:超过就 capped=True
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""只读:查已结案(按结案时间过滤,倒序取最近)。不写任何字节、不依赖格式升级。
|
|
2
|
+
|
|
3
|
+
时间区间一律落在**结案时间** `finished` 上 —— 历史文件本来就按结案月归档
|
|
4
|
+
(`history/<结案年月>.md`),按创建时间过滤会漏掉「上月建、本月结」的记录。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..core import history_md
|
|
14
|
+
from ..core.identity import Identity, normalize
|
|
15
|
+
from ..core.paths import HISTORY_DIR, LEGACY_HISTORY_REL, month_of, to_repo_rel
|
|
16
|
+
from ..core.time import now, time_key
|
|
17
|
+
from ..errors import Fail
|
|
18
|
+
from ..store.history_store import HistoryStore
|
|
19
|
+
from .query import COUNT_CAP, HistoryQuery
|
|
20
|
+
from .result import HistoryResult, rel
|
|
21
|
+
|
|
22
|
+
#: 一次最多读多少个历史文件(按月分文件;不区分连续 / 分散,只数总数)。
|
|
23
|
+
MAX_HISTORY_FILES = 13
|
|
24
|
+
#: 没给任何时间参数时,默认只看最近几个自然月。
|
|
25
|
+
DEFAULT_HISTORY_MONTHS = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _recent_month_keys(count: int) -> list[str]:
|
|
29
|
+
"""最近 count 个自然月的月份键(含当月),按当前 UTC 推,从新到旧。"""
|
|
30
|
+
stamp = now()
|
|
31
|
+
year, month = int(stamp[0:4]), int(stamp[5:7])
|
|
32
|
+
keys: list[str] = []
|
|
33
|
+
for _ in range(max(count, 0)):
|
|
34
|
+
keys.append(f"{year:04d}{month:02d}")
|
|
35
|
+
month -= 1
|
|
36
|
+
if month == 0:
|
|
37
|
+
year, month = year - 1, 12
|
|
38
|
+
return keys
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _shift_month(ym: str, delta: int) -> str:
|
|
42
|
+
"""`YYYYMM` 偏移 delta 个月;越过年份边界自动进位 / 借位。"""
|
|
43
|
+
idx = int(ym[0:4]) * 12 + (int(ym[4:6]) - 1) + delta
|
|
44
|
+
return f"{idx // 12:04d}{idx % 12 + 1:02d}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _month_keys_between(lo_ym: str, hi_ym: str) -> list[str]:
|
|
48
|
+
"""`[lo_ym, hi_ym]` 闭区间内的月份键(升序);两端给反了也能兜住。"""
|
|
49
|
+
if lo_ym > hi_ym:
|
|
50
|
+
lo_ym, hi_ym = hi_ym, lo_ym
|
|
51
|
+
keys: list[str] = []
|
|
52
|
+
cur = lo_ym
|
|
53
|
+
while cur <= hi_ym:
|
|
54
|
+
keys.append(cur)
|
|
55
|
+
cur = _shift_month(cur, 1)
|
|
56
|
+
return keys
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _month_end(ym: str) -> str:
|
|
60
|
+
"""`YYYYMM` 所在月的最后一秒(`YYYY-MM-DD HH:MM:SS`),用于把老平铺文件也框进区间。"""
|
|
61
|
+
year, month = int(ym[0:4]), int(ym[4:6])
|
|
62
|
+
nxt = datetime(year + (1 if month == 12 else 0), (month % 12) + 1, 1)
|
|
63
|
+
return (nxt - timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _target_month_keys(q: HistoryQuery) -> list[str]:
|
|
67
|
+
"""由时间参数算出「要读的月份键」;超过上限直接 Fail(不碰磁盘)。
|
|
68
|
+
|
|
69
|
+
- 无任何时间参数 ⇒ 最近 `DEFAULT_HISTORY_MONTHS` 个自然月;
|
|
70
|
+
- `--months N` ⇒ 最近 N 个自然月(与 `--since/--until` 互斥);
|
|
71
|
+
- `--since/--until` ⇒ 结案月区间(只给一端时,另一端按默认口径补齐)。
|
|
72
|
+
"""
|
|
73
|
+
if q.months is not None and (q.since or q.until):
|
|
74
|
+
raise Fail("--months 与 --since/--until 不能同时用(时间口径二选一)。")
|
|
75
|
+
if q.months is not None:
|
|
76
|
+
if q.months < 1:
|
|
77
|
+
raise Fail("--months 至少为 1。")
|
|
78
|
+
keys = _recent_month_keys(q.months)
|
|
79
|
+
elif q.since or q.until:
|
|
80
|
+
if q.since:
|
|
81
|
+
lo = month_of(q.since)
|
|
82
|
+
elif q.until:
|
|
83
|
+
# 只给 --until:往前补到默认窗口(与「默认近 3 月」一致)
|
|
84
|
+
lo = _shift_month(month_of(q.until), -(DEFAULT_HISTORY_MONTHS - 1))
|
|
85
|
+
else: # pragma: no cover - 上面已保证至少给了一个
|
|
86
|
+
raise Fail("--since/--until 至少要给一个。")
|
|
87
|
+
hi = month_of(q.until) if q.until else month_of(now())
|
|
88
|
+
if not lo or not hi:
|
|
89
|
+
raise Fail("--since/--until 时间格式不对,算不出月份。")
|
|
90
|
+
keys = _month_keys_between(lo, hi)
|
|
91
|
+
else:
|
|
92
|
+
keys = _recent_month_keys(DEFAULT_HISTORY_MONTHS)
|
|
93
|
+
# 上限卡的是「要读的文件总数」,不是月跨度:按月分文件,一个键一个文件;
|
|
94
|
+
# 连不连续、将来把多段时间拼在一起也都只数总数。
|
|
95
|
+
files = sorted(set(keys))
|
|
96
|
+
if len(files) > MAX_HISTORY_FILES:
|
|
97
|
+
raise Fail(
|
|
98
|
+
f"历史查询一次最多读 {MAX_HISTORY_FILES} 个历史文件(按月分文件,不区分连续/分散),"
|
|
99
|
+
f"本次要读 {len(files)} 个 ⇒ 缩小 --since/--until 或 --months。"
|
|
100
|
+
)
|
|
101
|
+
return files
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _candidate_paths(repo: Path, keys: list[str]) -> list[Path]:
|
|
105
|
+
"""按月键【直接拼】`history/<年月>.md`(不扫磁盘);存量平铺文件按固定路径顺带一看。"""
|
|
106
|
+
paths = [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
|
|
107
|
+
paths.append(repo / to_repo_rel(LEGACY_HISTORY_REL))
|
|
108
|
+
return paths
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
112
|
+
"""`--owner` / `--mine` 一律**精确相等**(不做前缀 / 子串);`--since` / `--until` 比 `finished`(结案时间)。"""
|
|
113
|
+
keys = _target_month_keys(q)
|
|
114
|
+
store = HistoryStore(repo)
|
|
115
|
+
records: list[dict[str, Any]] = []
|
|
116
|
+
warnings: list[str] = []
|
|
117
|
+
for path in _candidate_paths(repo, keys):
|
|
118
|
+
if not path.is_file():
|
|
119
|
+
continue # 该月无记录 / 老平铺文件不存在 ⇒ 跳过,不报错
|
|
120
|
+
rows, warns = history_md.parse_history(store.read(path))
|
|
121
|
+
warnings.extend(f"{rel(repo, path)}:{w}" for w in warns)
|
|
122
|
+
for row in rows:
|
|
123
|
+
row = dict(row)
|
|
124
|
+
row["src"] = rel(repo, path)
|
|
125
|
+
row["created"] = time_key(row.get("created") or "") or row.get("created")
|
|
126
|
+
row["finished"] = time_key(row.get("finished") or "") or row.get("finished")
|
|
127
|
+
records.append(row)
|
|
128
|
+
|
|
129
|
+
if q.mine:
|
|
130
|
+
wanted = {k for k in (me.email, *me.aliases) if k}
|
|
131
|
+
wanted = {normalize(k) for k in wanted}
|
|
132
|
+
records = [r for r in records if normalize(r.get("owner") or "") in wanted]
|
|
133
|
+
elif q.owner:
|
|
134
|
+
key = normalize(q.owner)
|
|
135
|
+
records = [r for r in records if normalize(r.get("owner") or "") == key]
|
|
136
|
+
if q.category:
|
|
137
|
+
records = [r for r in records if (r.get("category") or "") == q.category]
|
|
138
|
+
if q.tag:
|
|
139
|
+
records = [r for r in records if q.tag in (r.get("tags") or ())]
|
|
140
|
+
|
|
141
|
+
# 有效结案区间:显式 --since/--until 优先(精确到秒),缺的一端用月份窗口补,
|
|
142
|
+
# 这样老平铺文件里的行也会被同一条规则框住,不会在月份查询里「漏网」。
|
|
143
|
+
lo_key, hi_key = min(keys), max(keys)
|
|
144
|
+
eff_since = q.since or f"{lo_key[0:4]}-{lo_key[4:6]}-01 00:00:00"
|
|
145
|
+
eff_until = q.until or _month_end(hi_key)
|
|
146
|
+
records = [r for r in records if (r.get("finished") or "") >= eff_since]
|
|
147
|
+
records = [r for r in records if (r.get("finished") or "") <= eff_until]
|
|
148
|
+
|
|
149
|
+
records.sort(key=lambda r: str(r.get("finished") or ""), reverse=True)
|
|
150
|
+
capped = False
|
|
151
|
+
if q.count_only and len(records) > COUNT_CAP:
|
|
152
|
+
capped = True
|
|
153
|
+
records = records[:COUNT_CAP]
|
|
154
|
+
elif q.limit:
|
|
155
|
+
records = records[: int(q.limit)]
|
|
156
|
+
|
|
157
|
+
filters = {
|
|
158
|
+
"owner": q.owner,
|
|
159
|
+
"mine": q.mine,
|
|
160
|
+
"category": q.category,
|
|
161
|
+
"tag": q.tag,
|
|
162
|
+
"since": q.since,
|
|
163
|
+
"until": q.until,
|
|
164
|
+
"limit": q.limit,
|
|
165
|
+
"months": q.months,
|
|
166
|
+
}
|
|
167
|
+
return HistoryResult(
|
|
168
|
+
count=len(records),
|
|
169
|
+
filters=filters,
|
|
170
|
+
records=tuple(records),
|
|
171
|
+
warnings=tuple(warnings),
|
|
172
|
+
capped=capped,
|
|
173
|
+
)
|