dsh-task-ledger 0.0.126 → 0.0.127
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 +23143 -14271
- package/dist/client.js.map +1 -1
- package/dist/host/errors.d.ts +7 -1
- package/dist/host/errors.js +10 -0
- package/dist/host/repositories/dirs.repository.d.ts +37 -0
- package/dist/host/repositories/dirs.repository.js +97 -0
- package/dist/host/repositories/history.repository.d.ts +20 -0
- package/dist/host/repositories/history.repository.js +63 -0
- package/dist/host/repositories/identity.repository.d.ts +53 -3
- package/dist/host/repositories/identity.repository.js +105 -10
- package/dist/host/repositories/ledger.repository.d.ts +0 -8
- package/dist/host/repositories/ledger.repository.js +3 -20
- package/dist/host/repositories/runner.d.ts +29 -0
- package/dist/host/repositories/runner.js +66 -4
- package/dist/host/repositories/stats.repository.js +2 -2
- package/dist/host/repositories/task.repository.d.ts +12 -0
- package/dist/host/repositories/task.repository.js +63 -0
- package/dist/host/repositories/trend.repository.d.ts +3 -3
- package/dist/host/repositories/trend.repository.js +16 -8
- package/dist/host/routes.d.ts +13 -1
- package/dist/host/routes.js +369 -16
- package/dist/host/services/continue.service.d.ts +126 -0
- package/dist/host/services/continue.service.js +138 -0
- package/dist/host/services/dirs.service.d.ts +34 -0
- package/dist/host/services/dirs.service.js +66 -0
- package/dist/host/services/history.service.d.ts +13 -1
- package/dist/host/services/history.service.js +42 -0
- package/dist/host/services/identity.service.d.ts +53 -0
- package/dist/host/services/identity.service.js +89 -0
- package/dist/host/services/stats.service.d.ts +2 -2
- package/dist/host/services/stats.service.js +23 -6
- package/dist/host/services/task.service.d.ts +15 -0
- package/dist/host/services/task.service.js +15 -0
- package/dist/host/services/trend.service.d.ts +2 -2
- package/dist/host/services/trend.service.js +3 -3
- package/dist/host/services/workspaces.service.d.ts +10 -0
- package/dist/host/services/workspaces.service.js +28 -0
- package/dist/host/types.d.ts +53 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +31 -3
- package/package.json +2 -2
- package/skill/SKILL.md +13 -13
- package/skill/package.json +1 -1
- package/skill/references/init-identity.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/dist/host/workspace.d.ts +0 -20
- package/dist/host/workspace.js +0 -17
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「继续」:在**该台账所属的 DSH 工作区**里起一个新会话,把只读提示词作为首条 user message 注入。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不塞进现有会话的输入框(2026-11-03 核实):写草稿的 `setDraft` 只挂在会话作用域的门面上
|
|
5
|
+
* (`dsh-client-ui-conversation` `contract/input.d.ts:165`,经 `SessionInputResolver.for(actx)` 取),
|
|
6
|
+
* 根级 client 插件拿不到某个会话的 `actx`;按 id 取的 `input.shell()` 是包内部件。
|
|
7
|
+
* 且任务↔会话之间没有绑定字段 ⇒「往哪个会话塞」本身没有答案。链路事实见
|
|
8
|
+
* `docs/design/external/dsh-session-handoff.md`,兄弟插件实证 `dsh-task-dispatch-table/src/dispatch.ts:519/562/627`。
|
|
9
|
+
*
|
|
10
|
+
* 三条硬约束(宿主 rc 线的坑,缺一条就是"会话建了但没法干活"):
|
|
11
|
+
* 1. `setup` 里必须 `agentPresets.mount()` —— 不挂 preset 的 agent 落到空的全局层,fs / bash 全无;
|
|
12
|
+
* 2. `agentOptions.provider/model` 必须成对显式给 —— 缺省**不等于**走宿主默认;
|
|
13
|
+
* 3. 建完必须 `attachSession(sessionId)` —— 只设 `meta.cwd` 不会把会话归进工作区。
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
/**
|
|
18
|
+
* 比较用的规范化路径:registry 里存的是建工作区时的 `fs.realpath`,
|
|
19
|
+
* 而台账目录可能是手工添加进来的(符号链接 / 相对写法)⇒ 直接用字符串比会误判成「非 DSH 工作区」。
|
|
20
|
+
* realpath 失败(目录不在了)就退回原串,交给后续步骤按真实情况报错。
|
|
21
|
+
*/
|
|
22
|
+
function canonical(path) {
|
|
23
|
+
try {
|
|
24
|
+
return realpathSync(path);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 判据:台账目录的 path 能不能在宿主工作区里找到实体。
|
|
32
|
+
*
|
|
33
|
+
* 「继续」和「完成」两条链都先过这一关 —— 非 DSH 工作区**起不了会话**,
|
|
34
|
+
* 只能退化成「把提示词复制给用户,让他去对应的 Agent 粘」。
|
|
35
|
+
*/
|
|
36
|
+
export function resolveWorkspace(registry, path) {
|
|
37
|
+
const list = registry.list();
|
|
38
|
+
const wanted = canonical(path);
|
|
39
|
+
const workspace = list.find((item) => item.path === path) ??
|
|
40
|
+
list.find((item) => canonical(item.path) === wanted);
|
|
41
|
+
return workspace === undefined ? { kind: "not-dsh" } : { kind: "dsh", workspace };
|
|
42
|
+
}
|
|
43
|
+
function continueMessage(id, prompt) {
|
|
44
|
+
return {
|
|
45
|
+
id: randomUUID(),
|
|
46
|
+
role: "user",
|
|
47
|
+
content: [{ type: "text", text: prompt }],
|
|
48
|
+
// source 的 kind 与别的插件不冲突(合并扩展的判别字段),form/summary 只影响气泡上那行小字。
|
|
49
|
+
source: { kind: "task-ledger", form: "notice", summary: `继续任务 ${id}` },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** 模型必须成对给:宿主缺省不填 `{{model}}` 会让 deployment persona 取不到值直接报错。 */
|
|
53
|
+
function resolveRoute(defaultModel) {
|
|
54
|
+
if (defaultModel === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
const route = defaultModel.currentSelection();
|
|
58
|
+
if (typeof route?.provider === "string" &&
|
|
59
|
+
route.provider !== "" &&
|
|
60
|
+
typeof route.model === "string" &&
|
|
61
|
+
route.model !== "")
|
|
62
|
+
return { provider: route.provider, model: route.model };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* 宿主服务未挂载时读属性会抛:按"没有默认模型"处理,由调用方报人话 */
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** 部署默认 preset;解析不出按 rosterless 处理(那种部署的东西在全局层,不挂也看得见)。 */
|
|
70
|
+
async function resolvePresetId(presets) {
|
|
71
|
+
if (presets === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
try {
|
|
74
|
+
const { id } = await presets.resolve();
|
|
75
|
+
return id === "" ? undefined : id;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 起会话并注入只读提示词。
|
|
83
|
+
*
|
|
84
|
+
* @throws Error(人话直上界面):宿主缺 agents 服务 / 没有可用默认模型 / 会话建不起来 /
|
|
85
|
+
* 建好了但归不进工作区(此时会话会被 dispose 掉,不留孤儿)。
|
|
86
|
+
*/
|
|
87
|
+
export async function continueTask(deps, input) {
|
|
88
|
+
// ① 判据:台账目录的 path 能不能在宿主工作区里找到实体 —— 找不到就是非 DSH 工作区。
|
|
89
|
+
const resolved = resolveWorkspace(deps.registry, input.workspaceId);
|
|
90
|
+
if (resolved.kind === "not-dsh")
|
|
91
|
+
return { kind: "not-dsh" };
|
|
92
|
+
const workspace = resolved.workspace;
|
|
93
|
+
const agents = deps.agents;
|
|
94
|
+
if (agents === undefined)
|
|
95
|
+
throw new Error("宿主没有提供 agents 服务,起不了会话(本环境无法继续任务)。");
|
|
96
|
+
const route = resolveRoute(deps.defaultModel);
|
|
97
|
+
if (route === undefined)
|
|
98
|
+
throw new Error("宿主没有可用的默认模型(provider + model 需成对),请先在宿主里选一个模型再继续。");
|
|
99
|
+
const presetId = await resolvePresetId(deps.presets);
|
|
100
|
+
// ② 建会话(禁止预建再驱动:撞 already exists)
|
|
101
|
+
const sessionId = randomUUID();
|
|
102
|
+
const handle = await agents.create({
|
|
103
|
+
sessionId,
|
|
104
|
+
meta: {
|
|
105
|
+
cwd: workspace.path,
|
|
106
|
+
...(presetId === undefined ? {} : { agentPreset: presetId }),
|
|
107
|
+
},
|
|
108
|
+
agentOptions: route,
|
|
109
|
+
setup: async (agentCtx) => {
|
|
110
|
+
if (presetId === undefined || deps.presets === undefined)
|
|
111
|
+
return;
|
|
112
|
+
await deps.presets.mount(agentCtx, presetId);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
// ③ 归组:不 attach 就只有 cwd,侧栏里找不到这个会话 ⇒ 宁可回滚也不留孤儿
|
|
116
|
+
try {
|
|
117
|
+
if (workspace.attachSession === undefined)
|
|
118
|
+
throw new Error(`宿主没有提供 attachSession,会话 ${sessionId} 无法归入工作区「${workspace.title}」,已放弃。`);
|
|
119
|
+
await workspace.attachSession(sessionId);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
await handle.dispose().catch(() => { });
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
// ④ 命名:`sessionTitle.rename` 写的是「用户来源」标题(会锁定自动命名,正是我们要的稳定名字)。
|
|
126
|
+
// 服务缺席 / 写失败都只丢标题,不能连累已经建好并归组的会话。
|
|
127
|
+
if (deps.sessionTitle !== undefined) {
|
|
128
|
+
try {
|
|
129
|
+
deps.sessionTitle.rename(handle.agent.session, input.title);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* 命名失败不阻断继续 */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// ⑤ 注入首条消息并唤醒 driver
|
|
136
|
+
handle.agent.send(continueMessage(input.id, input.prompt), "next-turn", true);
|
|
137
|
+
return { kind: "started", sessionId };
|
|
138
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 台账目录清单(机器级;真源在 skill `dirs` + `~/.taskledger/ledgers.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 写一律走 skill 命令,插件不碰那个文件 —— 既有规矩,见 dirs.repository.ts 头注。
|
|
5
|
+
*/
|
|
6
|
+
import { type LedgerDir } from "../repositories/dirs.repository.js";
|
|
7
|
+
/** 设置页「台账目录」块要的清单:**含隐藏项**(关了显示还得能开回来)。 */
|
|
8
|
+
export declare function listDirs(): Promise<{
|
|
9
|
+
readonly dirs: readonly LedgerDir[];
|
|
10
|
+
}>;
|
|
11
|
+
/** 一次目录写动作(登记 / 显示 / 改名 / 移除),返回改完之后的完整清单。 */
|
|
12
|
+
export type DirWriteInput = {
|
|
13
|
+
readonly action: "hide" | "unhide" | "remove";
|
|
14
|
+
readonly path: string;
|
|
15
|
+
} | {
|
|
16
|
+
readonly action: "rename";
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly action: "add";
|
|
21
|
+
readonly path: string;
|
|
22
|
+
};
|
|
23
|
+
/** 写动作回执;`added` / `reason` 只有「登记」会带(`added:false` 时 `reason` 是原因)。 */
|
|
24
|
+
export interface DirWriteResult {
|
|
25
|
+
readonly dirs: readonly LedgerDir[];
|
|
26
|
+
/** 登记回执:`false` = 一个都没进清单(`reason` 是原因)。 */
|
|
27
|
+
readonly added?: boolean;
|
|
28
|
+
/** 这些目录**本来就在**清单里(界面据此提示「无需重复添加」)。 */
|
|
29
|
+
readonly existed?: boolean;
|
|
30
|
+
/** 这次涉及的台账根(新增的;全部已存在时是已存在的那些),界面列名字用。 */
|
|
31
|
+
readonly paths?: readonly string[];
|
|
32
|
+
readonly reason?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function writeDirs(input: DirWriteInput): Promise<DirWriteResult>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 台账目录清单(机器级;真源在 skill `dirs` + `~/.taskledger/ledgers.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 写一律走 skill 命令,插件不碰那个文件 —— 既有规矩,见 dirs.repository.ts 头注。
|
|
5
|
+
*/
|
|
6
|
+
import { addDir, ledgerChildrenOf, readDirs, removeDir, renameDir, setDirHidden, } from "../repositories/dirs.repository.js";
|
|
7
|
+
/** 设置页「台账目录」块要的清单:**含隐藏项**(关了显示还得能开回来)。 */
|
|
8
|
+
export async function listDirs() {
|
|
9
|
+
return { dirs: await readDirs() };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 登记:选中的目录**自己**是台账根就登记它;不是的话,把它**一级子目录**里的台账根逐个登记
|
|
13
|
+
* (用户 2026-11-03:只知道「这项目有台账」的人会选它的直属父目录,这种也算有效)。
|
|
14
|
+
* 两样都没有 ⇒ 不落盘,把 skill 给的原因回给界面。
|
|
15
|
+
*
|
|
16
|
+
* 回执里**清单永远是完整的**:skill 在「不是台账根」时不回清单(`dirs` 为 null),
|
|
17
|
+
* 照它回执换表格会把界面清空(2026-11-03 真机踩过)⇒ 失败分支自己再读一次。
|
|
18
|
+
*/
|
|
19
|
+
async function addDirs(path) {
|
|
20
|
+
// 登记前先记一份:skill 的 `--add` 幂等,回执分不出「这次新增」和「本来就有」
|
|
21
|
+
const before = new Set((await readDirs()).map((dir) => dir.path));
|
|
22
|
+
const fresh = [];
|
|
23
|
+
const known = [];
|
|
24
|
+
let dirs = [];
|
|
25
|
+
const self = await addDir(path, "");
|
|
26
|
+
if (self.added) {
|
|
27
|
+
dirs = self.dirs;
|
|
28
|
+
(before.has(path) ? known : fresh).push(path);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const children = await ledgerChildrenOf(path);
|
|
32
|
+
if (children.length === 0)
|
|
33
|
+
return { dirs: await readDirs(), added: false, reason: self.reason };
|
|
34
|
+
// 串行:读改写同一个清单文件,并发会互相覆盖
|
|
35
|
+
for (const child of children) {
|
|
36
|
+
const result = await addDir(child, "");
|
|
37
|
+
if (!result.added)
|
|
38
|
+
continue;
|
|
39
|
+
dirs = result.dirs;
|
|
40
|
+
(before.has(child) ? known : fresh).push(child);
|
|
41
|
+
}
|
|
42
|
+
if (fresh.length === 0 && known.length === 0)
|
|
43
|
+
return {
|
|
44
|
+
dirs: await readDirs(),
|
|
45
|
+
added: false,
|
|
46
|
+
reason: `${path} 下的台账登记失败`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (fresh.length > 0)
|
|
50
|
+
return { dirs, added: true, paths: fresh };
|
|
51
|
+
return { dirs, added: true, existed: true, paths: known };
|
|
52
|
+
}
|
|
53
|
+
export async function writeDirs(input) {
|
|
54
|
+
switch (input.action) {
|
|
55
|
+
case "hide":
|
|
56
|
+
return { dirs: await setDirHidden(input.path, true) };
|
|
57
|
+
case "unhide":
|
|
58
|
+
return { dirs: await setDirHidden(input.path, false) };
|
|
59
|
+
case "rename":
|
|
60
|
+
return { dirs: await renameDir(input.path, input.name) };
|
|
61
|
+
case "remove":
|
|
62
|
+
return { dirs: await removeDir(input.path) };
|
|
63
|
+
case "add":
|
|
64
|
+
return addDirs(input.path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -1,2 +1,14 @@
|
|
|
1
|
-
import type { HistoryGran, HistoryTrendPayload, LedgerWorkspace } from "../types.js";
|
|
1
|
+
import type { HistoryGran, HistoryListPayload, HistoryTrendPayload, LedgerWorkspace } from "../types.js";
|
|
2
2
|
export declare function getHistoryTrend(workspaces: readonly LedgerWorkspace[], gran?: HistoryGran): Promise<HistoryTrendPayload>;
|
|
3
|
+
/** 历史列表的查询:**只取选中的那个月**(2026-11-01 用户拍板:取数按月,不再取一年再前端过滤)。 */
|
|
4
|
+
export interface HistoryListQuery {
|
|
5
|
+
/** `YYYY-MM`;缺省或格式不对 ⇒ 当月。 */
|
|
6
|
+
readonly ym?: string;
|
|
7
|
+
/** 执行人 uuid。 */
|
|
8
|
+
readonly owner?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 历史明细跨工作区合并:按结案时间正序、按 `key` 去重,整月一次下发。
|
|
12
|
+
* 分页不在这里做 —— 整月都在前端内存里,滚动由前端按行号开窗(不再按天切片翻页)。
|
|
13
|
+
*/
|
|
14
|
+
export declare function getHistoryList(workspaces: readonly LedgerWorkspace[], query: HistoryListQuery): Promise<HistoryListPayload>;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* (用户 2026-10-30 定:聚合算法不自己写,本地只加总,免得与核心口径漂移)。
|
|
6
6
|
*/
|
|
7
7
|
import { settleWorkspaces } from "../errors.js";
|
|
8
|
+
import { readHistory } from "../repositories/history.repository.js";
|
|
8
9
|
import { readTrendGran } from "../repositories/trend.repository.js";
|
|
9
10
|
function pad2(n) {
|
|
10
11
|
return String(n).padStart(2, "0");
|
|
@@ -128,3 +129,44 @@ export async function getHistoryTrend(workspaces, gran = "day") {
|
|
|
128
129
|
degraded: settled.degraded,
|
|
129
130
|
};
|
|
130
131
|
}
|
|
132
|
+
/** `YYYY-MM` → 该月首末时刻(与存盘同形的串,脚本直接字符串比较)。 */
|
|
133
|
+
function monthRange(ym) {
|
|
134
|
+
const year = Number(ym.slice(0, 4));
|
|
135
|
+
const month = Number(ym.slice(5, 7));
|
|
136
|
+
// 0 基月份:传 month 就是「下个月 1 号」,退一秒即本月末
|
|
137
|
+
const end = new Date(new Date(year, month, 1).getTime() - 1000);
|
|
138
|
+
return { since: `${ym}-01 00:00:00`, until: `${ym}-${pad2(end.getDate())} 23:59:59` };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 历史明细跨工作区合并:按结案时间正序、按 `key` 去重,整月一次下发。
|
|
142
|
+
* 分页不在这里做 —— 整月都在前端内存里,滚动由前端按行号开窗(不再按天切片翻页)。
|
|
143
|
+
*/
|
|
144
|
+
export async function getHistoryList(workspaces, query) {
|
|
145
|
+
const today = ymdOf(new Date());
|
|
146
|
+
const ym = query.ym !== undefined && /^\d{4}-\d{2}$/.test(query.ym)
|
|
147
|
+
? query.ym
|
|
148
|
+
: today.slice(0, 7);
|
|
149
|
+
const { since, until } = monthRange(ym);
|
|
150
|
+
const settled = await settleWorkspaces(workspaces, "history", (workspace) => readHistory(workspace, {
|
|
151
|
+
since,
|
|
152
|
+
until,
|
|
153
|
+
...(query.owner === undefined ? {} : { owner: query.owner }),
|
|
154
|
+
}), (rows) => rows);
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
const merged = [];
|
|
157
|
+
for (const page of settled.data) {
|
|
158
|
+
for (const record of page.records) {
|
|
159
|
+
if (seen.has(record.key))
|
|
160
|
+
continue;
|
|
161
|
+
seen.add(record.key);
|
|
162
|
+
merged.push(record);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
merged.sort((a, b) => (a.finished < b.finished ? -1 : a.finished > b.finished ? 1 : 0));
|
|
166
|
+
return {
|
|
167
|
+
records: merged,
|
|
168
|
+
// 整月不再设条数上限(2026-11-01)⇒ 恒为 false;字段留着是为了不动下发形状
|
|
169
|
+
capped: false,
|
|
170
|
+
degraded: settled.degraded,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 机器身份(看板「设置页」用):读是谁、换是谁、列出可以换成谁。
|
|
3
|
+
*
|
|
4
|
+
* 三条都走 skill 脚本,插件不碰 identity.json(布局知识只有 skill 一份):
|
|
5
|
+
* - `whoami --json` → 读;未初始化回双 null(退 0,不是错误);
|
|
6
|
+
* - `switch --json` → 写(隐藏命令,只给界面用;init 在非终端下仍拒写);
|
|
7
|
+
* - `owners --json` → 名册(2026-11-01 起放开,没身份也能读)。
|
|
8
|
+
*/
|
|
9
|
+
import type { LedgerWorkspace } from "../types.js";
|
|
10
|
+
import { type IdentityView, type RenameView, type SwitchInput } from "../repositories/identity.repository.js";
|
|
11
|
+
export type { IdentityView, RenameView, SwitchInput };
|
|
12
|
+
export declare function getIdentity(): Promise<IdentityView>;
|
|
13
|
+
export declare function switchIdentity(input: SwitchInput): Promise<IdentityView | RenameView>;
|
|
14
|
+
/** 一个工作区里可以被认领的人(名册原样,去掉 uuid 为空的行)。 */
|
|
15
|
+
export interface RosterWorkspace {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly title: string;
|
|
18
|
+
readonly owners: readonly {
|
|
19
|
+
readonly uuid: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
}[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 各工作区花名册。**读挂了的工作区直接跳过**——名册索引缺失(`owners.json` 还没被任何写命令建出来)
|
|
25
|
+
* 与新仓库本来就没人是同一件事:引导页要把它当「这里没人,只能新建」,不能当成故障。
|
|
26
|
+
*/
|
|
27
|
+
/** 摘人结果:删掉的、因为还有任务没删的、出错的,各按工作区列出来。 */
|
|
28
|
+
export interface ForgetReport {
|
|
29
|
+
readonly removed: readonly string[];
|
|
30
|
+
readonly kept: readonly {
|
|
31
|
+
readonly workspace: string;
|
|
32
|
+
readonly tasks: number;
|
|
33
|
+
}[];
|
|
34
|
+
readonly failed: readonly {
|
|
35
|
+
readonly workspace: string;
|
|
36
|
+
readonly error: string;
|
|
37
|
+
}[];
|
|
38
|
+
/** 删的是本机身份、并且**每一本都删成了** ⇒ 顺手退出了登录。 */
|
|
39
|
+
readonly signedOut: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 把某个人从**所有登记过他的台账**里摘掉(用户 2026-11-02 定的口径):
|
|
43
|
+
* 每本台账各删各的,分片里还有任务的那本不删、如实回报;历史记录与其他台账一概不动。
|
|
44
|
+
* 名册里没有他的台账直接跳过(不是失败)。
|
|
45
|
+
*
|
|
46
|
+
* **清本机身份必须是最后一步**:删的是自己时,每本台账都跑一次 forget,若让 forget 顺手
|
|
47
|
+
* 删 identity.json,先跑完的那本会让后面几本读不到身份(报「这台机器还没有初始化身份」)。
|
|
48
|
+
* 所以 forget 一律带 --keep-identity,全部跑完之后才按需要 logout。
|
|
49
|
+
*/
|
|
50
|
+
export declare function forgetOwner(workspaces: readonly LedgerWorkspace[], uuid: string): Promise<ForgetReport>;
|
|
51
|
+
export declare function getRoster(workspaces: readonly LedgerWorkspace[]): Promise<{
|
|
52
|
+
readonly workspaces: readonly RosterWorkspace[];
|
|
53
|
+
}>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { forgetOwner as runForget, logout as runLogout, readIdentity, switchIdentity as runSwitch, } from "../repositories/identity.repository.js";
|
|
2
|
+
import { readOwners } from "../repositories/ledger.repository.js";
|
|
3
|
+
export async function getIdentity() {
|
|
4
|
+
return await readIdentity();
|
|
5
|
+
}
|
|
6
|
+
export async function switchIdentity(input) {
|
|
7
|
+
return await runSwitch(input);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 把某个人从**所有登记过他的台账**里摘掉(用户 2026-11-02 定的口径):
|
|
11
|
+
* 每本台账各删各的,分片里还有任务的那本不删、如实回报;历史记录与其他台账一概不动。
|
|
12
|
+
* 名册里没有他的台账直接跳过(不是失败)。
|
|
13
|
+
*
|
|
14
|
+
* **清本机身份必须是最后一步**:删的是自己时,每本台账都跑一次 forget,若让 forget 顺手
|
|
15
|
+
* 删 identity.json,先跑完的那本会让后面几本读不到身份(报「这台机器还没有初始化身份」)。
|
|
16
|
+
* 所以 forget 一律带 --keep-identity,全部跑完之后才按需要 logout。
|
|
17
|
+
*/
|
|
18
|
+
export async function forgetOwner(workspaces, uuid) {
|
|
19
|
+
const key = uuid.trim().toLowerCase();
|
|
20
|
+
const removed = [];
|
|
21
|
+
const kept = [];
|
|
22
|
+
const failed = [];
|
|
23
|
+
const listed = await Promise.allSettled(workspaces.map(readOwners));
|
|
24
|
+
const targets = workspaces.filter((_workspace, index) => {
|
|
25
|
+
const result = listed[index];
|
|
26
|
+
if (result.status !== "fulfilled")
|
|
27
|
+
return false;
|
|
28
|
+
return result.value.owners.some((owner) => owner.uuid === key);
|
|
29
|
+
});
|
|
30
|
+
// 摘的是不是自己:只有「自己」才需要最后退出登录
|
|
31
|
+
const me = await readIdentity().catch(() => ({ uuid: null, name: null }));
|
|
32
|
+
const isSelf = me.uuid !== null && me.uuid === key;
|
|
33
|
+
const outcomes = await Promise.allSettled(targets.map((workspace) => runForget(workspace.path, key)));
|
|
34
|
+
outcomes.forEach((result, index) => {
|
|
35
|
+
const workspace = targets[index];
|
|
36
|
+
if (result.status === "rejected") {
|
|
37
|
+
const reason = result.reason;
|
|
38
|
+
failed.push({
|
|
39
|
+
workspace: workspace.title,
|
|
40
|
+
error: reason instanceof Error ? reason.message : String(reason),
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (result.value.removed)
|
|
45
|
+
removed.push(workspace.title);
|
|
46
|
+
else
|
|
47
|
+
kept.push({ workspace: workspace.title, tasks: result.value.tasks });
|
|
48
|
+
});
|
|
49
|
+
// **全部成功才退登录**(用户 2026-11-02 拍板):只要有一本还留着人(kept)或报错(failed),
|
|
50
|
+
// 本机身份就保住 —— 人还没从台账里清干净,机器先没了身份只会更乱。
|
|
51
|
+
let signedOut = false;
|
|
52
|
+
const allDone = removed.length > 0 && kept.length === 0 && failed.length === 0;
|
|
53
|
+
if (isSelf && allDone) {
|
|
54
|
+
const anchor = (targets[0] ?? workspaces[0])?.path;
|
|
55
|
+
if (anchor === undefined) {
|
|
56
|
+
failed.push({ workspace: me.name ?? key, error: "退出登录失败:没有可用的台账目录" });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
try {
|
|
60
|
+
await runLogout(anchor);
|
|
61
|
+
signedOut = true;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
failed.push({
|
|
65
|
+
workspace: me.name ?? key,
|
|
66
|
+
error: `退出登录失败:${error instanceof Error ? error.message : String(error)}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { removed, kept, failed, signedOut };
|
|
72
|
+
}
|
|
73
|
+
export async function getRoster(workspaces) {
|
|
74
|
+
const settled = await Promise.allSettled(workspaces.map(readOwners));
|
|
75
|
+
const rows = [];
|
|
76
|
+
settled.forEach((result, index) => {
|
|
77
|
+
if (result.status !== "fulfilled")
|
|
78
|
+
return;
|
|
79
|
+
const owners = result.value;
|
|
80
|
+
rows.push({
|
|
81
|
+
id: workspaces[index].id,
|
|
82
|
+
title: workspaces[index].title,
|
|
83
|
+
owners: owners.owners
|
|
84
|
+
.filter((owner) => owner.uuid !== "")
|
|
85
|
+
.map((owner) => ({ uuid: owner.uuid, name: owner.name })),
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
return { workspaces: rows };
|
|
89
|
+
}
|
|
@@ -22,8 +22,8 @@ export declare function weekDoneFromTrend(trend: TrendView, fallback: {
|
|
|
22
22
|
readonly to: string;
|
|
23
23
|
}): WeekDone;
|
|
24
24
|
/**
|
|
25
|
-
* 首屏一次拿全:8 卡 + 在办列表 + 工作区 + 身份 +
|
|
26
|
-
*
|
|
25
|
+
* 首屏一次拿全:8 卡 + 在办列表 + 工作区 + 身份 + 各工作区花名册 + 趋势两档(周 / 日)。
|
|
26
|
+
* 四路并行(`stats` / `list` / `owners` / 趋势),各自缓存;一个工作区坏只进 degraded,其余照常。
|
|
27
27
|
*/
|
|
28
28
|
export declare function getBoardHome(workspaces: readonly LedgerWorkspace[], now?: Date): Promise<BoardHomePayload>;
|
|
29
29
|
export declare function getBoardStats(workspaces: readonly LedgerWorkspace[], now?: Date): Promise<StatsPayload>;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { settleWorkspaces } from "../errors.js";
|
|
9
9
|
import { getCurrentIdentity } from "../repositories/identity.repository.js";
|
|
10
|
-
import {
|
|
10
|
+
import { getBoardTrendGran } from "./trend.service.js";
|
|
11
11
|
import { readOwners, readTasks } from "../repositories/ledger.repository.js";
|
|
12
12
|
import { readStats } from "../repositories/stats.repository.js";
|
|
13
13
|
import { weekWindow } from "./dates.js";
|
|
@@ -30,6 +30,14 @@ export function weekDoneFromTrend(trend, fallback, fallbackLast) {
|
|
|
30
30
|
lastWeekTo: lastWeek?.to ?? fallbackLast.to,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
/** 趋势载荷 → 下发视图(`TrendPayload` 多带一份 degraded,界面不要)。 */
|
|
34
|
+
function viewOf(trend) {
|
|
35
|
+
return {
|
|
36
|
+
buckets: trend.buckets,
|
|
37
|
+
coverageFrom: trend.coverageFrom,
|
|
38
|
+
coverageTo: trend.coverageTo,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
33
41
|
/** 单工作区在办列表 + 本机身份(都经 skill:`list --json` / `whoami --json`)。 */
|
|
34
42
|
async function readTaskScope(workspace) {
|
|
35
43
|
const [tasks, identity] = await Promise.all([
|
|
@@ -50,16 +58,23 @@ function dedupe(rows) {
|
|
|
50
58
|
});
|
|
51
59
|
}
|
|
52
60
|
/**
|
|
53
|
-
* 首屏一次拿全:8 卡 + 在办列表 + 工作区 + 身份 +
|
|
54
|
-
*
|
|
61
|
+
* 首屏一次拿全:8 卡 + 在办列表 + 工作区 + 身份 + 各工作区花名册 + 趋势两档(周 / 日)。
|
|
62
|
+
* 四路并行(`stats` / `list` / `owners` / 趋势),各自缓存;一个工作区坏只进 degraded,其余照常。
|
|
55
63
|
*/
|
|
56
64
|
export async function getBoardHome(workspaces, now = new Date()) {
|
|
57
|
-
const [statsPayload, taskScope, ownerScope,
|
|
65
|
+
const [statsPayload, taskScope, ownerScope, trends] = await Promise.all([
|
|
58
66
|
getBoardStats(workspaces, now),
|
|
59
67
|
settleWorkspaces(workspaces, "list", readTaskScope, (rows) => rows),
|
|
60
68
|
settleWorkspaces(workspaces, "owners", readOwners, (rows) => rows),
|
|
61
|
-
|
|
69
|
+
// 周 / 日两档:先周后日 —— 冷缓存时周档顺手把月度缓存写下去,日档再读就是命中。
|
|
70
|
+
// 两段窗口覆盖的是同一批月份,并行跑等于把同一批历史文件读两遍。
|
|
71
|
+
(async () => ({
|
|
72
|
+
week: await getBoardTrendGran(workspaces, "week"),
|
|
73
|
+
day: await getBoardTrendGran(workspaces, "day"),
|
|
74
|
+
}))(),
|
|
62
75
|
]);
|
|
76
|
+
const trend = trends.week;
|
|
77
|
+
const trendDay = trends.day;
|
|
63
78
|
const identities = {};
|
|
64
79
|
const tasks = [];
|
|
65
80
|
for (const scope of taskScope.data) {
|
|
@@ -70,7 +85,8 @@ export async function getBoardHome(workspaces, now = new Date()) {
|
|
|
70
85
|
return {
|
|
71
86
|
...statsPayload,
|
|
72
87
|
stats: { ...statsPayload.stats, ...weeks },
|
|
73
|
-
trend:
|
|
88
|
+
trend: viewOf(trend),
|
|
89
|
+
trendDay: viewOf(trendDay),
|
|
74
90
|
workspaces,
|
|
75
91
|
tasks,
|
|
76
92
|
identities,
|
|
@@ -80,6 +96,7 @@ export async function getBoardHome(workspaces, now = new Date()) {
|
|
|
80
96
|
...taskScope.degraded,
|
|
81
97
|
...ownerScope.degraded,
|
|
82
98
|
...trend.degraded,
|
|
99
|
+
...trendDay.degraded,
|
|
83
100
|
]),
|
|
84
101
|
};
|
|
85
102
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TaskAction, type TaskActionStep } from "../repositories/task.repository.js";
|
|
2
|
+
export type { TaskAction, TaskActionStep };
|
|
3
|
+
export interface TaskActionInput {
|
|
4
|
+
readonly action: TaskAction;
|
|
5
|
+
/** 任务所在工作区根(skill 定位台账用)。 */
|
|
6
|
+
readonly repo: string;
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/** 取消时的一句话理由(可选;进历史摘要与执行记录)。 */
|
|
9
|
+
readonly summary?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* @param onStep - 过程步骤回调:路由把它写成 NDJSON 一行一行推给界面(进度翻牌)。
|
|
13
|
+
* 命令跑完 / 失败前都会先收到前面那几个阶段,界面据此换文案。
|
|
14
|
+
*/
|
|
15
|
+
export declare function actOnTask(input: TaskActionInput, onStep?: (step: TaskActionStep) => void): Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务动作(界面行内按钮 → skill 写命令):认领、释放。
|
|
3
|
+
*
|
|
4
|
+
* 写完必须清读缓存 —— 下一屏快照要看到刚改过的归属,不能吃 1.5s 的旧值。
|
|
5
|
+
*/
|
|
6
|
+
import { invalidate } from "../repositories/cache.js";
|
|
7
|
+
import { runTaskAction, } from "../repositories/task.repository.js";
|
|
8
|
+
/**
|
|
9
|
+
* @param onStep - 过程步骤回调:路由把它写成 NDJSON 一行一行推给界面(进度翻牌)。
|
|
10
|
+
* 命令跑完 / 失败前都会先收到前面那几个阶段,界面据此换文案。
|
|
11
|
+
*/
|
|
12
|
+
export async function actOnTask(input, onStep) {
|
|
13
|
+
await runTaskAction(input.repo, input.id, input.action, input.summary ?? "", onStep);
|
|
14
|
+
invalidate();
|
|
15
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TrendBucket } from "../repositories/trend.repository.js";
|
|
2
|
-
import type { LedgerWorkspace, TrendPayload, TrendView } from "../types.js";
|
|
2
|
+
import type { BoardTrendGran, LedgerWorkspace, TrendPayload, TrendView } from "../types.js";
|
|
3
3
|
/**
|
|
4
4
|
* 跨工作区求和:同一天起止的周桶相加。
|
|
5
5
|
* 工作区之间周界一致(同机同时区、都由 skill 的周一起算),所以直接按 `from` 归并。
|
|
@@ -10,4 +10,4 @@ export declare function mergeTrend(rows: readonly {
|
|
|
10
10
|
coverageTo: string;
|
|
11
11
|
}[]): TrendView;
|
|
12
12
|
/** 趋势图数据:逐工作区读、坏的进 degraded,全挂给空桶(不抛)。 */
|
|
13
|
-
export declare function
|
|
13
|
+
export declare function getBoardTrendGran(workspaces: readonly LedgerWorkspace[], gran: BoardTrendGran): Promise<TrendPayload>;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 全挂时返回空桶 + inherited degraded,界面显示空态(真源口径见 errors.ts 的 settleWorkspaces 注释)。
|
|
9
9
|
*/
|
|
10
10
|
import { codeOf, messageOf } from "../errors.js";
|
|
11
|
-
import {
|
|
11
|
+
import { readBoardTrend } from "../repositories/trend.repository.js";
|
|
12
12
|
const byFrom = (a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : 0;
|
|
13
13
|
/**
|
|
14
14
|
* 跨工作区求和:同一天起止的周桶相加。
|
|
@@ -63,8 +63,8 @@ export function mergeTrend(rows) {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
/** 趋势图数据:逐工作区读、坏的进 degraded,全挂给空桶(不抛)。 */
|
|
66
|
-
export async function
|
|
67
|
-
const settled = await Promise.allSettled(workspaces.map((workspace) =>
|
|
66
|
+
export async function getBoardTrendGran(workspaces, gran) {
|
|
67
|
+
const settled = await Promise.allSettled(workspaces.map((workspace) => readBoardTrend(workspace, gran)));
|
|
68
68
|
const rows = [];
|
|
69
69
|
const degraded = [];
|
|
70
70
|
settled.forEach((result, i) => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LedgerWorkspace, RegistryWorkspace } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 台账目录发现(用户 2026-11-01 改口径):**按机器级清单读**,不再按宿主工作区读。
|
|
4
|
+
*
|
|
5
|
+
* 宿主 registry 只剩一件事:替清单**补登记**(扫到的工作区里清单还没有的,加进去)。
|
|
6
|
+
* 补登记只在 `sync = true` 时做(用户 2026-11-03:进看板 + 手动刷新,一共两处;其余请求
|
|
7
|
+
* 一律只读清单 —— 每请求起一个进程只为对比工作区,纯浪费)。
|
|
8
|
+
* 隐藏 = 全局不看,过滤只在这里做一次 —— 下游(统计卡 / 趋势 / 列表 / 名册)全吃这个输出。
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveWorkspaces(registryList: readonly RegistryWorkspace[], sync?: boolean): Promise<LedgerWorkspace[]>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { addDir, readDirs } from "../repositories/dirs.repository.js";
|
|
2
|
+
/**
|
|
3
|
+
* 台账目录发现(用户 2026-11-01 改口径):**按机器级清单读**,不再按宿主工作区读。
|
|
4
|
+
*
|
|
5
|
+
* 宿主 registry 只剩一件事:替清单**补登记**(扫到的工作区里清单还没有的,加进去)。
|
|
6
|
+
* 补登记只在 `sync = true` 时做(用户 2026-11-03:进看板 + 手动刷新,一共两处;其余请求
|
|
7
|
+
* 一律只读清单 —— 每请求起一个进程只为对比工作区,纯浪费)。
|
|
8
|
+
* 隐藏 = 全局不看,过滤只在这里做一次 —— 下游(统计卡 / 趋势 / 列表 / 名册)全吃这个输出。
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveWorkspaces(registryList, sync = false) {
|
|
11
|
+
let dirs = await readDirs();
|
|
12
|
+
if (sync) {
|
|
13
|
+
const known = new Set(dirs.map((dir) => dir.path));
|
|
14
|
+
for (const workspace of registryList) {
|
|
15
|
+
if (known.has(workspace.path))
|
|
16
|
+
continue;
|
|
17
|
+
// 串行:读改写同一个文件,并发会互相覆盖(skill 侧虽有锁,最后写入者仍可能少几条)
|
|
18
|
+
const added = await addDir(workspace.path, workspace.title);
|
|
19
|
+
known.add(workspace.path);
|
|
20
|
+
if (added.added)
|
|
21
|
+
dirs = added.dirs;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return dirs.filter((dir) => !dir.hidden).map(toWorkspace);
|
|
25
|
+
}
|
|
26
|
+
function toWorkspace(dir) {
|
|
27
|
+
return { id: dir.path, title: dir.name, path: dir.path };
|
|
28
|
+
}
|