dsh-task-ledger 0.0.100 → 0.0.102
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 +2 -2
- package/dist/host/repositories/runner.d.ts +1 -1
- package/dist/host/repositories/runner.js +10 -7
- package/dist/host/skill/registration.d.ts +2 -1
- package/dist/host/skill/registration.js +4 -3
- package/package.json +1 -1
- package/skill/README.md +4 -2
- package/skill/SKILL.md +17 -14
- package/skill/assets/worklog.md +3 -3
- 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/state-model.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 -193
- 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 -130
- 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 -105
- 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 -73
- 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 -90
- 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 -437
- 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 -536
- 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 -180
- package/skill/scripts/progress.py +0 -20
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"""只读:查在办任务(池 + 分片,后读到的为准)。不拿锁、不跑自举、不做取最新。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import replace
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from ..core.identity import Identity
|
|
10
|
-
from ..core.labels import STATUSES
|
|
11
|
-
from ..core.paths import POOL_REL
|
|
12
|
-
from ..errors import Fail
|
|
13
|
-
from ..store.owners_index import OwnersIndex
|
|
14
|
-
from ..store.task_store import TaskStore
|
|
15
|
-
from .guard import in_scope, is_mine, owner_of, require_uuid, status_of
|
|
16
|
-
from .query import ListQuery
|
|
17
|
-
from .result import ListResult, TaskRow, rel
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
|
|
21
|
-
"""合并顺序定死:先池、再按索引键排序的各分片,同一任务号由分片那份胜出。"""
|
|
22
|
-
# `--owner` 只认 uuid,且**给了就必须生效**:和 --mine / --pool 同传直接报错,
|
|
23
|
-
# 不许出现「传了 uuid 却被范围参数静默吃掉」(--owner "" 也算给了,同样报错)。
|
|
24
|
-
if q.owner is not None:
|
|
25
|
-
if q.scope in ("mine", "pool"):
|
|
26
|
-
raise Fail("--owner 不能和 --mine / --pool 一起用:一次只按一种范围筛。")
|
|
27
|
-
q = replace(q, owner=require_uuid(q.owner, field="--owner"))
|
|
28
|
-
# `--status` 只认白名单:`--status blocked` 这种早该消失的值不许静默返回空表
|
|
29
|
-
unknown = [s for s in q.statuses if s not in STATUSES]
|
|
30
|
-
if unknown:
|
|
31
|
-
raise Fail(
|
|
32
|
-
f"--status 只认 {' / '.join(STATUSES)}(收到 {', '.join(unknown)})。"
|
|
33
|
-
)
|
|
34
|
-
rows: dict[str, dict[str, Any]] = {}
|
|
35
|
-
where: dict[str, Path] = {}
|
|
36
|
-
|
|
37
|
-
pool = TaskStore(repo, POOL_REL)
|
|
38
|
-
for tid, task in pool.all().items():
|
|
39
|
-
rows[tid] = task
|
|
40
|
-
where[tid] = pool.path
|
|
41
|
-
|
|
42
|
-
index = OwnersIndex(repo)
|
|
43
|
-
index.assert_present()
|
|
44
|
-
for key in sorted(index.shards()):
|
|
45
|
-
path = index.shards()[key]
|
|
46
|
-
store = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
|
|
47
|
-
for tid, task in store.all().items():
|
|
48
|
-
rows[tid] = task
|
|
49
|
-
where[tid] = store.path
|
|
50
|
-
|
|
51
|
-
name_keys = index.keys_of_name(q.owner_name) if q.owner_name else set()
|
|
52
|
-
kept = {
|
|
53
|
-
tid: t for tid, t in rows.items() if in_scope(t, q, me=me, name_keys=name_keys)
|
|
54
|
-
}
|
|
55
|
-
# 每行自带 `mine`(只比 uuid,零额外 IO):调用方不必自己拿 uuid 再比一次
|
|
56
|
-
for task in kept.values():
|
|
57
|
-
task["mine"] = is_mine(task, me)
|
|
58
|
-
table = tuple(
|
|
59
|
-
TaskRow(
|
|
60
|
-
tid=tid,
|
|
61
|
-
title=str(task.get("title") or ""),
|
|
62
|
-
status=status_of(task),
|
|
63
|
-
owner=task.get("owner_name") or owner_of(task),
|
|
64
|
-
created=task.get("created"),
|
|
65
|
-
started=task.get("started"),
|
|
66
|
-
category=task.get("category"),
|
|
67
|
-
tags=tuple(task.get("tags") or ()),
|
|
68
|
-
where=rel(repo, where[tid]),
|
|
69
|
-
)
|
|
70
|
-
for tid, task in sorted(
|
|
71
|
-
kept.items(), key=lambda kv: str(kv[1].get("created") or "")
|
|
72
|
-
)
|
|
73
|
-
)
|
|
74
|
-
return ListResult(rows=kept, table=table)
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""记进展:刷分片的「最后更新」,再往过程文档追加一行。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from ..core.identity import Identity
|
|
8
|
-
from ..core.paths import shard_rel
|
|
9
|
-
from ..core.time import now
|
|
10
|
-
from ..core.worklog_md import StatusLine, exec_line
|
|
11
|
-
from ..errors import Fail
|
|
12
|
-
from ..store.task_store import TaskStore
|
|
13
|
-
from ..store.worklog_store import WorklogStore
|
|
14
|
-
from .guard import require_owner
|
|
15
|
-
from .result import Result, rel
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
|
|
19
|
-
"""写序:刷 `updated` → 过程文档追加一行。
|
|
20
|
-
|
|
21
|
-
文档写不进(`append_exec` 回 False)⇒ **不算失败**:分片已经刷了就照常提交,
|
|
22
|
-
只留一条警告 —— 抛错让外壳回滚会变成「改了又不提交」,下一条命令被写前 guard 拦死。
|
|
23
|
-
"""
|
|
24
|
-
text = " ".join((note or "").split())
|
|
25
|
-
if not text:
|
|
26
|
-
raise Fail("--note 不能为空。")
|
|
27
|
-
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
28
|
-
task = mine.get(tid) # 锁内重读
|
|
29
|
-
if task is None:
|
|
30
|
-
raise Fail(f"任务 {tid} 不在我的分片里(只能给自己的任务记进展)。")
|
|
31
|
-
# 分片里有、行内 owner 却不是我 ⇒ 台账脏了(validate ⑤),先拦下来
|
|
32
|
-
require_owner(task, tid, me)
|
|
33
|
-
|
|
34
|
-
updated = dict(task)
|
|
35
|
-
updated["updated"] = now()
|
|
36
|
-
mine.put(tid, updated)
|
|
37
|
-
|
|
38
|
-
logs = WorklogStore(repo)
|
|
39
|
-
path = logs.find(tid, updated.get("worklog"))
|
|
40
|
-
if path is None:
|
|
41
|
-
return Result(
|
|
42
|
-
changed=("updated",),
|
|
43
|
-
where=rel(repo, mine.path),
|
|
44
|
-
message=f"{tid}:记进展",
|
|
45
|
-
notices=(
|
|
46
|
-
"提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
|
|
47
|
-
),
|
|
48
|
-
)
|
|
49
|
-
ok = logs.append_exec(
|
|
50
|
-
path,
|
|
51
|
-
exec_line(now(), me.stamp(), "log", text),
|
|
52
|
-
status=StatusLine("doing", me.name, me.uuid, now()),
|
|
53
|
-
)
|
|
54
|
-
if not ok:
|
|
55
|
-
return Result(
|
|
56
|
-
changed=("updated",),
|
|
57
|
-
where=rel(repo, mine.path),
|
|
58
|
-
message=f"{tid}:记进展",
|
|
59
|
-
notices=(
|
|
60
|
-
"警告:分片的最后更新已刷,过程文档补不上(文件不可写);已写的分片照常提交,文档那行要手动补。",
|
|
61
|
-
),
|
|
62
|
-
)
|
|
63
|
-
return Result(
|
|
64
|
-
changed=("updated",),
|
|
65
|
-
where=rel(repo, mine.path),
|
|
66
|
-
message=f"{tid}:记进展",
|
|
67
|
-
)
|
|
@@ -1,130 +0,0 @@
|
|
|
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 StatusLine, 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.uuid), 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} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)。\n"
|
|
70
|
-
" 处理:换一个 --id,别复用历史号。\n"
|
|
71
|
-
" 若上次 new 崩在中间(文档已写、台账没落),这篇就是它留下的:确认内容要保留就换号重建;"
|
|
72
|
-
"非要复用这一号,先把那篇文档挪走再重跑。"
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
doc_rel = worklog_rel(tid, created) # 别叫 rel:那是 result.rel 的函数名
|
|
76
|
-
path = repo / to_repo_rel(doc_rel)
|
|
77
|
-
logs = WorklogStore(repo)
|
|
78
|
-
before_doc = snapshot(path)
|
|
79
|
-
|
|
80
|
-
task = {
|
|
81
|
-
"title": clean_title,
|
|
82
|
-
"created": created,
|
|
83
|
-
# --self = 建并认领:直接归自己名下就必须是在做,不留 open 半成品
|
|
84
|
-
"status": "doing" if mine else "open",
|
|
85
|
-
"owner": me.uuid if mine else None,
|
|
86
|
-
"owner_name": me.name if mine else None,
|
|
87
|
-
"creator": me.uuid,
|
|
88
|
-
"creator_name": me.name,
|
|
89
|
-
"category": category,
|
|
90
|
-
"tags": list(tag_list),
|
|
91
|
-
"worklog": doc_rel,
|
|
92
|
-
"started": created if mine else None,
|
|
93
|
-
"updated": created,
|
|
94
|
-
"finished": None,
|
|
95
|
-
}
|
|
96
|
-
try:
|
|
97
|
-
ensure_dir(path.parent)
|
|
98
|
-
_ = logs.create(
|
|
99
|
-
tid=tid,
|
|
100
|
-
title=clean_title,
|
|
101
|
-
created=created,
|
|
102
|
-
creator=me.stamp(),
|
|
103
|
-
category=category,
|
|
104
|
-
tags=tag_list,
|
|
105
|
-
status=StatusLine(
|
|
106
|
-
"doing" if mine else "open",
|
|
107
|
-
me.name if mine else None,
|
|
108
|
-
me.uuid if mine else None,
|
|
109
|
-
created,
|
|
110
|
-
),
|
|
111
|
-
first_exec=exec_line(
|
|
112
|
-
created, me.stamp(), "new", "创建任务并认领" if mine else "创建任务"
|
|
113
|
-
),
|
|
114
|
-
)
|
|
115
|
-
target.put(tid, task)
|
|
116
|
-
except Exception:
|
|
117
|
-
# 同一个 atom 内收尾:台账那条 drop 掉,文档原本不存在 ⇒ restore(None) 删掉
|
|
118
|
-
target.drop(tid)
|
|
119
|
-
restore(path, before_doc)
|
|
120
|
-
raise
|
|
121
|
-
|
|
122
|
-
text = logs.read(path)
|
|
123
|
-
return Result(
|
|
124
|
-
changed=FIELDS,
|
|
125
|
-
where=rel(repo, target.path),
|
|
126
|
-
message=f"新建任务 {tid}:{clean_title}",
|
|
127
|
-
worklog=WorklogView(path=rel(repo, path), text=text, error=None),
|
|
128
|
-
payload={"tid": tid},
|
|
129
|
-
notices=(f"新建过程文档:{rel(repo, path)}",),
|
|
130
|
-
)
|
|
@@ -1,35 +0,0 @@
|
|
|
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" 时生效;**只收 uuid**
|
|
12
|
-
owner_name: str | None = None # 显示名模糊筛(子串、不分大小写);只筛不判定
|
|
13
|
-
statuses: tuple[str, ...] = ()
|
|
14
|
-
category: str | None = None
|
|
15
|
-
tag: str | None = None
|
|
16
|
-
since: str | None = None # UTC 串,闭区间
|
|
17
|
-
until: str | None = None # 同上
|
|
18
|
-
# `since` / `until` **一律比 `created`**(含边界,只给其一也行),不比 started / updated。
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass(frozen=True)
|
|
22
|
-
class HistoryQuery:
|
|
23
|
-
owner: str | None = None # **只收 uuid**
|
|
24
|
-
owner_name: str | None = None # 显示名模糊筛(子串、不分大小写)
|
|
25
|
-
mine: bool = False
|
|
26
|
-
category: str | None = None
|
|
27
|
-
tag: str | None = None
|
|
28
|
-
since: str | None = None # UTC 串,闭区间;比 finished(结案时间),不比 created
|
|
29
|
-
until: str | None = None # 同上
|
|
30
|
-
limit: int | None = None
|
|
31
|
-
months: int | None = None # 最近 N 个自然月;与 since/until 互斥
|
|
32
|
-
count_only: bool = False # 只要计数,不要 records
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
COUNT_CAP = 10_000 # --count-only 的封顶:超过就 capped=True
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
"""只读:查已结案(按结案时间过滤,倒序取最近)。不写任何字节、不依赖格式升级。
|
|
2
|
-
|
|
3
|
-
时间区间一律落在**结案时间** `finished` 上 —— 历史文件本来就按结案月归档
|
|
4
|
-
(`history/<结案年月>.md`),按创建时间过滤会漏掉「上月建、本月结」的记录。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from dataclasses import replace
|
|
10
|
-
from datetime import datetime, timedelta
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
from ..core import history_md
|
|
15
|
-
from ..core.identity import Identity, normalize
|
|
16
|
-
from ..core.paths import HISTORY_DIR, month_of, to_repo_rel
|
|
17
|
-
from ..core.time import now, time_key
|
|
18
|
-
from ..errors import Fail
|
|
19
|
-
from ..store.history_store import HistoryStore
|
|
20
|
-
from ..store.owners_index import OwnersIndex
|
|
21
|
-
from .guard import is_mine, require_uuid
|
|
22
|
-
from .query import COUNT_CAP, HistoryQuery
|
|
23
|
-
from .result import HistoryResult, rel
|
|
24
|
-
|
|
25
|
-
#: 一次最多读多少个历史文件(按月分文件;不区分连续 / 分散,只数总数)。
|
|
26
|
-
MAX_HISTORY_FILES = 13
|
|
27
|
-
#: 没给任何时间参数时,默认只看最近几个自然月。
|
|
28
|
-
DEFAULT_HISTORY_MONTHS = 3
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _recent_month_keys(count: int) -> list[str]:
|
|
32
|
-
"""最近 count 个自然月的月份键(含当月),按当前 UTC 推,从新到旧。"""
|
|
33
|
-
stamp = now()
|
|
34
|
-
year, month = int(stamp[0:4]), int(stamp[5:7])
|
|
35
|
-
keys: list[str] = []
|
|
36
|
-
for _ in range(max(count, 0)):
|
|
37
|
-
keys.append(f"{year:04d}{month:02d}")
|
|
38
|
-
month -= 1
|
|
39
|
-
if month == 0:
|
|
40
|
-
year, month = year - 1, 12
|
|
41
|
-
return keys
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _shift_month(ym: str, delta: int) -> str:
|
|
45
|
-
"""`YYYYMM` 偏移 delta 个月;越过年份边界自动进位 / 借位。"""
|
|
46
|
-
idx = int(ym[0:4]) * 12 + (int(ym[4:6]) - 1) + delta
|
|
47
|
-
return f"{idx // 12:04d}{idx % 12 + 1:02d}"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _month_keys_between(lo_ym: str, hi_ym: str) -> list[str]:
|
|
51
|
-
"""`[lo_ym, hi_ym]` 闭区间内的月份键(升序);两端给反了也能兜住。"""
|
|
52
|
-
if lo_ym > hi_ym:
|
|
53
|
-
lo_ym, hi_ym = hi_ym, lo_ym
|
|
54
|
-
keys: list[str] = []
|
|
55
|
-
cur = lo_ym
|
|
56
|
-
while cur <= hi_ym:
|
|
57
|
-
keys.append(cur)
|
|
58
|
-
cur = _shift_month(cur, 1)
|
|
59
|
-
return keys
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _month_end(ym: str) -> str:
|
|
63
|
-
"""`YYYYMM` 所在月的最后一秒(`YYYY-MM-DD HH:MM:SS`),用于把老平铺文件也框进区间。"""
|
|
64
|
-
year, month = int(ym[0:4]), int(ym[4:6])
|
|
65
|
-
nxt = datetime(year + (1 if month == 12 else 0), (month % 12) + 1, 1)
|
|
66
|
-
return (nxt - timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _target_month_keys(q: HistoryQuery) -> list[str]:
|
|
70
|
-
"""由时间参数算出「要读的月份键」;超过上限直接 Fail(不碰磁盘)。
|
|
71
|
-
|
|
72
|
-
- 无任何时间参数 ⇒ 最近 `DEFAULT_HISTORY_MONTHS` 个自然月;
|
|
73
|
-
- `--months N` ⇒ 最近 N 个自然月(与 `--since/--until` 互斥);
|
|
74
|
-
- `--since/--until` ⇒ 结案月区间(只给一端时,另一端按默认口径补齐)。
|
|
75
|
-
"""
|
|
76
|
-
if q.months is not None and (q.since or q.until):
|
|
77
|
-
raise Fail("--months 与 --since/--until 不能同时用(时间口径二选一)。")
|
|
78
|
-
if q.months is not None:
|
|
79
|
-
if q.months < 1:
|
|
80
|
-
raise Fail("--months 至少为 1。")
|
|
81
|
-
keys = _recent_month_keys(q.months)
|
|
82
|
-
elif q.since or q.until:
|
|
83
|
-
if q.since:
|
|
84
|
-
lo = month_of(q.since)
|
|
85
|
-
elif q.until:
|
|
86
|
-
# 只给 --until:往前补到默认窗口(与「默认近 3 月」一致)
|
|
87
|
-
lo = _shift_month(month_of(q.until), -(DEFAULT_HISTORY_MONTHS - 1))
|
|
88
|
-
else: # pragma: no cover - 上面已保证至少给了一个
|
|
89
|
-
raise Fail("--since/--until 至少要给一个。")
|
|
90
|
-
hi = month_of(q.until) if q.until else month_of(now())
|
|
91
|
-
if not lo or not hi:
|
|
92
|
-
raise Fail("--since/--until 时间格式不对,算不出月份。")
|
|
93
|
-
keys = _month_keys_between(lo, hi)
|
|
94
|
-
else:
|
|
95
|
-
keys = _recent_month_keys(DEFAULT_HISTORY_MONTHS)
|
|
96
|
-
# 上限卡的是「要读的文件总数」,不是月跨度:按月分文件,一个键一个文件;
|
|
97
|
-
# 连不连续、将来把多段时间拼在一起也都只数总数。
|
|
98
|
-
files = sorted(set(keys))
|
|
99
|
-
if len(files) > MAX_HISTORY_FILES:
|
|
100
|
-
raise Fail(
|
|
101
|
-
f"历史查询一次最多读 {MAX_HISTORY_FILES} 个历史文件(按月分文件,不区分连续/分散),"
|
|
102
|
-
f"本次要读 {len(files)} 个 ⇒ 缩小 --since/--until 或 --months。"
|
|
103
|
-
)
|
|
104
|
-
return files
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _candidate_paths(repo: Path, keys: list[str]) -> list[Path]:
|
|
108
|
-
"""按月键【直接拼】`history/<年月>.md`(不扫磁盘)。"""
|
|
109
|
-
return [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
113
|
-
"""`--owner` / `--mine` 一律按 **uuid 精确相等**;名字只能走 `--owner-name` 模糊筛。
|
|
114
|
-
|
|
115
|
-
`--since` / `--until` 比 `finished`(结案时间)。
|
|
116
|
-
"""
|
|
117
|
-
if q.owner is not None:
|
|
118
|
-
if q.mine:
|
|
119
|
-
raise Fail("--owner 不能和 --mine 一起用:一次只按一种范围筛。")
|
|
120
|
-
q = replace(q, owner=require_uuid(q.owner, field="--owner"))
|
|
121
|
-
keys = _target_month_keys(q)
|
|
122
|
-
store = HistoryStore(repo)
|
|
123
|
-
records: list[dict[str, Any]] = []
|
|
124
|
-
warnings: list[str] = []
|
|
125
|
-
for path in _candidate_paths(repo, keys):
|
|
126
|
-
if not path.is_file():
|
|
127
|
-
continue # 该月无记录 / 老平铺文件不存在 ⇒ 跳过,不报错
|
|
128
|
-
rows, warns = history_md.parse_history(store.read(path))
|
|
129
|
-
warnings.extend(f"{rel(repo, path)}:{w}" for w in warns)
|
|
130
|
-
for row in rows:
|
|
131
|
-
row = dict(row)
|
|
132
|
-
row["src"] = rel(repo, path)
|
|
133
|
-
row["created"] = time_key(row.get("created") or "") or row.get("created")
|
|
134
|
-
row["finished"] = time_key(row.get("finished") or "") or row.get("finished")
|
|
135
|
-
# 每行自带 `mine`(只比 uuid):调用方不必自己拿 uuid 再比一次
|
|
136
|
-
row["mine"] = is_mine(row, me)
|
|
137
|
-
records.append(row)
|
|
138
|
-
|
|
139
|
-
index = OwnersIndex(repo)
|
|
140
|
-
roster = index.all()
|
|
141
|
-
if q.mine:
|
|
142
|
-
wanted = {normalize(me.uuid)}
|
|
143
|
-
records = [r for r in records if normalize(r.get("owner") or "") in wanted]
|
|
144
|
-
elif q.owner:
|
|
145
|
-
key = normalize(q.owner)
|
|
146
|
-
records = [r for r in records if normalize(r.get("owner") or "") == key]
|
|
147
|
-
if q.owner_name:
|
|
148
|
-
# 名字已经落在历史行里 ⇒ 直接比它(子串、不分大小写)。落的是**当时**那个名字,
|
|
149
|
-
# 改名后的旧行仍留旧名 ⇒ 按新名筛不到旧行,这是快照语义,不是漏。
|
|
150
|
-
# 老行(升级前落的)没有名字列,退回按 uuid 片段比,不至于一条都筛不出来。
|
|
151
|
-
needle = q.owner_name.strip().lower()
|
|
152
|
-
records = [
|
|
153
|
-
r
|
|
154
|
-
for r in records
|
|
155
|
-
if needle in str(r.get("owner_name") or "").lower()
|
|
156
|
-
or needle in normalize(r.get("owner") or "")
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
# 名字**已经落盘**(`owner_name` / `creator_name` 两列)。这里只给没名字的**老行**
|
|
160
|
-
# 按名册兜底补一次 —— 补的是读时展示值,不回写文件。
|
|
161
|
-
for row in records:
|
|
162
|
-
for field, target in (("owner", "owner_name"), ("creator", "creator_name")):
|
|
163
|
-
if row.get(target):
|
|
164
|
-
continue
|
|
165
|
-
key = normalize(row.get(field) or "")
|
|
166
|
-
name = str((roster.get(key) or {}).get("name") or "")
|
|
167
|
-
if name:
|
|
168
|
-
row[target] = name
|
|
169
|
-
if q.category:
|
|
170
|
-
records = [r for r in records if (r.get("category") or "") == q.category]
|
|
171
|
-
if q.tag:
|
|
172
|
-
records = [r for r in records if q.tag in (r.get("tags") or ())]
|
|
173
|
-
|
|
174
|
-
# 有效结案区间:显式 --since/--until 优先(精确到秒),缺的一端用月份窗口补,
|
|
175
|
-
# 这样老平铺文件里的行也会被同一条规则框住,不会在月份查询里「漏网」。
|
|
176
|
-
lo_key, hi_key = min(keys), max(keys)
|
|
177
|
-
eff_since = q.since or f"{lo_key[0:4]}-{lo_key[4:6]}-01 00:00:00"
|
|
178
|
-
eff_until = q.until or _month_end(hi_key)
|
|
179
|
-
if eff_since > eff_until:
|
|
180
|
-
raise Fail(
|
|
181
|
-
f"时间区间反了:--since({eff_since})晚于 --until({eff_until})。\n"
|
|
182
|
-
" 处理:把两端换过来;只给一端就只给一端。"
|
|
183
|
-
)
|
|
184
|
-
records = [r for r in records if (r.get("finished") or "") >= eff_since]
|
|
185
|
-
records = [r for r in records if (r.get("finished") or "") <= eff_until]
|
|
186
|
-
|
|
187
|
-
records.sort(key=lambda r: str(r.get("finished") or ""), reverse=True)
|
|
188
|
-
if q.count_only and q.limit is not None:
|
|
189
|
-
raise Fail("--count-only 与 --limit 不能同时用:计数不截断,列表才截断。")
|
|
190
|
-
limit: int | None = None
|
|
191
|
-
if q.limit is not None:
|
|
192
|
-
limit = int(q.limit)
|
|
193
|
-
if limit < 1:
|
|
194
|
-
raise Fail(f"--limit 必须 ≥ 1(收到 {limit})。")
|
|
195
|
-
capped = False
|
|
196
|
-
if q.count_only and len(records) > COUNT_CAP:
|
|
197
|
-
capped = True
|
|
198
|
-
records = records[:COUNT_CAP]
|
|
199
|
-
elif limit is not None:
|
|
200
|
-
capped = len(records) > limit # 截断了要让调用方知道「还有更多」
|
|
201
|
-
records = records[:limit]
|
|
202
|
-
|
|
203
|
-
filters = {
|
|
204
|
-
"owner": q.owner,
|
|
205
|
-
"owner_name": q.owner_name,
|
|
206
|
-
"mine": q.mine,
|
|
207
|
-
"category": q.category,
|
|
208
|
-
"tag": q.tag,
|
|
209
|
-
"since": q.since,
|
|
210
|
-
"until": q.until,
|
|
211
|
-
"limit": q.limit,
|
|
212
|
-
"months": q.months,
|
|
213
|
-
}
|
|
214
|
-
return HistoryResult(
|
|
215
|
-
count=len(records),
|
|
216
|
-
filters=filters,
|
|
217
|
-
records=tuple(records),
|
|
218
|
-
warnings=tuple(warnings),
|
|
219
|
-
capped=capped,
|
|
220
|
-
)
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
"""动作名册:命令写法 → `ActionSpec`。只有 `shell/cli.py` 与 `shell/runner.py` 会 import 它。
|
|
2
|
-
|
|
3
|
-
**渲染函数只存名字**,不 import `shell/render.py` —— 否则 actions → shell 的依赖就反了;
|
|
4
|
-
由 runner 用 `getattr(render, spec.render)` 取到函数。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from collections.abc import Callable
|
|
10
|
-
from dataclasses import dataclass, field
|
|
11
|
-
|
|
12
|
-
from ..errors import Fail
|
|
13
|
-
from . import (
|
|
14
|
-
bootstrap,
|
|
15
|
-
claim,
|
|
16
|
-
close_task,
|
|
17
|
-
list_tasks,
|
|
18
|
-
log_progress,
|
|
19
|
-
new_task,
|
|
20
|
-
query_history,
|
|
21
|
-
set_task,
|
|
22
|
-
show_task,
|
|
23
|
-
validate,
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass(frozen=True)
|
|
28
|
-
class ActionSpec:
|
|
29
|
-
func: Callable # (repo, me, **params) -> Result | 视图对象
|
|
30
|
-
render: str # 渲染函数**名**;runner 用 getattr 取
|
|
31
|
-
readonly: bool = False
|
|
32
|
-
lock_action: str = "" # 交给 lock_scope 的动作名;默认同命令名
|
|
33
|
-
params: tuple[str, ...] = field(default=()) # 从参数里挑哪些键传给 func
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
ACTIONS: dict[str, ActionSpec] = {
|
|
37
|
-
"new": ActionSpec(
|
|
38
|
-
new_task.new_task,
|
|
39
|
-
"result",
|
|
40
|
-
params=("title", "tid", "created", "category", "tags", "mine"),
|
|
41
|
-
),
|
|
42
|
-
"claim": ActionSpec(claim.claim, "result", params=("tid",)),
|
|
43
|
-
"set": ActionSpec(
|
|
44
|
-
set_task.set_task,
|
|
45
|
-
"result",
|
|
46
|
-
params=("tid", "owner", "release", "category", "tags"),
|
|
47
|
-
lock_action="set",
|
|
48
|
-
),
|
|
49
|
-
"log": ActionSpec(log_progress.log_progress, "result", params=("tid", "note")),
|
|
50
|
-
"close": ActionSpec(
|
|
51
|
-
close_task.close_task,
|
|
52
|
-
"result",
|
|
53
|
-
params=("tid", "summary", "finished", "category", "tags"),
|
|
54
|
-
),
|
|
55
|
-
"list": ActionSpec(
|
|
56
|
-
list_tasks.list_tasks, "list_view", readonly=True, params=("q",)
|
|
57
|
-
),
|
|
58
|
-
"show": ActionSpec(
|
|
59
|
-
show_task.show_task, "show_view", readonly=True, params=("tid",)
|
|
60
|
-
),
|
|
61
|
-
"history": ActionSpec(
|
|
62
|
-
query_history.query_history, "history_view", readonly=True, params=("q",)
|
|
63
|
-
),
|
|
64
|
-
"validate": ActionSpec(
|
|
65
|
-
validate.validate, "validate_view", readonly=True, params=("deep",)
|
|
66
|
-
),
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
READONLY = tuple(name for name, spec in ACTIONS.items() if spec.readonly)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def spec_of(command: str) -> ActionSpec:
|
|
73
|
-
"""按命令写法取名册项;不在名册里就是用法错(业务错,退 1 人话)。"""
|
|
74
|
-
spec = ACTIONS.get(command)
|
|
75
|
-
if spec is None:
|
|
76
|
-
raise Fail(f"未知命令:{command}")
|
|
77
|
-
return spec
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def bootstrap_func():
|
|
81
|
-
"""自举不是名册里的命令(runner 单独调),这里只给它的入口,避免 shell 直接 import 动作。"""
|
|
82
|
-
return bootstrap.bootstrap
|