dsh-task-ledger 0.0.45 → 0.0.47

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.
Files changed (36) hide show
  1. package/dist/client.js +130 -46
  2. package/dist/client.js.map +1 -1
  3. package/dist/host/board.d.ts +13 -108
  4. package/dist/host/board.js +19 -285
  5. package/dist/host/errors.d.ts +42 -0
  6. package/dist/host/errors.js +55 -0
  7. package/dist/host/repositories/cache.d.ts +27 -0
  8. package/dist/host/repositories/cache.js +57 -0
  9. package/dist/host/repositories/identity.repository.d.ts +9 -0
  10. package/dist/host/repositories/identity.repository.js +20 -0
  11. package/dist/host/{ledger-read.d.ts → repositories/ledger.repository.d.ts} +10 -4
  12. package/dist/host/repositories/ledger.repository.js +85 -0
  13. package/dist/host/repositories/records.d.ts +5 -0
  14. package/dist/host/repositories/records.js +48 -0
  15. package/dist/host/{script.js → repositories/runner.js} +3 -2
  16. package/dist/host/routes.d.ts +17 -0
  17. package/dist/host/routes.js +215 -64
  18. package/dist/host/services/dates.d.ts +37 -0
  19. package/dist/host/services/dates.js +71 -0
  20. package/dist/host/services/history.service.d.ts +24 -0
  21. package/dist/host/services/history.service.js +61 -0
  22. package/dist/host/services/home.service.d.ts +13 -0
  23. package/dist/host/services/home.service.js +50 -0
  24. package/dist/host/services/session.service.d.ts +110 -0
  25. package/dist/host/services/session.service.js +145 -0
  26. package/dist/host/services/trend.service.d.ts +26 -0
  27. package/dist/host/services/trend.service.js +134 -0
  28. package/dist/host/{skill-registration.d.ts → skill/registration.d.ts} +2 -0
  29. package/dist/host/{skill-registration.js → skill/registration.js} +5 -2
  30. package/dist/host/types.d.ts +94 -0
  31. package/dist/host/workspace.d.ts +8 -0
  32. package/dist/host/workspace.js +6 -9
  33. package/dist/index.js +31 -2
  34. package/package.json +4 -3
  35. package/dist/host/ledger-read.js +0 -64
  36. /package/dist/host/{script.d.ts → repositories/runner.d.ts} +0 -0
@@ -1,115 +1,20 @@
1
- import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
2
- /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
3
- export declare const STALE_DAYS = 14;
4
- /** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
5
- export declare const WEEKLY_WEEKS = 8;
6
- /** 一个自然周期内的两个计数(核心永远是「完成」,新增只作对照)。 */
7
- export interface PeriodCounts {
8
- readonly done: number;
9
- readonly created: number;
10
- /** 周期起(YYYY-MM-DD,含)。 */
11
- readonly from: string;
12
- /** 周期止(YYYY-MM-DD,含)。 */
13
- readonly to: string;
14
- }
15
- /** 统计台四个自然周期(用户 2026-10-17 定稿:本周 / 上周 / 本月 / 上月,不用 7/30 天)。 */
16
- export interface TrendPeriods {
17
- readonly thisWeek: PeriodCounts;
18
- readonly lastWeek: PeriodCounts;
19
- readonly thisMonth: PeriodCounts;
20
- readonly lastMonth: PeriodCounts;
21
- }
22
- /** 趋势图一桶(一周):完成 + 新增 + 截止时仍未完成。 */
23
- export interface WeeklyBucket {
24
- /** 该周周一(YYYY-MM-DD)。 */
25
- readonly week: string;
26
- /** 该周截止日(YYYY-MM-DD):历史周=当周周日,当前周=今天——图表 x 轴标它,因为数据是按截止日判的。 */
27
- readonly end: string;
28
- readonly done: number;
29
- readonly created: number;
30
- /** 该周截止时仍未完成(在办)的任务数:cumulative(创建 - 完成) 到该周末,含窗口前遗留。 */
31
- readonly remaining: number;
32
- }
33
- /** 趋势图数据按范围各一份(切换零请求)。 */
34
- export interface ClosedSummary {
35
- readonly weekly: {
36
- readonly all: readonly WeeklyBucket[];
37
- readonly mine: readonly WeeklyBucket[];
38
- };
39
- }
40
- /** 首页统计卡的六个面(阈值 / 天数全在 host 算,前端只渲染——单一真源)。 */
41
- export interface BoardStats {
42
- /** 我的 doing + blocked。 */
43
- readonly myActive: number;
44
- /** 我的 blocked。 */
45
- readonly myBlocked: number;
46
- /** 待认领(open)。 */
47
- readonly open: number;
48
- /** 全部 doing。 */
49
- readonly doing: number;
50
- /** 全部 blocked。 */
51
- readonly blocked: number;
52
- /** 停滞 = doing|blocked 且 ageDays > STALE_DAYS。 */
53
- readonly stale: number;
54
- /** 停滞阈值(天)——随响应下发,界面文案不硬编码(单一真源)。 */
55
- readonly staleDays: number;
56
- /** 最老一条待认领躺了多少天(没有待认领 = null)。 */
57
- readonly oldestOpenDays: number | null;
58
- }
59
- /** 列表行附加工资字段(host 算,前端不判阈值)。 */
60
- export interface BoardTask extends TaskRow {
61
- /** 认领后未动天数(doing / blocked;open 与解析不了的时间为 null)。 */
62
- readonly ageDays: number | null;
63
- /** 停滞标记。 */
64
- readonly stale: boolean;
65
- }
66
- export interface BoardSnapshot {
67
- readonly at: string;
68
- readonly workspaces: readonly LedgerWorkspace[];
69
- /** 全部在办任务(UI 自己筛「我的 / 待认领」——筛选是前端的事)。 */
70
- readonly tasks: readonly BoardTask[];
71
- readonly stats: BoardStats;
72
- /**
73
- * 工作区 id → 该工作区的当前 git 身份(取不到为 null)。
74
- * 「我的待办」在前端按 `owner === identities[workspace.id]` 筛——身份只在 host 侧取得到
75
- * (cwd = 该工作区跑 `git config user.name`),UI 不摸 git。
76
- */
77
- readonly identities: Readonly<Record<string, string | null>>;
78
- }
79
- /** 首页一次 `/board` 的全部聚合(**每个工作区只读一次** list + history + git 身份)。 */
80
- export interface HomeAggregate {
81
- readonly snapshot: BoardSnapshot;
82
- readonly closed: ClosedSummary;
83
- readonly trend: TrendPeriods;
84
- }
85
1
  /**
86
- * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
87
- * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
88
- * 该工作区的「我的」统计按 0 计(不谎报)。
2
+ * 首页**组合入口**(`/board` 兼容全量端点用):一次读全(tasks + history + git 身份),
3
+ * 喂快 / 慢两条纯计算——**读一次算两样,不重复 IO**。
4
+ *
5
+ * 拆端点后(host-data-plane §五 A)首屏走 `services/home.service.ts::getSnapshot()`(不碰 history),
6
+ * 本组合归「兼容期全量 `/board`」;新端点在 routes 层直接调 service,不过本文件。
89
7
  */
