dsh-task-ledger 0.0.44 → 0.0.46

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 (30) hide show
  1. package/dist/client.js +33 -6
  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/identity.repository.d.ts +9 -0
  8. package/dist/host/repositories/identity.repository.js +20 -0
  9. package/dist/host/{ledger-read.d.ts → repositories/ledger.repository.d.ts} +10 -4
  10. package/dist/host/{ledger-read.js → repositories/ledger.repository.js} +19 -7
  11. package/dist/host/repositories/records.d.ts +5 -0
  12. package/dist/host/repositories/records.js +48 -0
  13. package/dist/host/{script.js → repositories/runner.js} +3 -2
  14. package/dist/host/routes.js +7 -5
  15. package/dist/host/services/dates.d.ts +37 -0
  16. package/dist/host/services/dates.js +71 -0
  17. package/dist/host/services/history.service.d.ts +24 -0
  18. package/dist/host/services/history.service.js +61 -0
  19. package/dist/host/services/home.service.d.ts +13 -0
  20. package/dist/host/services/home.service.js +50 -0
  21. package/dist/host/services/trend.service.d.ts +26 -0
  22. package/dist/host/services/trend.service.js +134 -0
  23. package/dist/host/{skill-registration.js → skill/registration.js} +3 -2
  24. package/dist/host/types.d.ts +94 -0
  25. package/dist/host/workspace.d.ts +2 -0
  26. package/dist/host/workspace.js +6 -9
  27. package/dist/index.js +1 -1
  28. package/package.json +4 -3
  29. /package/dist/host/{script.d.ts → repositories/runner.d.ts} +0 -0
  30. /package/dist/host/{skill-registration.d.ts → skill/registration.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,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>;
@@ -0,0 +1,20 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
4
+ * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
5
+ * 该工作区的「我的」统计按 0 计(不谎报)。
6
+ *
7
+ * 全 host 唯一 spawn git 的地方(python3 的唯一出口在 ./runner.ts)。
8
+ */
9
+ export function getCurrentIdentity(workspace) {
10
+ return new Promise((resolve) => {
11
+ const child = spawn('git', ['config', 'user.name'], { cwd: workspace.path, stdio: ['ignore', 'pipe', 'ignore'] });
12
+ let stdout = '';
13
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
14
+ child.on('error', () => { resolve(null); });
15
+ child.on('close', (code) => {
16
+ const name = stdout.trim();
17
+ resolve(code === 0 && name !== '' ? name : null);
18
+ });
19
+ });
20
+ }
@@ -1,15 +1,21 @@
1
- import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
1
+ import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from '../types.js';
2
2
  /**
3
3
  * LedgerFilter → skill 脚本参数(只拼调用方给了的;undefined 一律不拼,
4
4
  * 让脚本走自己的默认——插件不替脚本决定默认值)。
5
5
  */
6
6
  export declare function filterArgs(filter: LedgerFilter): string[];
7
7
  /**
8
- * 在办任务(包 `list --json`;结果打上 workspace)。
9
- * 脚本失败抛错——路由层转 500 + 人话,不静默返空(空 = 撒谎)。
8
+ * 台账工作区探测:`<path>/ledger/progress.json` 在才算(未初始化 = 后端不可见,
9
+ * 2026-10-17 拍板)。全 host 对台账目录的 IO 只在本文件——别处再写 existsSync 即为分层倒退。
10
+ */
11
+ export declare function hasLedger(workspacePath: string): boolean;
12
+ /**
13
+ * 在办任务(包 `list --json`;结果过 records 入口校验后打上 workspace)。
14
+ * 脚本失败 / 形状不符抛错——由 service 层捕获进 degraded,路由层转 500 + 人话,
15
+ * 不静默返空(空 = 撒谎)。
10
16
  */
11
17
  export declare function readTasks(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<TaskRow[]>;
12
- /** 已结案记录(包 `history --json`;结果打上 workspace)。 */
18
+ /** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。 */
13
19
  export declare function readHistory(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<HistoryRecord[]>;
14
20
  /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
15
21
  export declare function getTask(workspace: LedgerWorkspace, taskId: string): Promise<TaskRow | null>;