@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/CHANGELOG.md +139 -0
- package/LICENSE +21 -0
- package/README.md +290 -3
- package/README.zh.md +282 -0
- package/bin/cli.mjs +325 -0
- package/client.js +1950 -0
- package/cordis.patch.yml +9 -0
- package/examples/core-review/roster.yaml +82 -0
- package/examples/research/roster.yaml +42 -0
- package/icon.svg +11 -0
- package/index/registry.json +23 -0
- package/index.js +298 -0
- package/lib/bridge.js +233 -0
- package/lib/config-view.js +59 -0
- package/lib/format.js +223 -0
- package/lib/home.js +108 -0
- package/lib/http.js +122 -0
- package/lib/json.js +89 -0
- package/lib/library.js +161 -0
- package/lib/parse.js +148 -0
- package/lib/registry.js +285 -0
- package/lib/remote.js +623 -0
- package/lib/render.js +343 -0
- package/lib/serialize.js +92 -0
- package/lib/service.js +421 -0
- package/lib/teams-state.js +146 -0
- package/lib/tools.js +601 -0
- package/lib/validate.js +747 -0
- package/lib/verify.js +164 -0
- package/lib/write.js +290 -0
- package/locale/en.json +245 -0
- package/locale/zh.json +245 -0
- package/package.json +98 -4
- package/schema/roster.v1.schema.json +193 -0
package/lib/service.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板快照:档案库 + 团队状态 + 配置体检,一次给前端。
|
|
3
|
+
*
|
|
4
|
+
* 与 `lib/library.js` 的分工:那一层只投影档案(纯文件扫描,工具也在用),
|
|
5
|
+
* 这一层是"面板视角"——把团队状态、部署情况、配置提示折叠进同一个响应,
|
|
6
|
+
* 让前端不用发三次请求、也不用自己判断"这份档案到底有没有进运行时"。
|
|
7
|
+
*
|
|
8
|
+
* 提示一律用 **code + params** 表达,不出中文/英文散文:文案由客户端词典渲染,
|
|
9
|
+
* 这样同一份快照能同时喂给中文和英文界面,也让快照本身可被工具与测试断言。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-agent-roster/service
|
|
12
|
+
*/
|
|
13
|
+
import nodePath from "node:path";
|
|
14
|
+
|
|
15
|
+
import { globalRosterRoot } from "./home.js";
|
|
16
|
+
import { collectLibrary, listWorkspaces } from "./library.js";
|
|
17
|
+
import { CACHE_DIR_NAME, fetchCollection, loadRegistryIndex } from "./remote.js";
|
|
18
|
+
import { rosterDocumentText } from "./serialize.js";
|
|
19
|
+
import { readTeamStates, resolveStateDir } from "./teams-state.js";
|
|
20
|
+
import { listLibraryRoots, readRosterDocument, writeRosterDocument } from "./write.js";
|
|
21
|
+
|
|
22
|
+
/** 会参与"档案值 vs 运行时值"比对的字段。 */
|
|
23
|
+
const COMPARED_FIELDS = ["provider", "model", "reasoningEffort", "toolPolicy"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 本轮允许写的档案库根目录(配置里的目录;全局目录与工作区无关)。
|
|
27
|
+
*
|
|
28
|
+
* @param {{ get?: (key: string) => unknown }} ctx
|
|
29
|
+
* @param {object} config
|
|
30
|
+
* @returns {object[]}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveRoots(ctx, config) {
|
|
33
|
+
return listLibraryRoots({
|
|
34
|
+
rosterDirs: Array.isArray(config.rosterDirs) ? config.rosterDirs : [],
|
|
35
|
+
workspaces: listWorkspaces(ctx).map((workspace) => ({ path: workspace.path })),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 组装面板快照。
|
|
41
|
+
*
|
|
42
|
+
* @param {{ get?: (key: string) => unknown }} ctx 宿主上下文(只用到工作区注册表)
|
|
43
|
+
* @param {object} config 插件配置(已解引用)
|
|
44
|
+
* @param {{ name: string, version: string }} meta 插件自身元信息
|
|
45
|
+
* @param {{ now?: () => Date }} [options] 便于测试固定时间
|
|
46
|
+
* @returns {object}
|
|
47
|
+
*/
|
|
48
|
+
export function buildPanelSnapshot(ctx, config, meta, options = {}) {
|
|
49
|
+
const library = collectLibrary(ctx, config, meta);
|
|
50
|
+
const now = options.now ?? (() => new Date());
|
|
51
|
+
|
|
52
|
+
const roots = listLibraryRoots({
|
|
53
|
+
rosterDirs: library.rosterDirs,
|
|
54
|
+
workspaces: library.workspaces.map((workspace) => ({ path: workspace.workspacePath })),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const rosters = [];
|
|
58
|
+
for (const workspace of library.workspaces) {
|
|
59
|
+
const rootExists = roots.find((root) => root.rootDir === workspace.rootDir)?.exists ?? false;
|
|
60
|
+
workspace.rootExists = rootExists;
|
|
61
|
+
// 面板要能"照着这份档案去写文件",所以把所在的库根目录一并带给前端;
|
|
62
|
+
// 客户端传回来的 rootDir 仍会被 listLibraryRoots 白名单校验,不是信任输入。
|
|
63
|
+
for (const roster of workspace.rosters) {
|
|
64
|
+
rosters.push({ ...roster, rootDir: workspace.rootDir, workspacePath: workspace.workspacePath });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const teams = projectTeams({ spec: config.agentTeamsStateDir, workspaces: library.workspaces, rosters });
|
|
69
|
+
|
|
70
|
+
const diagnostics = [
|
|
71
|
+
...library.workspaces.flatMap((workspace) => workspace.diagnostics),
|
|
72
|
+
...teams.diagnostics,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const hints = buildHints({ library, roots, rosters, teams, config });
|
|
76
|
+
const deployments = countDeployments(rosters, teams.teams);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
plugin: meta.name,
|
|
80
|
+
version: meta.version,
|
|
81
|
+
generatedAt: now().toISOString(),
|
|
82
|
+
config: {
|
|
83
|
+
rosterDirs: library.rosterDirs,
|
|
84
|
+
dshHome: library.dshHome,
|
|
85
|
+
defaultRoot: globalRosterRoot(),
|
|
86
|
+
knownToolPolicies: Array.isArray(config.knownToolPolicies) ? config.knownToolPolicies : [],
|
|
87
|
+
stateDirSpec: config.agentTeamsStateDir,
|
|
88
|
+
},
|
|
89
|
+
workspaces: library.workspaces,
|
|
90
|
+
teams,
|
|
91
|
+
deployments,
|
|
92
|
+
hints,
|
|
93
|
+
summary: {
|
|
94
|
+
rosters: rosters.length,
|
|
95
|
+
members: rosters.reduce((total, roster) => total + roster.members.length, 0),
|
|
96
|
+
teams: teams.teams.length,
|
|
97
|
+
errors: diagnostics.filter((item) => item.severity === "error").length,
|
|
98
|
+
warnings: diagnostics.filter((item) => item.severity === "warn").length,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 读团队状态并投影成面板能安全渲染的形状。
|
|
105
|
+
*
|
|
106
|
+
* 成员的人设正文不外发(只给字数):面板要的是"谁在队里、模型对不对、是否来自档案",
|
|
107
|
+
* 不是把别人的 system prompt 摊在界面上。
|
|
108
|
+
*
|
|
109
|
+
* @param {{ spec: string, workspaces: object[], rosters: object[] }} input
|
|
110
|
+
* @returns {object}
|
|
111
|
+
*/
|
|
112
|
+
export function projectTeams(input) {
|
|
113
|
+
const specs = new Set();
|
|
114
|
+
const teams = [];
|
|
115
|
+
const diagnostics = [];
|
|
116
|
+
let available = false;
|
|
117
|
+
let stateDir = "";
|
|
118
|
+
|
|
119
|
+
for (const workspace of input.workspaces) {
|
|
120
|
+
const abs = resolveStateDir(input.spec, workspace.workspacePath);
|
|
121
|
+
if (stateDir === "") stateDir = abs;
|
|
122
|
+
if (specs.has(abs)) continue;
|
|
123
|
+
specs.add(abs);
|
|
124
|
+
|
|
125
|
+
const read = readTeamStates({ stateDir: abs });
|
|
126
|
+
available = available || read.available;
|
|
127
|
+
diagnostics.push(...read.diagnostics);
|
|
128
|
+
const rosterIndex = indexRosterMembers(input.rosters);
|
|
129
|
+
for (const team of read.teams) {
|
|
130
|
+
teams.push(projectTeam(team, rosterIndex));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { stateDirSpec: input.spec, stateDir, available, teams, diagnostics };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 单个团队的投影。
|
|
139
|
+
*
|
|
140
|
+
* @param {object} team `readTeamStates` 的团队记录
|
|
141
|
+
* @param {Map<string, object>} rosterIndex 成员名 → 档案成员
|
|
142
|
+
* @returns {object}
|
|
143
|
+
*/
|
|
144
|
+
function projectTeam(team, rosterIndex) {
|
|
145
|
+
const members = team.members.map((member) => {
|
|
146
|
+
const name = typeof member.name === "string" ? member.name : "";
|
|
147
|
+
const managed = rosterIndex.get(name) ?? null;
|
|
148
|
+
const drift = [];
|
|
149
|
+
if (managed !== null) {
|
|
150
|
+
for (const field of COMPARED_FIELDS) {
|
|
151
|
+
const wanted = managed.member[field];
|
|
152
|
+
const actual = member[field];
|
|
153
|
+
if (typeof wanted === "string" && wanted !== "" && typeof actual === "string" && actual !== "" && wanted !== actual) {
|
|
154
|
+
drift.push({ field, roster: wanted, runtime: actual });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
name,
|
|
160
|
+
role: typeof member.role === "string" ? member.role : "",
|
|
161
|
+
provider: typeof member.provider === "string" ? member.provider : "",
|
|
162
|
+
model: typeof member.model === "string" ? member.model : "",
|
|
163
|
+
reasoningEffort: typeof member.reasoningEffort === "string" ? member.reasoningEffort : "",
|
|
164
|
+
toolPolicy: typeof member.toolPolicy === "string" ? member.toolPolicy : "",
|
|
165
|
+
promptChars: typeof member.executionPrompt === "string" ? member.executionPrompt.length : 0,
|
|
166
|
+
rosterId: managed === null ? null : managed.rosterId,
|
|
167
|
+
faithful: managed !== null && drift.length === 0,
|
|
168
|
+
drift,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const tasks = Array.isArray(team.tasks) ? team.tasks : [];
|
|
173
|
+
const count = (status) => tasks.filter((task) => task?.status === status).length;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
id: team.id,
|
|
177
|
+
name: team.name,
|
|
178
|
+
description: typeof team.description === "string" ? team.description : "",
|
|
179
|
+
phase: team.phase,
|
|
180
|
+
halted: team.halted === true,
|
|
181
|
+
escalated: team.escalated === true,
|
|
182
|
+
planReviewState: typeof team.planReviewState === "string" ? team.planReviewState : null,
|
|
183
|
+
captainSessionId: typeof team.captainSessionId === "string" ? team.captainSessionId : "",
|
|
184
|
+
members,
|
|
185
|
+
tasks: {
|
|
186
|
+
total: tasks.length,
|
|
187
|
+
completed: count("completed"),
|
|
188
|
+
failed: count("failed"),
|
|
189
|
+
pending: count("pending"),
|
|
190
|
+
active: tasks.length - count("completed") - count("failed") - count("pending"),
|
|
191
|
+
},
|
|
192
|
+
file: team.file,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 成员名 → 档案成员 的索引(同名多份档案时以先扫到的为准,与工具取值顺序一致)。
|
|
198
|
+
*
|
|
199
|
+
* @param {object[]} rosters
|
|
200
|
+
* @returns {Map<string, { rosterId: string, member: object }>}
|
|
201
|
+
*/
|
|
202
|
+
function indexRosterMembers(rosters) {
|
|
203
|
+
const index = new Map();
|
|
204
|
+
for (const roster of rosters) {
|
|
205
|
+
for (const member of roster.members) {
|
|
206
|
+
if (!index.has(member.name)) index.set(member.name, { rosterId: roster.id, member });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return index;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 统计每份档案的部署情况:文件里有几个成员、运行时真的出现了几个、字段是否有漂移。
|
|
214
|
+
*
|
|
215
|
+
* @param {object[]} rosters
|
|
216
|
+
* @param {object[]} teams
|
|
217
|
+
* @returns {Record<string, { deployed: number, total: number, drifted: number, teams: string[] }>}
|
|
218
|
+
*/
|
|
219
|
+
export function countDeployments(rosters, teams) {
|
|
220
|
+
const result = {};
|
|
221
|
+
for (const roster of rosters) {
|
|
222
|
+
const names = new Set(roster.members.map((member) => member.name));
|
|
223
|
+
const deployed = new Set();
|
|
224
|
+
const drifted = new Set();
|
|
225
|
+
const teamNames = new Set();
|
|
226
|
+
for (const team of teams) {
|
|
227
|
+
for (const member of team.members) {
|
|
228
|
+
if (member.rosterId !== roster.id) continue;
|
|
229
|
+
deployed.add(member.name);
|
|
230
|
+
teamNames.add(team.name || team.id);
|
|
231
|
+
if (!member.faithful) drifted.add(member.name);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
result[roster.id] = {
|
|
235
|
+
deployed: [...names].filter((name) => deployed.has(name)).length,
|
|
236
|
+
total: names.size,
|
|
237
|
+
drifted: drifted.size,
|
|
238
|
+
teams: [...teamNames],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 配置与环境体检:只产出 code + params,客户端负责措辞。
|
|
246
|
+
*
|
|
247
|
+
* @param {object} input
|
|
248
|
+
* @returns {{ code: string, severity: "info" | "warn" | "error", params: object }[]}
|
|
249
|
+
*/
|
|
250
|
+
export function buildHints(input) {
|
|
251
|
+
const hints = [];
|
|
252
|
+
const { library, rosters, teams, config } = input;
|
|
253
|
+
|
|
254
|
+
// 空档案库是初始状态,不是问题:目录没建(首次保存会自动建)、目录里还没有
|
|
255
|
+
// 档案、以及"配置里写了相对路径"这类正常情况都不出提示。体检页只说真正
|
|
256
|
+
// 需要用户决定的事——所以这里没有 no-rosters / roster-dir-missing 之类的条目。
|
|
257
|
+
if (library.workspaces.some((workspace) => workspace.fallbackWorkspace)) {
|
|
258
|
+
hints.push({ code: "workspace-fallback", severity: "info", params: {} });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const policies = Array.isArray(config.knownToolPolicies) ? config.knownToolPolicies : [];
|
|
262
|
+
if (policies.length === 0 && rosters.some((roster) => roster.members.some((member) => member.toolPolicy))) {
|
|
263
|
+
hints.push({ code: "tool-policies-unset", severity: "info", params: {} });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (teams.available) {
|
|
267
|
+
if (teams.teams.length === 0) {
|
|
268
|
+
hints.push({ code: "teams-none", severity: "info", params: { stateDir: teams.stateDir } });
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
hints.push({ code: "state-dir-missing", severity: "info", params: { stateDir: teams.stateDir } });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const duplicateIds = library.workspaces
|
|
275
|
+
.flatMap((workspace) => workspace.diagnostics ?? [])
|
|
276
|
+
.filter((item) => item?.code === "duplicate-roster-id").length;
|
|
277
|
+
if (duplicateIds > 0) {
|
|
278
|
+
hints.push({ code: "duplicate-roster-id", severity: "warn", params: { count: duplicateIds } });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const problems = library.workspaces
|
|
282
|
+
.flatMap((workspace) => workspace.rosters ?? [])
|
|
283
|
+
.reduce(
|
|
284
|
+
(total, roster) => total + (roster.diagnostics ?? []).filter((item) => item?.severity === "error").length,
|
|
285
|
+
0,
|
|
286
|
+
);
|
|
287
|
+
if (problems > 0) {
|
|
288
|
+
hints.push({ code: "roster-errors", severity: "warn", params: { count: problems } });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return hints;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 把公共目录里的一整套档案拉到本机档案库。
|
|
296
|
+
*
|
|
297
|
+
* 语义刻意做成**幂等**:
|
|
298
|
+
* - 本地没有 → 写入,`action: "created"`;
|
|
299
|
+
* - 本地有且是远端这一版 → 什么都不做,`action: "unchanged"`(重复点按钮不会产生噪音文件);
|
|
300
|
+
* - 本地有但内容不同 → 默认**不覆盖**,`action: "conflict"`(档案是用户资产,
|
|
301
|
+
* "点一下按钮就悄悄改掉你改过的东西"是不可接受的默认行为);传 `overwrite` 才覆盖。
|
|
302
|
+
*
|
|
303
|
+
* 单份失败不影响其它份:每份各自返回结果,整体 `ok` 只要求“没有写入失败”。
|
|
304
|
+
*
|
|
305
|
+
* @param {object} config
|
|
306
|
+
* @param {{ collectionId: string, source?: string, overwrite?: boolean, fetchImpl?: typeof fetch, cacheDir?: string }} options
|
|
307
|
+
* @returns {Promise<object>}
|
|
308
|
+
*/
|
|
309
|
+
export async function installCollection(config, options) {
|
|
310
|
+
const panel = await loadRemotePanel(config, { fetchImpl: options.fetchImpl, cacheDir: options.cacheDir });
|
|
311
|
+
const collection = panel.collections.find(
|
|
312
|
+
(item) => item.id === options.collectionId && (options.source === undefined || item.origin?.source === options.source),
|
|
313
|
+
);
|
|
314
|
+
if (collection === undefined) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
code: "collection-not-found",
|
|
318
|
+
known: panel.collections.map((item) => item.id),
|
|
319
|
+
diagnostics: panel.diagnostics,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const fetched = await fetchCollection(collection, { fetchImpl: options.fetchImpl });
|
|
324
|
+
const roots = listLibraryRoots({ rosterDirs: config.rosterDirs ?? [], workspaces: [] });
|
|
325
|
+
const root = roots[0] ?? null;
|
|
326
|
+
if (root === null) {
|
|
327
|
+
return { ok: false, code: "no-library-root", diagnostics: fetched.diagnostics };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const results = [];
|
|
331
|
+
for (const roster of fetched.rosters) {
|
|
332
|
+
const text = rosterDocumentText(roster);
|
|
333
|
+
const current = readRosterDocument({ roots, rootDir: root.rootDir, id: roster.id });
|
|
334
|
+
|
|
335
|
+
// 内容一模一样就当“已是最新”:不写盘、不留备份、不报冲突,重复点按钮也安静。
|
|
336
|
+
if (current.ok && current.text.trim() === text.trim()) {
|
|
337
|
+
results.push({ id: roster.id, action: "unchanged", file: current.file });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
// 本地存在但版本不同:默认不覆盖,除非调用方显式要求。
|
|
341
|
+
if (current.ok && options.overwrite !== true) {
|
|
342
|
+
results.push({ id: roster.id, action: "conflict", file: current.file, code: "exists" });
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const written = writeRosterDocument({
|
|
347
|
+
roots,
|
|
348
|
+
rootDir: root.rootDir,
|
|
349
|
+
text,
|
|
350
|
+
overwrite: current.ok ? true : options.overwrite === true,
|
|
351
|
+
knownToolPolicies: config.knownToolPolicies ?? null,
|
|
352
|
+
});
|
|
353
|
+
if (written.ok) {
|
|
354
|
+
results.push({ id: roster.id, action: current.ok ? "updated" : "created", file: written.file });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
results.push({
|
|
358
|
+
id: roster.id,
|
|
359
|
+
action: written.code === "exists" ? "conflict" : written.code === "invalid-id" ? "invalid-id" : "failed",
|
|
360
|
+
code: written.code,
|
|
361
|
+
diagnostics: written.diagnostics ?? [],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const failed = results.filter((item) => item.action === "failed" || item.action === "conflict");
|
|
366
|
+
const fetchBroken = fetched.diagnostics.some((item) => item.severity === "error");
|
|
367
|
+
return {
|
|
368
|
+
ok: failed.length === 0 && !fetchBroken,
|
|
369
|
+
collection: { id: collection.id, title: collection.title, repo: collection.repo, ref: collection.ref },
|
|
370
|
+
rootDir: root.rootDir,
|
|
371
|
+
results,
|
|
372
|
+
written: results.filter((item) => item.action === "created" || item.action === "updated").length,
|
|
373
|
+
unchanged: results.filter((item) => item.action === "unchanged").length,
|
|
374
|
+
conflicts: results.filter((item) => item.action === "conflict").length,
|
|
375
|
+
diagnostics: fetched.diagnostics,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 远程档案源(公共 registry)的投影。
|
|
381
|
+
*
|
|
382
|
+
* 只取“有哪些 collection、各自登记了几份档案”,**不**顺手把远端档案全拉一遍:
|
|
383
|
+
* 面板打开一次就要几十个请求,而用户通常只想看自己库里的东西。真正拉取发生在
|
|
384
|
+
* 用户点开某个 collection 时(`fetchCollection`)。
|
|
385
|
+
*
|
|
386
|
+
* @param {object} config 插件配置(已解引用)
|
|
387
|
+
* @param {object} [options]
|
|
388
|
+
* @param {string} [options.cacheDir] 缓存目录,默认 `<全局库>/.index-cache`
|
|
389
|
+
* @param {boolean} [options.refresh] 忽略 TTL
|
|
390
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
391
|
+
* @param {() => Date} [options.now]
|
|
392
|
+
* @returns {Promise<object>}
|
|
393
|
+
*/
|
|
394
|
+
export async function loadRemotePanel(config, options = {}) {
|
|
395
|
+
const cacheDir = options.cacheDir ?? nodePath.join(globalRosterRoot(), CACHE_DIR_NAME);
|
|
396
|
+
const loaded = await loadRegistryIndex(config, {
|
|
397
|
+
cacheDir,
|
|
398
|
+
refresh: options.refresh === true,
|
|
399
|
+
fetchImpl: options.fetchImpl,
|
|
400
|
+
now: options.now,
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
cacheDir,
|
|
404
|
+
indexes: loaded.indexes,
|
|
405
|
+
collections: loaded.collections.map((collection) => ({
|
|
406
|
+
id: collection.id,
|
|
407
|
+
title: collection.title,
|
|
408
|
+
description: collection.description,
|
|
409
|
+
repo: collection.repo,
|
|
410
|
+
ref: collection.ref,
|
|
411
|
+
path: collection.path,
|
|
412
|
+
rosters: collection.rosters,
|
|
413
|
+
tags: collection.tags,
|
|
414
|
+
license: collection.license,
|
|
415
|
+
maintainers: collection.maintainers,
|
|
416
|
+
homepage: collection.homepage,
|
|
417
|
+
origin: collection.origin,
|
|
418
|
+
})),
|
|
419
|
+
diagnostics: loaded.diagnostics,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读地读取 dsh-agent-teams 的团队状态,用于核对"档案是否真的进了运行时"。
|
|
3
|
+
*
|
|
4
|
+
* 为什么读别人的状态文件,而不是问运行时 API:
|
|
5
|
+
* - 状态文件是运行时自己写下的**实际值**(provider/model/toolPolicy/executionPrompt 都在里面),
|
|
6
|
+
* 而工具调用参数只是"请求值"。验证必须比对实际值,否则就是让模型自我报告。
|
|
7
|
+
* - agent-teams 的 `agent_teams_status` 只回传筛选后的视图,字段不全;
|
|
8
|
+
* `team.json` 里的 {@link TeamMember} 才是完整记录。
|
|
9
|
+
*
|
|
10
|
+
* 代价是耦合了它的目录布局与字段名(`.agent-teams/<teamId>/team.json`,TeamMember 用 camelCase)。
|
|
11
|
+
* 因此这里**只读、容错、缺失即降级为诊断**:目录不在、字段没有、结构变了,都只是"无法核对",
|
|
12
|
+
* 绝不让档案层因为运行时升级而崩掉。适配其它运行时的实现只要提供同样的 `readTeamStates()`
|
|
13
|
+
* 形状即可——这就是预留的适配层边界。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-agent-roster/teams-state
|
|
16
|
+
*/
|
|
17
|
+
import nodeFs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { diagnostic } from "./parse.js";
|
|
21
|
+
import { messageOf } from "./registry.js";
|
|
22
|
+
|
|
23
|
+
/** 团队状态里用于核对桥接结果的字段(camelCase,取自 TeamMember)。 */
|
|
24
|
+
export const TEAM_MEMBER_KEYS = ["name", "role", "provider", "model", "reasoningEffort", "toolPolicy", "executionPrompt"];
|
|
25
|
+
|
|
26
|
+
/** 不属于任何团队的目录名:归档区不在核对范围内。 */
|
|
27
|
+
const NON_TEAM_DIRS = new Set(["archive"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 读取一个 AgentTeams 状态根目录下的全部团队记录。
|
|
31
|
+
*
|
|
32
|
+
* @param {{ stateDir: string, fs?: object, teamId?: string }} options
|
|
33
|
+
* @returns {{
|
|
34
|
+
* stateDir: string,
|
|
35
|
+
* teams: object[],
|
|
36
|
+
* diagnostics: object[],
|
|
37
|
+
* available: boolean,
|
|
38
|
+
* }}
|
|
39
|
+
*/
|
|
40
|
+
export function readTeamStates(options = {}) {
|
|
41
|
+
const stateDir = typeof options.stateDir === "string" ? options.stateDir : "";
|
|
42
|
+
const fs = options.fs ?? nodeFs;
|
|
43
|
+
const teamId = typeof options.teamId === "string" && options.teamId !== "" ? options.teamId : null;
|
|
44
|
+
|
|
45
|
+
const result = { stateDir, teams: [], diagnostics: [], available: false };
|
|
46
|
+
const report = (severity, code, p, reason) =>
|
|
47
|
+
result.diagnostics.push(diagnostic(severity, code, p, reason, stateDir));
|
|
48
|
+
|
|
49
|
+
if (stateDir === "") {
|
|
50
|
+
report("error", "team-state-dir-unset", "", "没有配置 AgentTeams 状态目录(config.agentTeamsStateDir)");
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let stats;
|
|
55
|
+
try {
|
|
56
|
+
stats = fs.statSync(stateDir);
|
|
57
|
+
} catch {
|
|
58
|
+
report(
|
|
59
|
+
"warn",
|
|
60
|
+
"team-state-missing",
|
|
61
|
+
"",
|
|
62
|
+
`没有找到 AgentTeams 状态目录 ${stateDir};只做档案静态校验,不核对运行时`,
|
|
63
|
+
);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
if (!stats.isDirectory()) {
|
|
67
|
+
report("error", "team-state-not-directory", "", `AgentTeams 状态路径不是目录:${stateDir}`);
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
result.available = true;
|
|
71
|
+
|
|
72
|
+
let names;
|
|
73
|
+
try {
|
|
74
|
+
names = fs.readdirSync(stateDir);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
report("error", "team-state-unreadable", "", `无法读取团队状态目录:${messageOf(error)}`);
|
|
77
|
+
result.available = false;
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const name of names.slice().sort()) {
|
|
82
|
+
if (name.startsWith(".") || NON_TEAM_DIRS.has(name)) continue;
|
|
83
|
+
if (teamId !== null && name !== teamId) continue;
|
|
84
|
+
const dir = path.join(stateDir, name);
|
|
85
|
+
let kind;
|
|
86
|
+
try {
|
|
87
|
+
kind = fs.statSync(dir);
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!kind.isDirectory()) continue;
|
|
92
|
+
const file = path.join(dir, "team.json");
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = fs.readFileSync(file, "utf8");
|
|
96
|
+
} catch {
|
|
97
|
+
report("warn", "team-state-file-missing", name, `团队目录里没有 team.json,已跳过:${dir}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
let state;
|
|
101
|
+
try {
|
|
102
|
+
state = JSON.parse(text);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
report("error", "team-state-invalid-json", name, `team.json 不是合法 JSON:${messageOf(error)}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (state === null || typeof state !== "object" || !Array.isArray(state.members)) {
|
|
108
|
+
report("error", "team-state-invalid-shape", name, "team.json 缺少 members 数组,无法核对");
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
result.teams.push({
|
|
112
|
+
id: typeof state.id === "string" && state.id !== "" ? state.id : name,
|
|
113
|
+
dirName: name,
|
|
114
|
+
name: typeof state.name === "string" ? state.name : name,
|
|
115
|
+
description: typeof state.description === "string" ? state.description : "",
|
|
116
|
+
phase: state.phase === "staged" ? "staged" : "running",
|
|
117
|
+
planReviewState: typeof state.planReviewState === "string" ? state.planReviewState : null,
|
|
118
|
+
halted: state.halted === true,
|
|
119
|
+
escalated: state.escalated === true,
|
|
120
|
+
captainSessionId: typeof state.captainSessionId === "string" ? state.captainSessionId : "",
|
|
121
|
+
tasks: Array.isArray(state.tasks) ? state.tasks.filter((task) => task !== null && typeof task === "object") : [],
|
|
122
|
+
file,
|
|
123
|
+
members: state.members.filter((member) => member !== null && typeof member === "object"),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (result.teams.length === 0 && teamId === null) {
|
|
128
|
+
report("warn", "team-state-empty", "", `状态目录里没有任何团队:${stateDir}`);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 解析 AgentTeams 状态目录的绝对路径。
|
|
135
|
+
*
|
|
136
|
+
* 相对路径以当前会话工作区为基准(和 agent-teams 自己的 `join(workspace, stateDir)` 一致),
|
|
137
|
+
* 拿不到工作区时退回进程工作目录。
|
|
138
|
+
*
|
|
139
|
+
* @param {string} stateDir 配置值
|
|
140
|
+
* @param {string | undefined} workspacePath 会话工作区
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
export function resolveStateDir(stateDir, workspacePath) {
|
|
144
|
+
const base = typeof workspacePath === "string" && workspacePath !== "" ? workspacePath : process.cwd();
|
|
145
|
+
return path.isAbsolute(stateDir) ? stateDir : path.resolve(base, stateDir);
|
|
146
|
+
}
|