90
- export declare function getCurrentIdentity(workspace: LedgerWorkspace): Promise<string | null>;
8
+ import { type DegradedInfo } from './errors.js';
9
+ import type { HomeAggregate, LedgerWorkspace } from './types.js';
91
10
  /**
92
11
  * 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 近 8 周趋势(完成 / 新增 × 所有 / 我的)
93
12
  * + 四个自然周期计数。**每个工作区只读一次**(list + history + git 身份),
94
13
  * 避免「快照读一遍、统计再读一遍」把 5 万行级工作区的 history 解析翻倍。
14
+ * 单工作区坏 ⇒ 进 degraded(其余照常);全坏 ⇒ 抛(路由转 500 + 人话)。
95
15
  */
96
- export declare function getHomeAggregate(workspaces: readonly LedgerWorkspace[]): Promise<HomeAggregate>;
97
- /** 只拿在办快照(薄封装:测试 / 其它调用方用;首页走 {@link getHomeAggregate} 一次读全)。 */
98
- export declare function getBoardSnapshot(workspaces: readonly LedgerWorkspace[]): Promise<BoardSnapshot>;
99
- /**
100
- * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
101
- * shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
102
- * 单工作区内 limit 已生效,合并边界的全局精确排序暂不保证(500 条一页无感)。
103
- * 要全量老数据:调用方显式传 months: undefined(不拼 --months,脚本读全部,按秒计)。
104
- */
105
- export declare function queryClosed(workspaces: readonly LedgerWorkspace[], filter?: LedgerFilter): Promise<HistoryRecord[]>;
106
- export interface FilterOptions {
107
- readonly owners: string[];
108
- readonly categories: string[];
109
- readonly tags: string[];
110
- }
111
- /**
112
- * 筛选项候选:在办(全量)+ 近 13 个月历史的去重值(owner / category / tag)。
113
- * 按需调用(/filters 路由),非首屏——历史侧在极端量级下是百毫秒级。
114
- */
115
- export declare function getFilterOptions(workspaces: readonly LedgerWorkspace[]): Promise<FilterOptions>;
16
+ export declare function getHomeAggregate(workspaces: readonly LedgerWorkspace[]): Promise<HomeAggregate & {
17
+ degraded: readonly DegradedInfo[];
18
+ }>;
19
+ export { getSnapshot as getBoardSnapshot } from './services/home.service.js';
20
+ export { getFilterOptions, queryClosed } from './services/history.service.js';
@@ -1,296 +1,30 @@
1
- import { spawn } from 'node:child_process';
2
- import { readHistory, readTasks } from './ledger-read.js';
3
- /** 历史窗口:只读最近 13 个月的文件(2026-10-17 拍板;脚本默认读全部,窗口由插件显式传)。 */
4
- const RECENT_MONTHS = 13;
5
- /** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
6
- const DEFAULT_HISTORY_LIMIT = 500;
7
- /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
8
- export const STALE_DAYS = 14;
9
- /** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
10
- export const WEEKLY_WEEKS = 8;
11
- /* ── 日期工具(本机时区;skill 脚本同口径:周一为一周之始)───────────────── */
12
- function pad(value) {
13
- return String(value).padStart(2, '0');
14
- }
15
- /** Date → `YYYY-MM-DD`。 */
16
- function ymd(date) {
17
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
18
- }
19
- /** 时刻串(`YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD`)→ 日期部分;解析不了回 null。 */
20
- function dayOf(stamp) {
21
- if (stamp === null || stamp === undefined || stamp === '')
22
- return null;
23
- const day = String(stamp).slice(0, 10);
24
- return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
25
- }
26
- /** 某日期所在周的周一(00:00)。 */
27
- function mondayOf(day) {
28
- const date = new Date(`${day}T00:00:00`);
29
- date.setDate(date.getDate() - ((date.getDay() + 6) % 7));
30
- return date;
31
- }
32
- /** 第 offset 周的起止(offset 0 = 本周,-1 = 上周;返回日期串,含边界)。
33
- * ⚠️ 本周只到今天(周还没结束),上周才是完整周一~周日——否则「本周」会把明天之后的
34
- * 日子也画进区间,tooltip 里的截止日撒谎。 */
35
- function weekWindow(offset, now) {
36
- const monday = mondayOf(ymd(now));
37
- monday.setDate(monday.getDate() + offset * 7);
38
- const sunday = new Date(monday);
39
- sunday.setDate(sunday.getDate() + 6);
40
- const last = offset === 0 ? now : sunday;
41
- return { from: ymd(monday), to: ymd(last < sunday ? last : sunday) };
42
- }
43
- /** 第 offset 月的起止(offset 0 = 本月,-1 = 上月;含边界)。
44
- * 同 {@link weekWindow}:本月只到今天,上月才是完整自然月。 */
45
- function monthWindow(offset, now) {
46
- const first = new Date(now.getFullYear(), now.getMonth() + offset, 1);
47
- const last = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0);
48
- const end = offset === 0 ? now : last;
49
- return { from: ymd(first), to: ymd(end < last ? end : last) };
50
- }
51
- /** 日期是否落在 [from, to](含边界,字符串比较即可——ISO 字典序即时间序)。 */
52
- function inWindow(day, window) {
53
- return day !== null && day >= window.from && day <= window.to;
54
- }
55
- /** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
56
- function daysSince(stamp, now) {
57
- if (stamp === null || stamp === undefined || stamp === '')
58
- return null;
59
- const parsed = new Date(String(stamp).replace(' ', 'T'));
60
- if (Number.isNaN(parsed.getTime()))
61
- return null;
62
- const days = Math.floor((now.getTime() - parsed.getTime()) / 86_400_000);
63
- return days > 0 ? days : 0;
64
- }
65
1
  /**
66
- * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
67
- * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
68
- * 该工作区的「我的」统计按 0 计(不谎报)。
2
+ * 首页**组合入口**(`/board` 兼容全量端点用):一次读全(tasks + history + git 身份),
3
+ * 喂快 / 慢两条纯计算——**读一次算两样,不重复 IO**。
4
+ *
5
+ * 拆端点后(host-data-plane §五 A)首屏走 `services/home.service.ts::getSnapshot()`(不碰 history),
6
+ * 本组合归「兼容期全量 `/board`」;新端点在 routes 层直接调 service,不过本文件。
69
7
  */
