@fa-software/semi2-dsh 0.0.10-preview.20260920042705
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/cordis.patch.yml +5 -0
- package/lib/client.js +351 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph/cli.js +142 -0
- package/lib/codegraph/config.js +22 -0
- package/lib/codegraph/ensure.js +25 -0
- package/lib/codegraph/guide.js +21 -0
- package/lib/codegraph/index.js +97 -0
- package/lib/codegraph/tools.js +227 -0
- package/lib/codegraph/workspace.js +52 -0
- package/lib/configuration/compile.js +177 -0
- package/lib/configuration/credentials.js +104 -0
- package/lib/configuration/generated/graphql.js +1 -0
- package/lib/configuration/generated/schema.js +95 -0
- package/lib/configuration/graphql.js +193 -0
- package/lib/configuration/index.js +29 -0
- package/lib/configuration/login.js +96 -0
- package/lib/configuration/runtime.js +1100 -0
- package/lib/configuration/skill-resources.js +218 -0
- package/lib/configuration/skills.js +185 -0
- package/lib/configuration/status.js +57 -0
- package/lib/configuration/workspace.js +55 -0
- package/lib/configuration/workspaces.js +169 -0
- package/lib/contract.js +42 -0
- package/lib/index.js +14 -0
- package/lib/types/codegraph/cli.d.ts +20 -0
- package/lib/types/codegraph/config.d.ts +10 -0
- package/lib/types/codegraph/ensure.d.ts +3 -0
- package/lib/types/codegraph/guide.d.ts +4 -0
- package/lib/types/codegraph/index.d.ts +5 -0
- package/lib/types/codegraph/tools.d.ts +17 -0
- package/lib/types/codegraph/workspace.d.ts +8 -0
- package/lib/types/configuration/compile.d.ts +81 -0
- package/lib/types/configuration/credentials.d.ts +35 -0
- package/lib/types/configuration/generated/graphql.d.ts +100 -0
- package/lib/types/configuration/generated/schema.d.ts +1003 -0
- package/lib/types/configuration/graphql.d.ts +46 -0
- package/lib/types/configuration/index.d.ts +9 -0
- package/lib/types/configuration/login.d.ts +22 -0
- package/lib/types/configuration/runtime.d.ts +320 -0
- package/lib/types/configuration/skill-resources.d.ts +14 -0
- package/lib/types/configuration/skills.d.ts +8 -0
- package/lib/types/configuration/status.d.ts +9 -0
- package/lib/types/configuration/workspace.d.ts +7 -0
- package/lib/types/configuration/workspaces.d.ts +96 -0
- package/lib/types/contract.d.ts +32 -0
- package/lib/types/index.d.ts +11 -0
- package/lib/types/web/index.d.ts +2 -0
- package/lib/types/web/login.d.ts +2 -0
- package/lib/types/web/loopback-bridge.d.ts +8 -0
- package/lib/types/web/preview-proxy.d.ts +17 -0
- package/lib/types/web/runtime.d.ts +8 -0
- package/lib/types/web/welcome-ack.d.ts +33 -0
- package/lib/web/index.js +38 -0
- package/lib/web/login.js +79 -0
- package/lib/web/loopback-bridge.js +12 -0
- package/lib/web/preview-proxy.js +201 -0
- package/lib/web/runtime.js +19 -0
- package/lib/web/welcome-ack.js +51 -0
- package/package.json +81 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// @fa-software/semi2-dsh/configuration/skill-resources —— 技能附属资源物化(hub 附件字节 → 宿主真实目录)。
|
|
2
|
+
// dsh 只把 `resourceBase` 当提示文本渲染(`<skill_resources>`),模型最终用通用 read 读宿主路径,
|
|
3
|
+
// 所以字节必须真正落盘;目录布局按版本分桶(`<root>/<id>/v<version>/files/<rel>`)。
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
/** 清单文件名:放版本目录根,不塞进 `files/`(那是模型可见的资源根,不混元数据)。 */
|
|
9
|
+
const MANIFEST_NAME = '.manifest.json';
|
|
10
|
+
/** 版本目录名(`v1`)与物化中间目录名(`v1.tmp-<hex>`):GC 只认这两种,别的目录不碰。 */
|
|
11
|
+
const VERSION_NAME = /^v\d+$/;
|
|
12
|
+
const STAGING_NAME = /^v\d+\.tmp-[0-9a-f]+$/;
|
|
13
|
+
function message(error) {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 缓存根的选择理由:`DSH_HOME` 是宿主自己的状态根——跨进程复用、随宿主生命周期清理,
|
|
18
|
+
* 也不会像 /tmp 那样被系统按空闲时间半路收走;没有它(容器面 / 自定义 HOME)时退回系统临时目录。
|
|
19
|
+
* 两级都保留 `semi2-skills` 命名空间,GC 才敢对整个子树做删除。
|
|
20
|
+
*/
|
|
21
|
+
function cacheRoot(environment) {
|
|
22
|
+
const home = environment.DSH_HOME?.trim();
|
|
23
|
+
if (home === undefined || home === '')
|
|
24
|
+
return join(tmpdir(), 'semi2-skills');
|
|
25
|
+
return join(home, 'skills-cache', 'semi2-skills');
|
|
26
|
+
}
|
|
27
|
+
/** hub 侧已校验;这里是「hub 被绕过」时的第二道闸:拒绝对路径、`..`、NUL 与反斜杠。 */
|
|
28
|
+
function safeRelativePath(path) {
|
|
29
|
+
if (path === '' || path.includes('\0') || path.includes('\\'))
|
|
30
|
+
return false;
|
|
31
|
+
if (path.startsWith('/') || /^[A-Za-z]:/.test(path))
|
|
32
|
+
return false;
|
|
33
|
+
return path
|
|
34
|
+
.split('/')
|
|
35
|
+
.every(segment => segment !== '' && segment !== '.' && segment !== '..');
|
|
36
|
+
}
|
|
37
|
+
/** skillId 要当目录名用:只接受 hub 的逻辑 id 形态,避免它顺带改写路径。 */
|
|
38
|
+
function safeSegment(value) {
|
|
39
|
+
return /^[A-Za-z0-9._-]+$/.test(value) && value !== '.' && value !== '..';
|
|
40
|
+
}
|
|
41
|
+
/** 文本类附件是 UTF8(content 是原文),其余是 BASE64;两种都还原成字节再落盘。 */
|
|
42
|
+
function decode(file) {
|
|
43
|
+
return file.encoding === 'BASE64'
|
|
44
|
+
? Buffer.from(file.content, 'base64')
|
|
45
|
+
: Buffer.from(file.content, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
/** 清单按 path 排序后序列化:同一份附件集合必须得到逐字节相同的文本,读回比较才等价于「逐项一致」。 */
|
|
48
|
+
function serializeManifest(entries) {
|
|
49
|
+
const sorted = entries.toSorted((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
50
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
51
|
+
}
|
|
52
|
+
async function isDirectory(path) {
|
|
53
|
+
try {
|
|
54
|
+
return (await stat(path)).isDirectory();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 逐条复核路径与解码:任一条非法就整次放弃——半份资源树比没有更危险,
|
|
62
|
+
* 正文会按相对路径引用附件,缺文件的目录会让模型读到错误内容。
|
|
63
|
+
*/
|
|
64
|
+
function planFiles(files, warn) {
|
|
65
|
+
const plan = [];
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (!safeRelativePath(file.path)) {
|
|
68
|
+
warn(`技能附件路径非法(${file.path}),放弃本次资源物化`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const bytes = decode(file);
|
|
72
|
+
plan.push({
|
|
73
|
+
relativePath: file.path,
|
|
74
|
+
bytes,
|
|
75
|
+
entry: {
|
|
76
|
+
path: file.path,
|
|
77
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
78
|
+
// 用实际解码长度而非服务端声明的 sizeBytes:清单要描述磁盘现状,才配当复用依据。
|
|
79
|
+
sizeBytes: bytes.byteLength,
|
|
80
|
+
isExecutable: file.isExecutable,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return plan;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 写一棵新树再原子换入:先在 `<versionDir>.tmp-<random>` 里写全,最后 `rename` 到版本目录。
|
|
88
|
+
* 中途失败只留 staging(本函数负责删),已有版本目录在 rename 前一刻才被替换,不会出现半棵树。
|
|
89
|
+
*/
|
|
90
|
+
async function writeTree(plan, versionDir, manifest) {
|
|
91
|
+
const staging = `${versionDir}.tmp-${randomBytes(6).toString('hex')}`;
|
|
92
|
+
const stagingFiles = join(staging, 'files');
|
|
93
|
+
try {
|
|
94
|
+
for (const file of plan) {
|
|
95
|
+
const target = resolve(stagingFiles, file.relativePath);
|
|
96
|
+
// 落地前再核一次包含关系:路径已筛过,这里是「hub 侧被绕过」也不越界的最后一道闸。
|
|
97
|
+
if (target !== join(stagingFiles, ...file.relativePath.split('/')))
|
|
98
|
+
throw new Error('SKILL_FILE_UNSAFE_PATH');
|
|
99
|
+
await mkdir(dirname(target), { recursive: true });
|
|
100
|
+
await writeFile(target, file.bytes);
|
|
101
|
+
// 可执行位是附件语义的一部分(脚本类技能靠它直接跑)。
|
|
102
|
+
if (file.entry.isExecutable)
|
|
103
|
+
await chmod(target, 0o755);
|
|
104
|
+
}
|
|
105
|
+
await writeFile(join(staging, MANIFEST_NAME), manifest);
|
|
106
|
+
// 同目录 rename 不跨设备;POSIX 要求目标不存在,所以先把目标位置已有的目录删掉再换入。
|
|
107
|
+
await rm(versionDir, { recursive: true, force: true });
|
|
108
|
+
await rename(staging, versionDir);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
await rm(staging, { recursive: true, force: true }).catch(() => { });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function createSkillResourceStore(options) {
|
|
116
|
+
const root = cacheRoot(options.environment);
|
|
117
|
+
/** 同 `(skillId, version)` 并发去重:键是版本目录绝对路径,第二次调用复用同一次物化。 */
|
|
118
|
+
const inFlight = new Map();
|
|
119
|
+
/** 物化一个版本;所有失败都在这里收敛成 null,绝不把错误抛给会话。 */
|
|
120
|
+
const writeVersion = async (skill, versionDir) => {
|
|
121
|
+
const filesDir = join(versionDir, 'files');
|
|
122
|
+
try {
|
|
123
|
+
const fetched = await options.readFiles(skill.id);
|
|
124
|
+
if (fetched === null) {
|
|
125
|
+
options.warn(`技能 ${skill.name} 的附件读取失败,本次不带 resourceBase`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const plan = planFiles(fetched, options.warn);
|
|
129
|
+
if (plan === null)
|
|
130
|
+
return null;
|
|
131
|
+
// 空清单不物化:没有 resourceBase 时 dsh 会渲染「资源由 provider 管理」的提示。
|
|
132
|
+
if (plan.length === 0)
|
|
133
|
+
return null;
|
|
134
|
+
const manifest = serializeManifest(plan.map(file => file.entry));
|
|
135
|
+
const manifestPath = join(versionDir, MANIFEST_NAME);
|
|
136
|
+
// 幂等:清单逐字节一致且资源根还在 → 直接复用(不写盘,目录 mtime / inode 不变)。
|
|
137
|
+
if (await isDirectory(filesDir)) {
|
|
138
|
+
const existing = await readFile(manifestPath, 'utf8').catch(() => null);
|
|
139
|
+
if (existing === manifest)
|
|
140
|
+
return filesDir;
|
|
141
|
+
}
|
|
142
|
+
await writeTree(plan, versionDir, manifest);
|
|
143
|
+
return filesDir;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
options.warn(`技能 ${skill.name} 的资源物化失败(${message(error)}),本次不带 resourceBase`);
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
/** 清掉不在当前集合里的版本目录与中间目录;技能整个缺席时连它的容器目录一起收掉。 */
|
|
151
|
+
const sweep = async (keep) => {
|
|
152
|
+
const skillDirs = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
153
|
+
for (const skillDir of skillDirs) {
|
|
154
|
+
if (!skillDir.isDirectory())
|
|
155
|
+
continue;
|
|
156
|
+
const versions = await readdir(join(root, skillDir.name), {
|
|
157
|
+
withFileTypes: true,
|
|
158
|
+
}).catch(() => []);
|
|
159
|
+
let kept = false;
|
|
160
|
+
let busy = false;
|
|
161
|
+
for (const version of versions) {
|
|
162
|
+
if (!version.isDirectory())
|
|
163
|
+
continue;
|
|
164
|
+
const staging = STAGING_NAME.test(version.name);
|
|
165
|
+
if (!staging && !VERSION_NAME.test(version.name))
|
|
166
|
+
continue;
|
|
167
|
+
// 中间目录 `v2.tmp-<hex>` 属于版本 `v2`:in-flight 判定要按版本目录名,不能按目录本身。
|
|
168
|
+
const versionName = staging
|
|
169
|
+
? version.name.slice(0, version.name.indexOf('.'))
|
|
170
|
+
: version.name;
|
|
171
|
+
// 正在物化的版本(含它的 staging)不动:删掉会让这次 rename 失败。
|
|
172
|
+
if (inFlight.has(join(root, skillDir.name, versionName))) {
|
|
173
|
+
busy = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!staging && keep.has(`${skillDir.name}/${versionName}`)) {
|
|
177
|
+
kept = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
await rm(join(root, skillDir.name, version.name), {
|
|
181
|
+
recursive: true,
|
|
182
|
+
force: true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (!kept && !busy && ![...keep].some(key => key.startsWith(`${skillDir.name}/`)))
|
|
186
|
+
await rm(join(root, skillDir.name), { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
async materialize(skill) {
|
|
191
|
+
if (skill.files.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
if (!safeSegment(skill.id)) {
|
|
194
|
+
options.warn(`技能 id(${skill.id})不能用作缓存目录名,跳过资源物化`);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const versionDir = join(root, skill.id, `v${skill.version}`);
|
|
198
|
+
const pending = inFlight.get(versionDir);
|
|
199
|
+
if (pending !== undefined)
|
|
200
|
+
return pending;
|
|
201
|
+
// 先登记再 await:同 tick 的第二个调用必须看到这次物化(去重靠这一步的同步性)。
|
|
202
|
+
const run = writeVersion(skill, versionDir).finally(() => {
|
|
203
|
+
inFlight.delete(versionDir);
|
|
204
|
+
});
|
|
205
|
+
inFlight.set(versionDir, run);
|
|
206
|
+
return run;
|
|
207
|
+
},
|
|
208
|
+
reconcile(skills) {
|
|
209
|
+
// 降级(skills = null)时当前集合未知:删任何目录都可能让 last-good 正文里的路径悬空。
|
|
210
|
+
if (skills === null)
|
|
211
|
+
return;
|
|
212
|
+
const keep = new Set(skills.map(skill => `${skill.id}/v${skill.version}`));
|
|
213
|
+
void sweep(keep).catch((error) => {
|
|
214
|
+
options.warn(`技能资源缓存清理失败(${message(error)})`);
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { isSkillName, } from '@deepseek-ai/dsh-skill';
|
|
2
|
+
import { createSkillResourceStore } from './skill-resources.js';
|
|
3
|
+
const HUB_SKILL_RANK = 240;
|
|
4
|
+
function errorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 一条技能事实 → dsh 候选;非法 name 或空 description 直接丢弃(服务端已校验,这里是防御:
|
|
9
|
+
* 坏条目不该进会话目录)。项目作用域由调用方决定顺序,候选本身不带作用域。
|
|
10
|
+
*/
|
|
11
|
+
function candidateOf(skill, warn) {
|
|
12
|
+
if (!isSkillName(skill.name)) {
|
|
13
|
+
warn(`技能 ${skill.id} 的 name 不是合法 kebab-case,已忽略`);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (skill.description.trim() === '') {
|
|
17
|
+
warn(`技能 ${skill.name} 的 description 为空,已忽略`);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
name: skill.name,
|
|
22
|
+
description: skill.description,
|
|
23
|
+
// exactOptionalPropertyTypes:可选字段只能用条件展开,不能赋 undefined。
|
|
24
|
+
...(skill.whenToUse ? { whenToUse: skill.whenToUse } : {}),
|
|
25
|
+
rank: HUB_SKILL_RANK,
|
|
26
|
+
locator: skill.id,
|
|
27
|
+
source: 'custom',
|
|
28
|
+
provider: 'semi2-hub',
|
|
29
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function definitionOf(skill) {
|
|
33
|
+
return {
|
|
34
|
+
name: skill.name,
|
|
35
|
+
description: skill.description,
|
|
36
|
+
...(skill.whenToUse ? { whenToUse: skill.whenToUse } : {}),
|
|
37
|
+
content: skill.content,
|
|
38
|
+
source: 'custom',
|
|
39
|
+
provider: 'semi2-hub',
|
|
40
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function installSkills(ctx, config, warn) {
|
|
44
|
+
// 附件字节 → 宿主目录的物化器:只在 get() 真正加载技能时才动它(list 只给清单)。
|
|
45
|
+
const resources = createSkillResourceStore({
|
|
46
|
+
readFiles: skillId => config.skillFiles(skillId),
|
|
47
|
+
environment: process.env,
|
|
48
|
+
warn,
|
|
49
|
+
});
|
|
50
|
+
/** 目录观测:先判运行面状态够不够权威,再判 cwd 有没有工作区身份。 */
|
|
51
|
+
const observe = async (cwd) => {
|
|
52
|
+
// 首装未落定前「未知」不能当权威空目录:先等这次领取结束(后续 reload 时 ready 已 settle)。
|
|
53
|
+
await config.ready;
|
|
54
|
+
const { status, skills } = config.state;
|
|
55
|
+
// 没接 hub = 权威地没有控制台技能;也顺带让上一个账号的 last-good 失效。
|
|
56
|
+
if (status === 'local')
|
|
57
|
+
return { candidates: [], complete: true };
|
|
58
|
+
// 读取中 / 整体失败 / 技能块降级:都还没拿到权威目录,交回不完整观测让 dsh 保留 last-good。
|
|
59
|
+
if (status !== 'ready' || skills === null) {
|
|
60
|
+
return { candidates: [], complete: false };
|
|
61
|
+
}
|
|
62
|
+
const project = await config.projectForCwd(cwd);
|
|
63
|
+
// 全仓统一口径:未登记目录一个技能都不给(全局技能也只是「在所有已登记工作区共享」)。
|
|
64
|
+
if (project === null)
|
|
65
|
+
return { candidates: [], complete: true };
|
|
66
|
+
const candidates = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const push = (skill) => {
|
|
69
|
+
if (seen.has(skill.name))
|
|
70
|
+
return;
|
|
71
|
+
const candidate = candidateOf(skill, warn);
|
|
72
|
+
if (candidate === null)
|
|
73
|
+
return;
|
|
74
|
+
seen.add(skill.name);
|
|
75
|
+
candidates.push(candidate);
|
|
76
|
+
};
|
|
77
|
+
// 先项目命中、再全局未重名:更具体的作用域压过更通用的(同名裁决在插件侧做)。
|
|
78
|
+
for (const skill of skills)
|
|
79
|
+
if (skill.projectId === project.id)
|
|
80
|
+
push(skill);
|
|
81
|
+
for (const skill of skills)
|
|
82
|
+
if (skill.projectId === null)
|
|
83
|
+
push(skill);
|
|
84
|
+
return { candidates, complete: true };
|
|
85
|
+
};
|
|
86
|
+
const guardedObserve = async (cwd) => {
|
|
87
|
+
try {
|
|
88
|
+
return await observe(cwd);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// 解析链(projectForCwd 会问 hub)出错也只是没拿到完整观测,绝不 reject 会话。
|
|
92
|
+
warn(`skills list failed: ${errorMessage(error)}`);
|
|
93
|
+
return { candidates: [], complete: false };
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const provider = {
|
|
97
|
+
name: 'semi2-hub',
|
|
98
|
+
async list(options) {
|
|
99
|
+
const observation = await guardedObserve(options.cwd);
|
|
100
|
+
return {
|
|
101
|
+
candidates: observation.candidates,
|
|
102
|
+
complete: observation.complete,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
async get(candidate, options) {
|
|
106
|
+
try {
|
|
107
|
+
await config.ready;
|
|
108
|
+
const { status, skills } = config.state;
|
|
109
|
+
if (status !== 'ready' || skills === null)
|
|
110
|
+
return undefined;
|
|
111
|
+
const found = skills.find(skill => skill.id === String(candidate.locator));
|
|
112
|
+
// 目录已刷新且这一条不在其中:服务端已撤下 / 归档,点名加载也不给。
|
|
113
|
+
if (found === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
const cwd = options.cwd?.trim();
|
|
116
|
+
if (cwd !== undefined && cwd !== '') {
|
|
117
|
+
const project = await config.projectForCwd(cwd);
|
|
118
|
+
// 与 list 同口径:未登记目录连点名加载也不行;项目技能只在该项目的 cwd 里加载。
|
|
119
|
+
if (project === null)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (found.projectId !== null && found.projectId !== project.id)
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
// 无 cwd(非会话调用)放行目录内已有条目:已列出的必须还能加载。
|
|
125
|
+
// 带附件的技能先把字节物化到宿主目录,再以 resourceBase 指给模型;失败只 warn 不带 base。
|
|
126
|
+
const definition = definitionOf(found);
|
|
127
|
+
const directory = await resources.materialize(found);
|
|
128
|
+
return directory === null
|
|
129
|
+
? definition
|
|
130
|
+
: {
|
|
131
|
+
...definition,
|
|
132
|
+
resourceBase: { kind: 'directory', path: directory },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
warn(`skills get failed: ${errorMessage(error)}`);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
let invalidate = () => {
|
|
142
|
+
// 注册失败前无缓存可失效。
|
|
143
|
+
};
|
|
144
|
+
const disposers = [];
|
|
145
|
+
try {
|
|
146
|
+
disposers.push(ctx.skills.registerProvider((control) => {
|
|
147
|
+
invalidate = () => {
|
|
148
|
+
control.invalidate();
|
|
149
|
+
};
|
|
150
|
+
return provider;
|
|
151
|
+
}));
|
|
152
|
+
// 目录随装配与工作区映射变化:两条事件都要丢 dsh 的按 cwd 缓存(cwd → 项目解析依赖后者),
|
|
153
|
+
// 并顺手清掉已不在当前 (skillId, version) 集合里的资源缓存(降级时 skills=null,物化器不删任何东西)。
|
|
154
|
+
const drop = (state) => {
|
|
155
|
+
invalidate();
|
|
156
|
+
resources.reconcile(state?.skills ?? null);
|
|
157
|
+
};
|
|
158
|
+
disposers.push(ctx.on('semi2/config-updated', drop));
|
|
159
|
+
disposers.push(ctx.on('semi2/workspaces-updated', drop));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
// fake ctx 可能没有 skills 服务:注册失败只 warn,其余能力照常。
|
|
163
|
+
warn(`hub skills provider register failed: ${errorMessage(error)}`);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
dispose: () => {
|
|
167
|
+
for (const dispose of disposers) {
|
|
168
|
+
try {
|
|
169
|
+
dispose();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// 收尾不抛。
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
invalidate: () => {
|
|
177
|
+
try {
|
|
178
|
+
invalidate();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// 缓存失效失败不影响会话。
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** 设置命名空间 `semi2` 的状态字段(顺序 = UI 展示顺序);`reload` 由 UI 写,不在此列。 */
|
|
2
|
+
export const STATUS_FIELDS = [
|
|
3
|
+
'status',
|
|
4
|
+
'error',
|
|
5
|
+
'loadedAt',
|
|
6
|
+
'consoleUrl',
|
|
7
|
+
'identity',
|
|
8
|
+
'credentialSource',
|
|
9
|
+
'platform',
|
|
10
|
+
'startupDir',
|
|
11
|
+
'workspaceCount',
|
|
12
|
+
'workspaces',
|
|
13
|
+
'projectCount',
|
|
14
|
+
'projects',
|
|
15
|
+
'model',
|
|
16
|
+
'skills',
|
|
17
|
+
];
|
|
18
|
+
/** 状态里最多列几个工作区:这段字符串要落 YAML,超出只报增量。 */
|
|
19
|
+
const MAX_LISTED_WORKSPACES = 20;
|
|
20
|
+
/** `标题 → slug(项目名)`:只有命中 hub 项目的工作区才进这里,读一行就知道同步到了什么。 */
|
|
21
|
+
function renderWorkspaces(workspaces) {
|
|
22
|
+
const listed = workspaces.slice(0, MAX_LISTED_WORKSPACES).map((entry) => {
|
|
23
|
+
const slug = entry.slug ?? '未解析';
|
|
24
|
+
const project = entry.name ?? '未匹配';
|
|
25
|
+
return `${entry.title} → ${slug}(${project})`;
|
|
26
|
+
});
|
|
27
|
+
const hidden = workspaces.length - listed.length;
|
|
28
|
+
return hidden > 0 ? `${listed.join(' | ')} | …+${String(hidden)}` : listed.join(' | ');
|
|
29
|
+
}
|
|
30
|
+
/** 身份 = hub 当前账户:展示名与用户名不同时并列,未就绪(未登录 / 读失败)为空串。 */
|
|
31
|
+
function identityText(identity) {
|
|
32
|
+
if (identity === null)
|
|
33
|
+
return '';
|
|
34
|
+
return identity.displayName !== '' && identity.displayName !== identity.username
|
|
35
|
+
? `${identity.displayName} (${identity.username})`
|
|
36
|
+
: identity.username;
|
|
37
|
+
}
|
|
38
|
+
export function statusFields(state, facts = {}) {
|
|
39
|
+
const workspaces = state.workspaces;
|
|
40
|
+
return {
|
|
41
|
+
status: state.status,
|
|
42
|
+
error: state.error ?? '',
|
|
43
|
+
loadedAt: state.loadedAt ?? '',
|
|
44
|
+
consoleUrl: state.consoleUrl ?? '',
|
|
45
|
+
identity: identityText(state.identity),
|
|
46
|
+
credentialSource: facts.credentialSource ?? '',
|
|
47
|
+
platform: facts.platform ?? '',
|
|
48
|
+
startupDir: process.cwd(),
|
|
49
|
+
workspaceCount: String(workspaces.length),
|
|
50
|
+
workspaces: renderWorkspaces(workspaces),
|
|
51
|
+
projectCount: String(state.projects.length),
|
|
52
|
+
projects: state.projects.map(project => project.slug).join(','),
|
|
53
|
+
model: state.model ? `${state.model.provider}/${state.model.id}` : '',
|
|
54
|
+
// 控制台技能目录条数;null = 没拿到权威目录(≠ 空目录),落空串。
|
|
55
|
+
skills: state.skills == null ? '' : String(state.skills.length),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @fa-software/semi2-dsh/configuration/workspace —— 会话 cwd → 项目 slug(进程内缓存)。
|
|
2
|
+
// git origin 反解失败(非 git 仓库 / 无 origin / 无 git 可执行文件)回落 CNB_REPO_SLUG。
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
const GIT_TIMEOUT_MS = 2_000;
|
|
5
|
+
const cache = new Map();
|
|
6
|
+
/** 从 git remote URL 提取 org/repo(https 与 scp 风格皆可),小写规范化。 */
|
|
7
|
+
export function slugFromRemoteUrl(url) {
|
|
8
|
+
let rest = url
|
|
9
|
+
.trim()
|
|
10
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
11
|
+
.replace(/^[^@/]*@/, '');
|
|
12
|
+
const colon = rest.indexOf(':');
|
|
13
|
+
const slash = rest.indexOf('/');
|
|
14
|
+
if (colon !== -1 && (slash === -1 || colon < slash))
|
|
15
|
+
rest = rest.slice(colon + 1); // scp 风格 git@host:org/repo
|
|
16
|
+
else if (slash !== -1)
|
|
17
|
+
rest = rest.slice(slash + 1);
|
|
18
|
+
else
|
|
19
|
+
return null;
|
|
20
|
+
const path = rest
|
|
21
|
+
.replace(/^\/+/, '')
|
|
22
|
+
.replace(/\/+$/, '')
|
|
23
|
+
.replace(/\.git$/i, '');
|
|
24
|
+
return path === '' ? null : path.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
/** 在 config.state.projects 里按 slug 精确匹配。 */
|
|
27
|
+
export function matchProject(projects, slug) {
|
|
28
|
+
if (slug === null)
|
|
29
|
+
return null;
|
|
30
|
+
return projects.find(project => project.slug.toLowerCase() === slug) ?? null;
|
|
31
|
+
}
|
|
32
|
+
/** CNB 面兜底:容器里工作区就是被触发的仓库,slug 由触发 env 给出。 */
|
|
33
|
+
function fallbackSlug(env) {
|
|
34
|
+
const slug = env.CNB_REPO_SLUG?.trim().toLowerCase();
|
|
35
|
+
return slug !== undefined && slug !== '' ? slug : null;
|
|
36
|
+
}
|
|
37
|
+
function gitOriginSlug(directory) {
|
|
38
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
39
|
+
execFile('git', ['-C', directory, 'remote', 'get-url', 'origin'], { timeout: GIT_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout) => {
|
|
40
|
+
resolve(error === null ? slugFromRemoteUrl(stdout) : null);
|
|
41
|
+
});
|
|
42
|
+
return promise;
|
|
43
|
+
}
|
|
44
|
+
/** cwd → git origin slug;进程内按 cwd 缓存。 */
|
|
45
|
+
export async function workspaceSlug(cwd, env) {
|
|
46
|
+
const directory = cwd?.trim();
|
|
47
|
+
if (directory === undefined || directory === '')
|
|
48
|
+
return fallbackSlug(env);
|
|
49
|
+
const cached = cache.get(directory);
|
|
50
|
+
if (cached !== undefined)
|
|
51
|
+
return cached;
|
|
52
|
+
const slug = (await gitOriginSlug(directory)) ?? fallbackSlug(env);
|
|
53
|
+
cache.set(directory, slug);
|
|
54
|
+
return slug;
|
|
55
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @fa-software/semi2-dsh/configuration/workspaces —— 工作区登记(dsh 工作区 → git 项目映射)。
|
|
2
|
+
// Web 面消费 dsh 的 workspaceController.follow(基线 + 增量),headless 面服务缺席时退化为单条隐式工作区(cwd / CNB_REPO_SLUG)。
|
|
3
|
+
// 登记表只存本地映射事实(目录与解析结果,密钥绝不落盘);所有失败都落成条目上的 error,绝不抛。
|
|
4
|
+
import { realpath } from 'node:fs/promises';
|
|
5
|
+
import { basename } from 'node:path';
|
|
6
|
+
/** 未解析条目的起点:映射三件套留空,等一次同步填。 */
|
|
7
|
+
export function pendingEntry(path, title, implicit = false) {
|
|
8
|
+
return {
|
|
9
|
+
path,
|
|
10
|
+
title,
|
|
11
|
+
slug: null,
|
|
12
|
+
projectId: null,
|
|
13
|
+
name: null,
|
|
14
|
+
syncedAt: null,
|
|
15
|
+
implicit,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** headless 面没有 dsh 工作区服务:启动目录就是唯一工作区。 */
|
|
19
|
+
export function implicitWorkspace(cwd) {
|
|
20
|
+
return pendingEntry(cwd, basename(cwd) || cwd, true);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 参与状态展示与持久化的工作区:只有命中 hub 项目的才算数。反解不出 slug(非 git)与
|
|
24
|
+
* hub 里查无此项目一样,都是「不归我们管」——簿记里留着等一次重解析(git init / 加
|
|
25
|
+
* remote / hub 补建项目都能上账),但不占状态、不入库。
|
|
26
|
+
*/
|
|
27
|
+
export function trackedWorkspaces(entries) {
|
|
28
|
+
return entries.filter(entry => entry.projectId !== null);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* cwd → canonical 路径。dsh registry 存的已是 realpath 结果,而会话 header 的 cwd 可能是
|
|
32
|
+
* 软链或带 `..` 的拼写,两边必须经同一把尺子才比得上;目录不在(已删 / 无权限)保留原拼写,
|
|
33
|
+
* 好让条目仍能定位与展示——失败结果不缓存,目录出现后还能纠正。
|
|
34
|
+
*/
|
|
35
|
+
const canonical = new Map();
|
|
36
|
+
export async function canonicalPath(directory) {
|
|
37
|
+
const cached = canonical.get(directory);
|
|
38
|
+
if (cached !== undefined)
|
|
39
|
+
return cached;
|
|
40
|
+
try {
|
|
41
|
+
const resolved = await realpath(directory);
|
|
42
|
+
canonical.set(directory, resolved);
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return directory;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 工作区登记表:canonical path 为键,另记 workspaceId → path 以便 remove / 改名定位。
|
|
51
|
+
* 只由配置运行时单线程驱动(feed 帧与同步排在同一条队列上),故不做并发保护。
|
|
52
|
+
*/
|
|
53
|
+
export class WorkspaceBook {
|
|
54
|
+
byPath = new Map();
|
|
55
|
+
pathById = new Map();
|
|
56
|
+
/** 播下 dsh 基线:registry 是权威列表,不在基线里的陈旧条目(隐式条目除外)撤下。 */
|
|
57
|
+
applyBaseline(items) {
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
seen.add(item.path);
|
|
61
|
+
this.pathById.set(item.workspaceId, item.path);
|
|
62
|
+
const previous = this.byPath.get(item.path);
|
|
63
|
+
this.byPath.set(item.path, previous === undefined
|
|
64
|
+
? pendingEntry(item.path, item.title)
|
|
65
|
+
: { ...previous, title: item.title, implicit: false });
|
|
66
|
+
}
|
|
67
|
+
for (const [path, entry] of [...this.byPath])
|
|
68
|
+
if (!entry.implicit && !seen.has(path))
|
|
69
|
+
this.byPath.delete(path);
|
|
70
|
+
}
|
|
71
|
+
/** 新增或改名:保留既有解析结果(同步很贵),只换标题。 */
|
|
72
|
+
upsertWorkspace(view) {
|
|
73
|
+
this.pathById.set(view.workspaceId, view.path);
|
|
74
|
+
const previous = this.byPath.get(view.path);
|
|
75
|
+
const next = previous === undefined
|
|
76
|
+
? pendingEntry(view.path, view.title)
|
|
77
|
+
: { ...previous, title: view.title, implicit: false };
|
|
78
|
+
this.byPath.set(view.path, next);
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
/** 移除一个工作区;未知 id 返回 undefined(幂等)。 */
|
|
82
|
+
removeWorkspace(workspaceId) {
|
|
83
|
+
const path = this.pathById.get(workspaceId);
|
|
84
|
+
if (path === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
this.pathById.delete(workspaceId);
|
|
87
|
+
const entry = this.byPath.get(path);
|
|
88
|
+
this.byPath.delete(path);
|
|
89
|
+
return entry;
|
|
90
|
+
}
|
|
91
|
+
/** 隐式工作区:同路径已有条目就保留(不把已解析结果降级回未解析)。 */
|
|
92
|
+
ensureImplicit(cwd) {
|
|
93
|
+
const existing = this.byPath.get(cwd);
|
|
94
|
+
if (existing !== undefined)
|
|
95
|
+
return existing;
|
|
96
|
+
const entry = implicitWorkspace(cwd);
|
|
97
|
+
this.byPath.set(cwd, entry);
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
/** 从持久化事实恢复(启动一次);解析结果保留,项目绑定等一次同步补齐。 */
|
|
101
|
+
restore(entries) {
|
|
102
|
+
for (const entry of entries)
|
|
103
|
+
this.byPath.set(entry.path, entry);
|
|
104
|
+
}
|
|
105
|
+
get(path) {
|
|
106
|
+
return this.byPath.get(path);
|
|
107
|
+
}
|
|
108
|
+
set(entry) {
|
|
109
|
+
this.byPath.set(entry.path, entry);
|
|
110
|
+
}
|
|
111
|
+
entries() {
|
|
112
|
+
return [...this.byPath.values()];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function persistedWorkspaces(entries) {
|
|
116
|
+
return entries
|
|
117
|
+
.filter(entry => !entry.implicit)
|
|
118
|
+
.map(entry => ({
|
|
119
|
+
path: entry.path,
|
|
120
|
+
title: entry.title,
|
|
121
|
+
slug: entry.slug ?? '',
|
|
122
|
+
projectId: entry.projectId ?? '',
|
|
123
|
+
name: entry.name ?? '',
|
|
124
|
+
syncedAt: entry.syncedAt ?? '',
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
function describe(error) {
|
|
128
|
+
return error instanceof Error ? error.message : String(error);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 订阅 dsh 工作区变更。服务缺席(headless 面)时回调永不执行,由调用方播一条隐式工作区兜底;
|
|
132
|
+
* 消费异常只 warn——已登记的工作区继续可用,绝不因订阅中断阻断会话。
|
|
133
|
+
*/
|
|
134
|
+
export function installWorkspaceFeed(ctx, handlers, warn) {
|
|
135
|
+
ctx.inject(['workspaceController'], (scoped) => {
|
|
136
|
+
const controller = scoped.get('workspaceController');
|
|
137
|
+
if (controller === undefined)
|
|
138
|
+
return;
|
|
139
|
+
const abort = new AbortController();
|
|
140
|
+
scoped.effect(() => () => {
|
|
141
|
+
abort.abort();
|
|
142
|
+
});
|
|
143
|
+
void (async () => {
|
|
144
|
+
// 单帧失败只记一条:丢掉订阅会让后续工作区再也不同步(降级是硬规则)。
|
|
145
|
+
const apply = async (work) => {
|
|
146
|
+
try {
|
|
147
|
+
await work();
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
warn(`工作区帧处理失败:${describe(error)}`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
for await (const frame of controller.follow(abort.signal)) {
|
|
155
|
+
if (frame.type === 'baseline')
|
|
156
|
+
await apply(() => handlers.baseline(frame.value.items));
|
|
157
|
+
else if (frame.type === 'upsert')
|
|
158
|
+
await apply(() => handlers.upsert(frame.workspace));
|
|
159
|
+
else if (frame.type === 'remove')
|
|
160
|
+
await apply(() => handlers.remove(frame.workspaceId));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (!abort.signal.aborted)
|
|
165
|
+
warn(`工作区变更订阅中断:${describe(error)}`);
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
});
|
|
169
|
+
}
|