@stofone/dsh-agent-roster 0.0.0-stage → 0.5.0

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/lib/verify.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * 核对:把"档案里写的"和"运行时实际记下的"逐字段摆在一起。
3
+ *
4
+ * 这一步是整个插件的判据所在:桥接参数生成得再漂亮,成员也可能因为运行时采用了队长默认模型、
5
+ * 或者策略 id 不存在而悄悄降级;只有比对 `team.json` 里的实际值才能发现。
6
+ *
7
+ * 两条补充规则:
8
+ * - `aliases` 让改名后的成员仍然认得出旧名。团队状态里存的是旧名时不算漂移,
9
+ * 否则每次重命名都会制造一堆"未托管"的假报警。
10
+ * - `skills` 是声明层字段,运行时不会帮你加载技能;宿主把技能目录传进来时,
11
+ * 这里只负责回答"声明了但没装"这一件事,不假装已生效。
12
+ *
13
+ * @module dsh-agent-roster/verify
14
+ */
15
+ import { buildMemberBinding, isBindingFaithful, reconcileBinding, summarizeBinding } from "./bridge.js";
16
+
17
+ /**
18
+ * 逐个成员核对档案与团队状态。
19
+ *
20
+ * @param {object[]} rosters 已校验档案
21
+ * @param {object[]} teams `readTeamStates` 产出的团队记录
22
+ * @param {{ knownSkills?: string[] }} [options] 宿主已知技能目录;缺省表示无法核对技能
23
+ * @returns {{ checks: object[], unmanaged: object[], skills: object[], summary: object }}
24
+ */
25
+ export function verifyBindings(rosters, teams, options = {}) {
26
+ const knownSkills =
27
+ Array.isArray(options.knownSkills) && options.knownSkills.length > 0
28
+ ? new Set(options.knownSkills.filter((name) => typeof name === "string" && name !== ""))
29
+ : null;
30
+
31
+ const checks = [];
32
+ const managedNames = new Set();
33
+
34
+ for (const roster of rosters) {
35
+ for (const member of roster.members) {
36
+ managedNames.add(member.name);
37
+ const aliases = new Set(Array.isArray(member.aliases) ? member.aliases : []);
38
+ for (const alias of aliases) managedNames.add(alias);
39
+
40
+ const matches = [];
41
+ for (const team of teams) {
42
+ const found = team.members.find(
43
+ (candidate) => candidate.name === member.name || aliases.has(candidate.name),
44
+ );
45
+ if (found !== undefined) matches.push({ team, teamMember: found });
46
+ }
47
+
48
+ if (matches.length === 0) {
49
+ checks.push({
50
+ rosterId: roster.id,
51
+ rosterVersion: roster.version,
52
+ member: member.name,
53
+ role: member.role,
54
+ found: false,
55
+ teamId: null,
56
+ teamName: null,
57
+ matchedAs: null,
58
+ binding: null,
59
+ faithful: false,
60
+ summary: null,
61
+ });
62
+ continue;
63
+ }
64
+
65
+ for (const { team, teamMember } of matches) {
66
+ const { binding } = buildMemberBinding(roster, member);
67
+ reconcileBinding(binding, teamMember);
68
+ // 通过别名匹配上时,`name` 本来就该不同:把它记成"降级"会让每次改名都变成假报警。
69
+ if (teamMember.name !== member.name) {
70
+ const nameField = binding.fields.find((field) => field.field === "name");
71
+ if (nameField !== undefined) {
72
+ nameField.status = "applied";
73
+ nameField.actual = teamMember.name;
74
+ nameField.note = `运行时以曾用名 "${teamMember.name}" 记录`;
75
+ }
76
+ }
77
+ checks.push({
78
+ rosterId: roster.id,
79
+ rosterVersion: roster.version,
80
+ member: member.name,
81
+ role: member.role,
82
+ found: true,
83
+ teamId: team.id,
84
+ teamName: team.name,
85
+ matchedAs: teamMember.name,
86
+ binding,
87
+ faithful: isBindingFaithful(binding),
88
+ summary: summarizeBinding(binding),
89
+ });
90
+ }
91
+ }
92
+ }
93
+
94
+ const unmanaged = [];
95
+ for (const team of teams) {
96
+ for (const member of team.members) {
97
+ if (managedNames.has(member.name)) continue;
98
+ unmanaged.push({
99
+ teamId: team.id,
100
+ teamName: team.name,
101
+ name: typeof member.name === "string" ? member.name : "",
102
+ provider: typeof member.provider === "string" ? member.provider : "",
103
+ model: typeof member.model === "string" ? member.model : "",
104
+ toolPolicy: typeof member.toolPolicy === "string" ? member.toolPolicy : "",
105
+ });
106
+ }
107
+ }
108
+
109
+ const skills = [];
110
+ for (const roster of rosters) {
111
+ for (const member of roster.members) {
112
+ const declared = Array.isArray(member.skills) ? member.skills : [];
113
+ if (declared.length === 0) continue;
114
+ const missing = knownSkills === null ? [] : declared.filter((name) => !knownSkills.has(name));
115
+ skills.push({
116
+ rosterId: roster.id,
117
+ member: member.name,
118
+ declared,
119
+ missing,
120
+ checked: knownSkills !== null,
121
+ });
122
+ }
123
+ }
124
+
125
+ const summary = {
126
+ members: checks.length,
127
+ deployed: checks.filter((check) => check.found).length,
128
+ faithful: checks.filter((check) => check.found && check.faithful).length,
129
+ degraded: checks.filter((check) => check.found && !check.faithful).length,
130
+ missing: checks.filter((check) => !check.found).length,
131
+ unmanaged: unmanaged.length,
132
+ skillsDeclared: skills.length,
133
+ skillsMissing: skills.filter((item) => item.missing.length > 0).length,
134
+ skillsUnverified: skills.filter((item) => !item.checked).length,
135
+ };
136
+
137
+ return { checks, unmanaged, skills, summary };
138
+ }
139
+
140
+ /**
141
+ * 只核对指定成员(工具的参数级筛选);其余成员不进入结果。
142
+ *
143
+ * 筛选同时接受成员名与它的别名,免得对着旧名筛不出东西。
144
+ *
145
+ * @param {object} report `verifyBindings` 的结果
146
+ * @param {string[]} names 成员名或别名
147
+ * @returns {object}
148
+ */
149
+ export function filterChecks(report, names) {
150
+ if (!Array.isArray(names) || names.length === 0) return report;
151
+ const wanted = new Set(names);
152
+ const checks = report.checks.filter(
153
+ (check) => wanted.has(check.member) || (check.matchedAs !== null && wanted.has(check.matchedAs)),
154
+ );
155
+ const summary = {
156
+ ...report.summary,
157
+ members: checks.length,
158
+ deployed: checks.filter((check) => check.found).length,
159
+ faithful: checks.filter((check) => check.found && check.faithful).length,
160
+ degraded: checks.filter((check) => check.found && !check.faithful).length,
161
+ missing: checks.filter((check) => !check.found).length,
162
+ };
163
+ return { ...report, checks, summary };
164
+ }
package/lib/write.js ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * 档案文件的读写:面板的"新建/编辑/导入/删除"落到磁盘的那一层。
3
+ *
4
+ * 安全边界都在这里:
5
+ * - 只能写进配置里声明过的档案库目录(`config.rosterDirs` × 已知工作区解析出的绝对路径),
6
+ * 客户端传来的 `rootDir` 必须精确等于其中之一,杜绝"借面板写任意路径";
7
+ * - 档案 id 只允许 `[a-z0-9][a-z0-9._-]*`,且必须是校验通过的文档里的 id,
8
+ * 因此路径片段不会包含分隔符或 `..`;
9
+ * - 写入前先解析 + 校验,校验不过就一个字节都不落盘;
10
+ * - 落盘用"同目录临时文件 + rename",中途失败不会留下半份档案。
11
+ *
12
+ * @module dsh-agent-roster/write
13
+ */
14
+ import fs from "node:fs";
15
+ import nodePath from "node:path";
16
+
17
+ import { MAX_DOCUMENT_BYTES, PATTERNS, ROSTER_KIND } from "./format.js";
18
+ import { isGlobalDirSpec, resolveDirSpec } from "./home.js";
19
+ import { formatOf, parseDocumentText } from "./parse.js";
20
+ import { validateRoster } from "./validate.js";
21
+
22
+ /** 每种格式落盘时的文件名。 */
23
+ export const FORMAT_FILE_NAMES = {
24
+ json: "roster.json",
25
+ yaml: "roster.yaml",
26
+ yml: "roster.yaml",
27
+ };
28
+
29
+ /** 一个档案目录里会被本体认作"档案文件"的名字。 */
30
+ const ROSTER_FILE_NAMES = ["roster.json", "roster.yaml", "roster.yml"];
31
+
32
+ /** 覆盖时保留的上一版备份后缀(扫描器不认识,因此不会当成第二份档案)。 */
33
+ const BACKUP_SUFFIX = ".bak";
34
+
35
+ /**
36
+ * 解析本轮允许写的档案库根目录。
37
+ *
38
+ * 默认(`rosterDirs` 为空)只解析一个全局根目录 `<DSH_HOME>/agent-roster`,
39
+ * 与工作区无关。只有显式配置成相对路径的目录才按工作区展开。
40
+ *
41
+ * @param {{ rosterDirs?: string[], workspaces: { path: string }[] }} input
42
+ * @returns {{ dirSpec: string, workspacePath: string, rootDir: string, exists: boolean }[]}
43
+ */
44
+ export function listLibraryRoots(input) {
45
+ const dirSpecs = Array.isArray(input.rosterDirs) && input.rosterDirs.length > 0 ? input.rosterDirs : [""];
46
+ const workspaces =
47
+ Array.isArray(input.workspaces) && input.workspaces.length > 0 ? input.workspaces : [{ path: process.cwd() }];
48
+ const roots = [];
49
+ for (const dirSpec of dirSpecs) {
50
+ if (isGlobalDirSpec(dirSpec)) {
51
+ const rootDir = resolveDirSpec(dirSpec);
52
+ roots.push({ dirSpec, workspacePath: "", rootDir, exists: fs.existsSync(rootDir) });
53
+ continue;
54
+ }
55
+ for (const workspace of workspaces) {
56
+ const rootDir = resolveDirSpec(dirSpec, workspace.path);
57
+ roots.push({ dirSpec, workspacePath: workspace.path, rootDir, exists: fs.existsSync(rootDir) });
58
+ }
59
+ }
60
+ return roots;
61
+ }
62
+
63
+ /**
64
+ * 在允许列表里按绝对路径找根目录(Windows 路径比较不区分大小写)。
65
+ *
66
+ * @param {{ rootDir: string }[]} roots
67
+ * @param {string} rootDir
68
+ * @returns {object | null}
69
+ */
70
+ export function findLibraryRoot(roots, rootDir) {
71
+ if (typeof rootDir !== "string" || rootDir === "") return null;
72
+ const wanted = nodePath.resolve(rootDir);
73
+ const key = process.platform === "win32" ? wanted.toLowerCase() : wanted;
74
+ return roots.find((root) => (process.platform === "win32" ? root.rootDir.toLowerCase() : root.rootDir) === key) ?? null;
75
+ }
76
+
77
+ /**
78
+ * 档案 id 是否可安全用作路径片段。
79
+ *
80
+ * @param {string} id
81
+ * @returns {boolean}
82
+ */
83
+ export function isSafeRosterId(id) {
84
+ if (typeof id !== "string" || id.length === 0 || id.length > 64) return false;
85
+ if (!PATTERNS.id.test(id)) return false;
86
+ if (id === "." || id === "..") return false;
87
+ return !id.includes("/") && !id.includes("\\");
88
+ }
89
+
90
+ /**
91
+ * 目标目录必须严格落在档案库根目录内部(防御性双保险,id 已经过滤过)。
92
+ *
93
+ * @param {string} rootDir
94
+ * @param {string} id
95
+ * @returns {string | null} 绝对目录;越界返回 null
96
+ */
97
+ export function resolveRosterDir(rootDir, id) {
98
+ if (!isSafeRosterId(id)) return null;
99
+ const dir = nodePath.resolve(rootDir, id);
100
+ const relative = nodePath.relative(nodePath.resolve(rootDir), dir);
101
+ if (relative === "" || relative.startsWith("..") || nodePath.isAbsolute(relative)) return null;
102
+ if (relative.includes(nodePath.sep)) return null;
103
+ return dir;
104
+ }
105
+
106
+ /**
107
+ * 读一份档案的原始文本,供编辑框回填。
108
+ *
109
+ * @param {{ roots: object[], rootDir: string, id: string }} input
110
+ * @returns {{ ok: true, file: string, format: string, text: string } | { ok: false, code: string }}
111
+ */
112
+ export function readRosterDocument(input) {
113
+ const root = findLibraryRoot(input.roots, input.rootDir);
114
+ if (root === null) return { ok: false, code: "unknown-root" };
115
+ const dir = resolveRosterDir(root.rootDir, input.id);
116
+ if (dir === null) return { ok: false, code: "invalid-id" };
117
+
118
+ for (const name of ROSTER_FILE_NAMES) {
119
+ const file = nodePath.join(dir, name);
120
+ if (!fs.existsSync(file)) continue;
121
+ try {
122
+ const text = fs.readFileSync(file, "utf8");
123
+ return { ok: true, file: `${input.id}/${name}`, format: formatOf(name), text };
124
+ } catch {
125
+ return { ok: false, code: "unreadable-file" };
126
+ }
127
+ }
128
+ return { ok: false, code: "not-found" };
129
+ }
130
+
131
+ /**
132
+ * 校验并写下一份档案(新建或覆盖)。
133
+ *
134
+ * @param {{
135
+ * roots: object[],
136
+ * rootDir: string,
137
+ * text: string,
138
+ * format?: string,
139
+ * overwrite?: boolean,
140
+ * knownToolPolicies?: string[] | null,
141
+ * }} input
142
+ * @returns {{
143
+ * ok: true,
144
+ * file: string,
145
+ * format: string,
146
+ * roster: { id: string, title: string, members: number },
147
+ * removed: string[],
148
+ * } | { ok: false, code: string, diagnostics?: object[], existing?: { file: string, format: string } }}
149
+ */
150
+ export function writeRosterDocument(input) {
151
+ const root = findLibraryRoot(input.roots, input.rootDir);
152
+ if (root === null) return { ok: false, code: "unknown-root" };
153
+
154
+ if (typeof input.text !== "string" || input.text.trim() === "") {
155
+ return { ok: false, code: "empty-document", diagnostics: [] };
156
+ }
157
+ if (Buffer.byteLength(input.text, "utf8") > MAX_DOCUMENT_BYTES) {
158
+ return { ok: false, code: "document-too-large", diagnostics: [] };
159
+ }
160
+
161
+ const format = resolveFormat(input.format, input.text);
162
+ if (format === null) return { ok: false, code: "unknown-format", diagnostics: [] };
163
+
164
+ const parsed = parseDocumentText(input.text, { file: "roster", format });
165
+ if (!parsed.ok) return { ok: false, code: "invalid-document", diagnostics: parsed.diagnostics };
166
+
167
+ const validated = validateRoster(parsed.value, {
168
+ file: "roster",
169
+ knownToolPolicies: input.knownToolPolicies ?? null,
170
+ });
171
+ if (!validated.ok) {
172
+ // id 本身不合法时单独归类:这是"换个名字就能存"的问题,不该混进"文档结构有问题"。
173
+ const onlyId =
174
+ validated.diagnostics.length > 0 && validated.diagnostics.every((item) => item.code === "invalid-id");
175
+ return {
176
+ ok: false,
177
+ code: onlyId ? "invalid-id" : "invalid-document",
178
+ diagnostics: validated.diagnostics.map((item) => ({ ...item, file: "" })),
179
+ };
180
+ }
181
+
182
+ const roster = validated.roster;
183
+ if (roster.kind !== ROSTER_KIND) return { ok: false, code: "invalid-document", diagnostics: validated.diagnostics };
184
+ if (!isSafeRosterId(roster.id)) {
185
+ return {
186
+ ok: false,
187
+ code: "invalid-id",
188
+ diagnostics: validated.diagnostics.filter((item) => item.code === "invalid-id"),
189
+ };
190
+ }
191
+
192
+ const dir = resolveRosterDir(root.rootDir, roster.id);
193
+ if (dir === null) return { ok: false, code: "invalid-id" };
194
+
195
+ // 同一目录里已有的档案文件:同名覆盖要显式确认,异格式要显式确认"换格式"。
196
+ const existing = [];
197
+ for (const name of ROSTER_FILE_NAMES) {
198
+ const file = nodePath.join(dir, name);
199
+ if (fs.existsSync(file)) existing.push({ name, format: formatOf(name) });
200
+ }
201
+ const sameFormat = existing.find((item) => item.format === format);
202
+ const otherFormats = existing.filter((item) => item.format !== format);
203
+ if (sameFormat !== undefined && input.overwrite !== true) {
204
+ return { ok: false, code: "exists", existing: { file: `${roster.id}/${sameFormat.name}`, format } };
205
+ }
206
+ if (otherFormats.length > 0 && input.overwrite !== true) {
207
+ return {
208
+ ok: false,
209
+ code: "format-conflict",
210
+ existing: { file: `${roster.id}/${otherFormats[0].name}`, format: otherFormats[0].format },
211
+ };
212
+ }
213
+
214
+ const targetName = FORMAT_FILE_NAMES[format];
215
+ const target = nodePath.join(dir, targetName);
216
+ const removed = [];
217
+
218
+ try {
219
+ fs.mkdirSync(dir, { recursive: true });
220
+ if (sameFormat !== undefined) {
221
+ fs.copyFileSync(target, `${target}${BACKUP_SUFFIX}`);
222
+ }
223
+ for (const item of otherFormats) {
224
+ // 换格式时把旧文件挪成备份,而不是删掉:用户能反悔。
225
+ fs.renameSync(nodePath.join(dir, item.name), nodePath.join(dir, `${item.name}${BACKUP_SUFFIX}`));
226
+ removed.push(`${roster.id}/${item.name}`);
227
+ }
228
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
229
+ fs.writeFileSync(temporary, input.text, "utf8");
230
+ fs.renameSync(temporary, target);
231
+ } catch {
232
+ return { ok: false, code: "write-failed" };
233
+ }
234
+
235
+ return {
236
+ ok: true,
237
+ file: `${roster.id}/${targetName}`,
238
+ format,
239
+ roster: { id: roster.id, title: roster.title ?? roster.id, members: roster.members.length },
240
+ removed,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * 删除一份档案:只删本体认得的档案文件名,不碰同目录的其它文件,也不递归删除。
246
+ *
247
+ * @param {{ roots: object[], rootDir: string, id: string }} input
248
+ * @returns {{ ok: true, removed: string[] } | { ok: false, code: string }}
249
+ */
250
+ export function deleteRosterDocument(input) {
251
+ const root = findLibraryRoot(input.roots, input.rootDir);
252
+ if (root === null) return { ok: false, code: "unknown-root" };
253
+ const dir = resolveRosterDir(root.rootDir, input.id);
254
+ if (dir === null) return { ok: false, code: "invalid-id" };
255
+ if (!fs.existsSync(dir)) return { ok: false, code: "not-found" };
256
+
257
+ const removed = [];
258
+ for (const name of ROSTER_FILE_NAMES) {
259
+ const file = nodePath.join(dir, name);
260
+ if (!fs.existsSync(file)) continue;
261
+ try {
262
+ fs.unlinkSync(file);
263
+ removed.push(`${input.id}/${name}`);
264
+ } catch {
265
+ return { ok: false, code: "delete-failed" };
266
+ }
267
+ }
268
+ if (removed.length === 0) return { ok: false, code: "not-found" };
269
+
270
+ // 目录空了就顺手收掉;还有别的文件(备份、README)时保留目录。
271
+ try {
272
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
273
+ } catch {
274
+ // 收不掉不算失败:文件已经删掉了。
275
+ }
276
+ return { ok: true, removed };
277
+ }
278
+
279
+ /**
280
+ * 推断格式:显式指定优先,否则按文本首字符推断(`{`/`[` 视为 JSON)。
281
+ *
282
+ * @param {unknown} declared
283
+ * @param {string} text
284
+ * @returns {"json" | "yaml" | null}
285
+ */
286
+ function resolveFormat(declared, text) {
287
+ if (declared === "json" || declared === "yaml") return declared;
288
+ if (declared !== undefined && declared !== null && declared !== "") return null;
289
+ return text.trimStart().startsWith("{") ? "json" : "yaml";
290
+ }