70
- export function getCurrentIdentity(workspace) {
71
- return new Promise((resolve) => {
72
- const child = spawn('git', ['config', 'user.name'], { cwd: workspace.path, stdio: ['ignore', 'pipe', 'ignore'] });
73
- let stdout = '';
74
- child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
75
- child.on('error', () => { resolve(null); });
76
- child.on('close', (code) => {
77
- const name = stdout.trim();
78
- resolve(code === 0 && name !== '' ? name : null);
79
- });
80
- });
81
- }
82
- /** 一个工作区读全(list + history + git 身份)——首页所有数字的共同原料。 */
83
- async function readWorkspace(workspace) {
84
- const [tasks, history, identity] = await Promise.all([
85
- readTasks(workspace),
86
- readHistory(workspace, { months: RECENT_MONTHS }),
87
- getCurrentIdentity(workspace),
88
- ]);
89
- return { workspace, tasks, history, identity };
90
- }
91
- /** 计数一批记录 / 任务(按范围过滤 owner;`mine` 时 identity 为 null 的一律不计)。 */
92
- function countPeriod(entries, window) {
93
- return entries.filter((entry) => inWindow(entry.day, window)).length;
94
- }
8
+ import { settleWorkspaces } from './errors.js';
9
+ import { computeSnapshot } from './services/home.service.js';
10
+ import { computeClosedSummary, computeTrendPeriods, readTrendScope } from './services/trend.service.js';
11
+ import { getFilterOptions, queryClosed } from './services/history.service.js';
95
12
  /**
96
13
  * 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 近 8 周趋势(完成 / 新增 × 所有 / 我的)
97
14
  * + 四个自然周期计数。**每个工作区只读一次**(list + history + git 身份),
98
15
  * 避免「快照读一遍、统计再读一遍」把 5 万行级工作区的 history 解析翻倍。
16
+ * 单工作区坏 ⇒ 进 degraded(其余照常);全坏 ⇒ 抛(路由转 500 + 人话)。
99
17
  */
100
18
  export async function getHomeAggregate(workspaces) {
101
19
  const now = new Date();
102
- const perWorkspace = await Promise.all(workspaces.map(readWorkspace));
103
- // ── 快照(在办路径,与历史量无关)────────────────────────────────────
104
- const withMeta = perWorkspace.flatMap((entry) => entry.tasks.map((task) => {
105
- const working = task.status === 'doing' || task.status === 'blocked';
106
- const ageDays = working ? daysSince(task.started ?? task.created, now) : null;
107
- return { ...task, ageDays, stale: ageDays !== null && ageDays > STALE_DAYS };
108
- }));
109
- const openTasks = withMeta.filter((task) => task.status === 'open');
110
- const blockedTasks = withMeta.filter((task) => task.status === 'blocked');
111
- const myActive = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && (task.status === 'doing' || task.status === 'blocked')).length, 0);
112
- const myBlocked = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && task.status === 'blocked').length, 0);
113
- const identities = {};
114
- for (const entry of perWorkspace)
115
- identities[entry.workspace.id] = entry.identity;
116
- const snapshot = {
117
- at: now.toISOString(),
118
- workspaces,
119
- tasks: withMeta,
120
- stats: {
121
- myActive,
122
- myBlocked,
123
- open: openTasks.length,
124
- doing: withMeta.filter((task) => task.status === 'doing').length,
125
- blocked: blockedTasks.length,
126
- stale: withMeta.filter((task) => task.stale).length,
127
- staleDays: STALE_DAYS,
128
- oldestOpenDays: openTasks.length === 0
129
- ? null
130
- : openTasks.reduce((max, task) => Math.max(max, daysSince(task.created, now) ?? 0), 0),
131
- },
132
- identities,
133
- };
134
- // ── 趋势:近 8 周(完成按结案时间、新增按创建时间)× 所有 / 我的 ────────
135
- // 新增必须**合并两个来源**:已结案的任务被 close 移出 progress.json,只在 history 里;
136
- // 在办的只在 progress.json 里。两者不相交(close 是移动),合并即全集、不会重复计。
137
- const weeks = [];
138
- for (let offset = WEEKLY_WEEKS - 1; offset >= 0; offset -= 1)
139
- weeks.push(weekWindow(-offset, now).from);
140
- // 每周截止日:历史周 = 当周周日;当前周(最后一项)只到今天(周没结束,否则截止日撒谎)
141
- const weekEnds = weeks.map((week, i) => {
142
- const monday = new Date(`${week}T00:00:00`);
143
- const sunday = new Date(monday);
144
- sunday.setDate(sunday.getDate() + 6);
145
- return i === weeks.length - 1 ? ymd(now) : ymd(sunday);
146
- });
147
- const bucketsAll = new Map(weeks.map((week) => [week, { done: 0, created: 0, remaining: 0 }]));
148
- const bucketsMine = new Map(weeks.map((week) => [week, { done: 0, created: 0, remaining: 0 }]));
149
- const bump = (map, week, field) => {
150
- const bucket = map.get(week);
151
- if (bucket !== undefined)
152
- map.set(week, { ...bucket, [field]: bucket[field] + 1 });
153
- };
154
- // remaining:某任务在「第 i 周截止时」仍未完成 ⇔ 创建 ≤ weekEnd_i 且(未结案 或 结案 > weekEnd_i)。
155
- // 窗口前创建、至今仍在办的任务会自然计入每一周 ⇒ 无需单独基线,口径自洽。
156
- const bumpRemaining = (map, createdDay, closeDay) => {
157
- if (createdDay === null)
158
- return;
159
- for (let i = 0; i < weeks.length; i++) {
160
- if (createdDay <= weekEnds[i] && (closeDay === null || closeDay > weekEnds[i])) {
161
- const bucket = map.get(weeks[i]);
162
- if (bucket !== undefined)
163
- bucket.remaining += 1;
164
- }
165
- }
166
- };
167
- for (const entry of perWorkspace) {
168
- const identity = entry.identity;
169
- for (const task of entry.tasks) {
170
- const created = dayOf(task.created);
171
- if (created === null)
172
- continue;
173
- const week = ymd(mondayOf(created));
174
- // 新增(我的)= 我创建的(creator);在办任务自带 creator 字段
175
- const createdMine = identity !== null && (task.creator ?? null) === identity;
176
- bump(bucketsAll, week, 'created');
177
- if (createdMine)
178
- bump(bucketsMine, week, 'created');
179
- // 未结(我的)= 我负责且未结案(owner)
180
- const ownedMine = identity !== null && (task.owner ?? null) === identity;
181
- bumpRemaining(bucketsAll, created, null);
182
- if (ownedMine)
183
- bumpRemaining(bucketsMine, created, null);
184
- }
185
- for (const record of entry.history) {
186
- const closed = dayOf(record.time);
187
- if (closed !== null) {
188
- const week = ymd(mondayOf(closed));
189
- bump(bucketsAll, week, 'done');
190
- if (identity !== null && (record.owner ?? null) === identity)
191
- bump(bucketsMine, week, 'done');
192
- }
193
- const created = dayOf(record.created);
194
- if (created !== null) {
195
- const week = ymd(mondayOf(created));
196
- bump(bucketsAll, week, 'created');
197
- // 新增(我的)= 我创建的(creator);旧 8 列历史无 creator 列 ⇒ 兜底 owner
198
- const createdMine = identity !== null && ((record.creator ?? null) === identity
199
- || ((record.creator ?? null) === null && (record.owner ?? null) === identity));
200
- if (createdMine)
201
- bump(bucketsMine, week, 'created');
202
- }
203
- const ownedMine = identity !== null && (record.owner ?? null) === identity;
204
- bumpRemaining(bucketsAll, created, closed);
205
- if (ownedMine)
206
- bumpRemaining(bucketsMine, created, closed);
207
- }
208
- }
209
- const closed = {
210
- weekly: {
211
- all: weeks.map((week, i) => ({ week, end: weekEnds[i], ...bucketsAll.get(week) ?? { done: 0, created: 0, remaining: 0 } })),
212
- mine: weeks.map((week, i) => ({ week, end: weekEnds[i], ...bucketsMine.get(week) ?? { done: 0, created: 0, remaining: 0 } })),
213
- },
214
- };
215
- // ── 统计台:四个自然周期(只算「全部工作区」范围;图表切换不带动卡片)────
216
- const windows = {
217
- thisWeek: weekWindow(0, now),
218
- lastWeek: weekWindow(-1, now),
219
- thisMonth: monthWindow(0, now),
220
- lastMonth: monthWindow(-1, now),
221
- };
222
- const doneEntries = perWorkspace.flatMap((entry) => entry.history.map((record) => ({ day: dayOf(record.time), owner: record.owner ?? null })));
223
- const createdEntries = perWorkspace.flatMap((entry) => [
224
- ...entry.tasks.map((task) => ({ day: dayOf(task.created), owner: task.owner ?? null })),
225
- ...entry.history.map((record) => ({ day: dayOf(record.created), owner: record.owner ?? null })),
226
- ]);
227
- const period = (window) => ({
228
- done: countPeriod(doneEntries, window),
229
- created: countPeriod(createdEntries, window),
230
- from: window.from,
231
- to: window.to,
232
- });
233
- const trend = {
234
- thisWeek: period(windows.thisWeek),
235
- lastWeek: period(windows.lastWeek),
236
- thisMonth: period(windows.thisMonth),
237
- lastMonth: period(windows.lastMonth),
238
- };
239
- return { snapshot, closed, trend };
240
- }
241
- /** 只拿在办快照(薄封装:测试 / 其它调用方用;首页走 {@link getHomeAggregate} 一次读全)。 */
242
- export async function getBoardSnapshot(workspaces) {
243
- return (await getHomeAggregate(workspaces)).snapshot;
244
- }
245
- /**
246
- * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
247
- * shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
248
- * 单工作区内 limit 已生效,合并边界的全局精确排序暂不保证(500 条一页无感)。
249
- * 要全量老数据:调用方显式传 months: undefined(不拼 --months,脚本读全部,按秒计)。
250
- */
251
- export async function queryClosed(workspaces, filter = {}) {
252
- const windowed = { months: RECENT_MONTHS, ...filter };
253
- const perWorkspace = await Promise.all(workspaces.map((workspace) => readHistory(workspace, windowed)));
254
- return perWorkspace.flat()
255
- .sort((a, b) => String(b.created ?? b.time ?? '').localeCompare(String(a.created ?? a.time ?? '')))
256
- .slice(0, filter.limit ?? DEFAULT_HISTORY_LIMIT);
257
- }
258
- /**
259
- * 筛选项候选:在办(全量)+ 近 13 个月历史的去重值(owner / category / tag)。
260
- * 按需调用(/filters 路由),非首屏——历史侧在极端量级下是百毫秒级。
261
- */
262
- export async function getFilterOptions(workspaces) {
263
- const perWorkspace = await Promise.all(workspaces.map(async (workspace) => ({
264
- tasks: await readTasks(workspace),
265
- history: await readHistory(workspace, { months: RECENT_MONTHS }),
266
- })));
267
- const owners = new Set();
268
- const categories = new Set();
269
- const tags = new Set();
270
- const addTask = (task) => {
271
- if (task.owner !== undefined && task.owner !== null && task.owner !== '')
272
- owners.add(task.owner);
273
- if (task.creator !== undefined && task.creator !== null && task.creator !== '')
274
- owners.add(task.creator);
275
- if (task.category !== undefined && task.category !== null && task.category !== '')
276
- categories.add(task.category);
277
- for (const tag of task.tags ?? [])
278
- tags.add(tag);
279
- };
280
- const addRecord = (record) => {
281
- if (record.owner !== undefined && record.owner !== '')
282
- owners.add(record.owner);
283
- if (record.category !== undefined && record.category !== null && record.category !== '')
284
- categories.add(record.category);
285
- for (const tag of record.tags ?? [])
286
- tags.add(tag);
20
+ const { data: scopes, degraded } = await settleWorkspaces(workspaces, 'history', readTrendScope, (rows) => rows);
21
+ return {
22
+ snapshot: computeSnapshot(scopes, now),
23
+ closed: computeClosedSummary(scopes, now),
24
+ trend: computeTrendPeriods(scopes, now),
25
+ degraded,
287
26
  };
288
- for (const entry of perWorkspace) {
289
- for (const task of entry.tasks)
290
- addTask(task);
291
- for (const record of entry.history)
292
- addRecord(record);
293
- }
294
- const sorted = (values) => [...values].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
295
- return { owners: sorted(owners), categories: sorted(categories), tags: sorted(tags) };
296
27
  }
28
+ // 单路径用例直接透传 service(调用方按快 / 慢自己选,不必经过组合):
29
+ export { getSnapshot as getBoardSnapshot } from './services/home.service.js';
30
+ export { getFilterOptions, queryClosed } from './services/history.service.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 横切:错误模型与降级语义(2026-10-18 重构步骤 3 落地)。
3
+ *
4
+ * 两种失败分开表达:
5
+ * - **已知失败**(坏数据 / 脚本非零 / python3 起不来 / 超时):service 层捕获进 `degraded`,
6
+ * 接口仍 200,该工作区被前端标红,其他工作区不受影响(不再一抛全板 500);
7
+ * - **全局失败**(所有工作区都读不到 / 编程错误):照旧抛,路由转 500 + 人话。
8
+ * 防止「python3 根本没装」被集体降级成一片绿色空板——那等于界面撒谎。
9
+ */
10
+ /** 机器可读错误码(只增不改;`LEDGER_` 前缀避开宿主错误码空间)。 */
11
+ export type LedgerErrorCode = 'LEDGER_SCRIPT_UNAVAILABLE' | 'LEDGER_SCRIPT_TIMEOUT' | 'LEDGER_SCRIPT_FAILED' | 'LEDGER_SCRIPT_BAD_JSON' | 'LEDGER_DATA_INVALID';
12
+ /** 台账数据形状不符(容器级校验失败)。message 带工作区名 + 人话,可直接上界面。 */
13
+ export declare class LedgerDataError extends Error {
14
+ readonly code: LedgerErrorCode;
15
+ readonly workspaceId: string;
16
+ constructor(workspaceId: string, message: string);
17
+ }
18
+ /** 降级信息:哪个工作区的哪类读挂了、为什么(扁平数组,UI 一种渲染路径)。 */
19
+ export interface DegradedInfo {
20
+ readonly workspaceId: string;
21
+ readonly workspaceTitle: string;
22
+ /** 哪个读挂了——trend 同时读 list + history,必须能区分。 */
23
+ readonly op: 'list' | 'history';
24
+ readonly code: LedgerErrorCode;
25
+ /** 人话,可直接上界面(真源:脚本 stderr / records 校验文案)。 */
26
+ readonly message: string;
27
+ }
28
+ /** 从任意抛出物取错误码(无 code 的按 LEDGER_SCRIPT_FAILED 兜底——那正是脚本非零的主路径)。 */
29
+ export declare function codeOf(error: unknown): LedgerErrorCode;
30
+ /** 人话(优先 Error.message;其余 String(),不吞)。 */
31
+ export declare function messageOf(error: unknown): string;
32
+ /**
33
+ * 多工作区 fan-out:逐个读,坏的进 `degraded`,好的交给 `merge` 合成。
34
+ * **全部失败** ⇒ 抛首个错误(路由转 500 + 人话)——部分失败才 200 + degraded。
35
+ */
36
+ export declare function settleWorkspaces<W extends {
37
+ id: string;
38
+ title: string;
39
+ }, T, R>(workspaces: readonly W[], op: 'list' | 'history', read: (workspace: W) => Promise<T>, merge: (rows: readonly T[]) => R): Promise<{
40
+ data: R;
41
+ degraded: readonly DegradedInfo[];
42
+ }>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 横切:错误模型与降级语义(2026-10-18 重构步骤 3 落地)。
3
+ *
4
+ * 两种失败分开表达:
5
+ * - **已知失败**(坏数据 / 脚本非零 / python3 起不来 / 超时):service 层捕获进 `degraded`,
6
+ * 接口仍 200,该工作区被前端标红,其他工作区不受影响(不再一抛全板 500);
7
+ * - **全局失败**(所有工作区都读不到 / 编程错误):照旧抛,路由转 500 + 人话。
8
+ * 防止「python3 根本没装」被集体降级成一片绿色空板——那等于界面撒谎。
9
+ */
10
+ /** 台账数据形状不符(容器级校验失败)。message 带工作区名 + 人话,可直接上界面。 */
11
+ export class LedgerDataError extends Error {
12
+ code = 'LEDGER_DATA_INVALID';
13
+ workspaceId;
14
+ constructor(workspaceId, message) {
15
+ super(message);
16
+ this.name = 'LedgerDataError';
17
+ this.workspaceId = workspaceId;
18
+ }
19
+ }
20
+ /** 从任意抛出物取错误码(无 code 的按 LEDGER_SCRIPT_FAILED 兜底——那正是脚本非零的主路径)。 */
21
+ export function codeOf(error) {
22
+ if (error !== null && typeof error === 'object' && 'code' in error) {
23
+ const code = error.code;
24
+ if (typeof code === 'string' && code.startsWith('LEDGER_'))
25
+ return code;
26
+ }
27
+ return 'LEDGER_SCRIPT_FAILED';
28
+ }
29
+ /** 人话(优先 Error.message;其余 String(),不吞)。 */
30
+ export function messageOf(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ /**
34
+ * 多工作区 fan-out:逐个读,坏的进 `degraded`,好的交给 `merge` 合成。
35
+ * **全部失败** ⇒ 抛首个错误(路由转 500 + 人话)——部分失败才 200 + degraded。
36
+ */
37
+ export async function settleWorkspaces(workspaces, op, read, merge) {
38
+ const settled = await Promise.allSettled(workspaces.map(read));
39
+ const rows = [];
40
+ const degraded = [];
41
+ let firstError = null;
42
+ settled.forEach((result, i) => {
43
+ const workspace = workspaces[i];
44
+ if (result.status === 'fulfilled') {
45
+ rows.push(result.value);
46
+ return;
47
+ }
48
+ if (firstError === null)
49
+ firstError = result.reason;
50
+ degraded.push({ workspaceId: workspace.id, workspaceTitle: workspace.title, op, code: codeOf(result.reason), message: messageOf(result.reason) });
51
+ });
52
+ if (rows.length === 0 && workspaces.length > 0)
53
+ throw firstError;
54
+ return { data: merge(rows), degraded };
55
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 读缓存(方案 B 的落地件,2026-10-18 拍板的三条口径,无黑魔法):
3
+ *
4
+ * ① 进程内 `Map`,key = `op:workspace:filter`(filter 已序列化),值 = 结果 + 时间戳;
5
+ * ② 命中且 1.5s 内直接返回、**不起进程**;同 key 并发**单飞**(只跑一次脚本,其余等同一个 Promise);
6
+ * ③ `fresh=1` 绕过(路由层先 `invalidate()` 再算);写路径成功后 `invalidate(workspaceId)` 钩子。
7
+ *
8
+ * 边界:只缓存 skill 脚本的两读(在办 list / 历史 history)。worklog 正文不缓存——它会被
9
+ * 会话里的 Agent 实时改写,缓存 1.5s 也会让用户看到上一版。**失败不入缓存**(错就是错,重试要能重新跑)。
10
+ * 命中返回的是**同一个引用**:调用方只许读(上层 service 全是纯计算,不原地改数组)。
11
+ */
12
+ /** 缓存有效期(2026-10-18 拍板 1.5s:够挡同一屏的重复读,短到不会让人看到旧数据)。 */
13
+ export declare const CACHE_TTL_MS = 1500;
14
+ /** 缓存的操作种类(= skill 脚本子命令;worklog 不进缓存故不在此列)。 */
15
+ export type CacheOp = 'list' | 'history';
16
+ /** 缓存键:`op:workspace:filter` 三条信息都要,否则不同筛选 / 不同工作区会串味。 */
17
+ export declare function cacheKey(workspaceId: string, op: CacheOp, detail: string): string;
18
+ /**
19
+ * 取数(命中即返回,未命中跑 `load` 并落缓存)。
20
+ * `now` 仅供冒烟注入;业务代码不传,默认取当前时刻。
21
+ */
22
+ export declare function cached<T>(key: string, load: () => Promise<T>, now?: number): Promise<T>;
23
+ /**
24
+ * 让缓存失效:不传 = 全清(`fresh=1` 手动刷新用);传工作区 id = 只清该工作区。
25
+ * 写路径(任务 4,未拍板)落地后在其成功后调本函数;现在只有手动刷新这一处用。
26
+ */
27
+ export declare function invalidate(workspaceId?: string): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 读缓存(方案 B 的落地件,2026-10-18 拍板的三条口径,无黑魔法):
3
+ *
4
+ * ① 进程内 `Map`,key = `op:workspace:filter`(filter 已序列化),值 = 结果 + 时间戳;
5
+ * ② 命中且 1.5s 内直接返回、**不起进程**;同 key 并发**单飞**(只跑一次脚本,其余等同一个 Promise);
6
+ * ③ `fresh=1` 绕过(路由层先 `invalidate()` 再算);写路径成功后 `invalidate(workspaceId)` 钩子。
7
+ *
8
+ * 边界:只缓存 skill 脚本的两读(在办 list / 历史 history)。worklog 正文不缓存——它会被
9
+ * 会话里的 Agent 实时改写,缓存 1.5s 也会让用户看到上一版。**失败不入缓存**(错就是错,重试要能重新跑)。
10
+ * 命中返回的是**同一个引用**:调用方只许读(上层 service 全是纯计算,不原地改数组)。
11
+ */
12
+ /** 缓存有效期(2026-10-18 拍板 1.5s:够挡同一屏的重复读,短到不会让人看到旧数据)。 */
13
+ export const CACHE_TTL_MS = 1_500;
14
+ const SEP = '\u0000';
15
+ /** 已完成的缓存:key → 值与时间戳。 */
16
+ const settled = new Map();
17
+ /** 在途单飞:key → 正在跑的 Promise(同 key 并发只跑一次)。 */
18
+ const inflight = new Map();
19
+ /** 缓存键:`op:workspace:filter` 三条信息都要,否则不同筛选 / 不同工作区会串味。 */
20
+ export function cacheKey(workspaceId, op, detail) {
21
+ return `${op}${SEP}${workspaceId}${SEP}${detail}`;
22
+ }
23
+ /**
24
+ * 取数(命中即返回,未命中跑 `load` 并落缓存)。
25
+ * `now` 仅供冒烟注入;业务代码不传,默认取当前时刻。
26
+ */
27
+ export function cached(key, load, now = Date.now()) {
28
+ const hit = settled.get(key);
29
+ if (hit !== undefined && now - hit.at < CACHE_TTL_MS)
30
+ return Promise.resolve(hit.value);
31
+ const pending = inflight.get(key);
32
+ if (pending !== undefined)
33
+ return pending;
34
+ const task = Promise.resolve()
35
+ .then(load)
36
+ .then((value) => {
37
+ settled.set(key, { at: Date.now(), value });
38
+ return value;
39
+ })
40
+ .finally(() => { inflight.delete(key); });
41
+ inflight.set(key, task);
42
+ return task;
43
+ }
44
+ /**
45
+ * 让缓存失效:不传 = 全清(`fresh=1` 手动刷新用);传工作区 id = 只清该工作区。
46
+ * 写路径(任务 4,未拍板)落地后在其成功后调本函数;现在只有手动刷新这一处用。
47
+ */
48
+ export function invalidate(workspaceId) {
49
+ if (workspaceId === undefined) {
50
+ settled.clear();
51
+ return;
52
+ }
53
+ for (const key of settled.keys()) {
54
+ if (key.split(SEP)[1] === workspaceId)
55
+ settled.delete(key);
56
+ }
57
+ }
@@ -0,0 +1,9 @@
1
+ import type { LedgerWorkspace } from '../types.js';
2
+ /**
3
+ * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
4
+ * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
5
+ * 该工作区的「我的」统计按 0 计(不谎报)。
6
+ *
7
+ * 全 host 唯一 spawn git 的地方(python3 的唯一出口在 ./runner.ts)。
8
+ */
9
+ export declare function getCurrentIdentity(workspace: LedgerWorkspace): Promise<string | null>;