@fa-software/semi2-dsh 0.0.13 → 0.0.14-preview.20260922151653
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/README.md +20 -7
- package/lib/api/generated/documents.js +10 -1
- package/lib/api/generated/schema.js +2 -9
- package/lib/api/index.js +13 -0
- package/lib/client.js +54 -48
- package/lib/client.js.map +1 -1
- package/lib/contract.js +6 -0
- package/lib/domains/models/domain.js +11 -4
- package/lib/domains/models/web-search.js +61 -0
- package/lib/domains/redmine/domain.js +139 -0
- package/lib/domains/redmine/index.js +57 -0
- package/lib/domains/skills/candidates.js +4 -2
- package/lib/domains/skills/domain.js +5 -3
- package/lib/domains/workspace/domain.js +3 -2
- package/lib/hub/facts.js +1 -0
- package/lib/index.js +10 -11
- package/lib/service/audit.js +21 -7
- package/lib/service/config-service.js +9 -22
- package/lib/service/lifecycle.js +32 -34
- package/lib/service/namespaces.js +46 -16
- package/lib/service/ports.js +36 -13
- package/lib/service/publish.js +8 -8
- package/lib/service/registry.js +2 -0
- package/lib/service/sessions.js +90 -0
- package/lib/service/settings-sections.js +23 -62
- package/lib/types/api/generated/documents.d.ts +1 -1
- package/lib/types/api/generated/graphql.d.ts +27 -5
- package/lib/types/api/generated/schema.d.ts +265 -60
- package/lib/types/api/index.d.ts +13 -1
- package/lib/types/contract.d.ts +11 -0
- package/lib/types/domains/models/web-search.d.ts +8 -0
- package/lib/types/domains/redmine/domain.d.ts +59 -0
- package/lib/types/domains/redmine/index.d.ts +10 -0
- package/lib/types/domains/skills/domain.d.ts +1 -1
- package/lib/types/domains/types.d.ts +8 -2
- package/lib/types/hub/facts.d.ts +18 -0
- package/lib/types/index.d.ts +28 -24
- package/lib/types/service/audit.d.ts +10 -0
- package/lib/types/service/config-service.d.ts +3 -1
- package/lib/types/service/lifecycle.d.ts +5 -0
- package/lib/types/service/namespaces.d.ts +15 -17
- package/lib/types/service/ports.d.ts +19 -15
- package/lib/types/service/publish.d.ts +6 -1
- package/lib/types/service/sessions.d.ts +8 -0
- package/lib/types/service/settings-sections.d.ts +5 -14
- package/lib/types/web/routes.d.ts +4 -2
- package/lib/types/web/welcome-ack.d.ts +3 -3
- package/lib/web/routes.js +25 -1
- package/lib/web/welcome-ack.js +5 -5
- package/package.json +19 -11
package/lib/service/lifecycle.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { clearConnection, connectionOf, storeConnection, } from '../hub/credentials.js';
|
|
2
2
|
import { codeOf, ConfigurationError } from '../hub/errors.js';
|
|
3
3
|
import { initialState } from '../hub/facts.js';
|
|
4
|
-
import { installSections } from './settings-sections.js';
|
|
5
4
|
import { appliedState, publishFailed, publishStatus } from './publish.js';
|
|
6
|
-
import { readOwned, readWorkspaces } from './namespaces.js';
|
|
5
|
+
import { readOwned, readWorkspaces, SEMI2_SETTINGS_ENTRY } from './namespaces.js';
|
|
7
6
|
import { DOMAINS } from './registry.js';
|
|
8
|
-
import { reportEvent } from './audit.js';
|
|
9
7
|
import { connectionSnapshot, exchangeCnbCredential, resolveConnectionWithRetry, resolveContract, } from './connection.js';
|
|
10
8
|
/** 首次加载;未配凭据或失败只落到 state.error,绝不 throw,也不注入任何配置。 */
|
|
11
9
|
export async function start(engine) {
|
|
@@ -13,22 +11,14 @@ export async function start(engine) {
|
|
|
13
11
|
const settings = await engine.ports.settings();
|
|
14
12
|
engine.setSettings(settings);
|
|
15
13
|
if (settings !== undefined) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// 带范围的同步:`workspace:<slug>` 只同步那一个工作区,其余整体重载。
|
|
19
|
-
// 两条路都要回写状态:`sync` 是触发字段,只有复位成空串,下一次同名写入才算变更。
|
|
20
|
-
if (value.startsWith('workspace:')) {
|
|
21
|
-
void engine
|
|
22
|
-
.workspace()
|
|
23
|
-
.syncBySlug(value.slice('workspace:'.length))
|
|
24
|
-
.then(() => publishStatus(engine));
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
void reload(engine);
|
|
28
|
-
},
|
|
29
|
-
});
|
|
14
|
+
// 状态段是展示面,不是给用户编辑的表单。
|
|
15
|
+
engine.ports.hideSettingsPage(settings, engine.ctx);
|
|
30
16
|
// 上次的映射先恢复:headless 面无 dsh 工作区服务,这份本地事实就是全部列表。
|
|
31
|
-
|
|
17
|
+
// 读之前要等条目 ACTIVE——dsh 的 describe() 看不见还没激活的条目,读早了会把映射读成空。
|
|
18
|
+
if (await engine.ports.awaitNamespace(settings, SEMI2_SETTINGS_ENTRY))
|
|
19
|
+
engine.workspace().restore(readWorkspaces(settings));
|
|
20
|
+
else
|
|
21
|
+
engine.warn('设置段未就绪,未恢复上次的工作区映射');
|
|
32
22
|
}
|
|
33
23
|
await ensureContract(engine);
|
|
34
24
|
engine.workspace().ensureImplicit();
|
|
@@ -39,20 +29,6 @@ export async function start(engine) {
|
|
|
39
29
|
else
|
|
40
30
|
await load(engine);
|
|
41
31
|
await publishStatus(engine);
|
|
42
|
-
// 会话启动 = 首次配置领取成功;未配凭据 / 领取失败都不是「会话起来过」。
|
|
43
|
-
// NPC 面自己的会话起点由适配器上报(带触发事实),这里只覆盖另外两个 Web 面与宿主直跑。
|
|
44
|
-
if (engine.connection() !== null &&
|
|
45
|
-
engine.state().status === 'ready' &&
|
|
46
|
-
engine.contract()?.face !== 'npc')
|
|
47
|
-
await reportEvent(engine, {
|
|
48
|
-
action: 'SESSION_START',
|
|
49
|
-
outcome: 'SUCCESS',
|
|
50
|
-
detail: {
|
|
51
|
-
platform: engine.platform,
|
|
52
|
-
consoleUrl: engine.connection()?.url ?? null,
|
|
53
|
-
identity: engine.state().identity?.username ?? null,
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
32
|
}
|
|
57
33
|
finally {
|
|
58
34
|
engine.settle();
|
|
@@ -72,6 +48,24 @@ export async function reload(engine) {
|
|
|
72
48
|
}
|
|
73
49
|
return load(engine);
|
|
74
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* 界面「同步」动作:`workspace:<slug>` 只同步那一个工作区,其余(含空串)整体重载。
|
|
53
|
+
* 两条路都把结果回写状态段;失败只 warn(面板上的错误由状态字段承载)。
|
|
54
|
+
*/
|
|
55
|
+
export async function syncByScope(engine, scope) {
|
|
56
|
+
try {
|
|
57
|
+
if (scope.startsWith('workspace:')) {
|
|
58
|
+
await engine.workspace().syncBySlug(scope.slice('workspace:'.length));
|
|
59
|
+
await publishStatus(engine);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await reload(engine);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
engine.warn(`同步失败(${codeOf(error)})`);
|
|
66
|
+
await publishStatus(engine);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
75
69
|
/** 读取一次运行配置;整体失败只落 state.error,绝不 throw(start/reload 均可重试)。 */
|
|
76
70
|
export async function load(engine) {
|
|
77
71
|
engine.patchState({ status: 'loading', error: null });
|
|
@@ -108,6 +102,9 @@ async function applyBoot(engine, boot, errorCode) {
|
|
|
108
102
|
engine.markSettingsMissing();
|
|
109
103
|
let compiled = null;
|
|
110
104
|
if (settings !== undefined) {
|
|
105
|
+
// 归属表读的是自身设置段:条目还没 ACTIVE 时 describe() 看不见它,读早了会当成「上次没注入过」。
|
|
106
|
+
if (!(await engine.ports.awaitNamespace(settings, SEMI2_SETTINGS_ENTRY)))
|
|
107
|
+
engine.warn('设置段未就绪,本轮按「上次无注入记录」继续');
|
|
111
108
|
const previousOwned = readOwned(settings);
|
|
112
109
|
const run = {
|
|
113
110
|
boot,
|
|
@@ -131,7 +128,7 @@ async function applyBoot(engine, boot, errorCode) {
|
|
|
131
128
|
compiled = run.compiled;
|
|
132
129
|
// 归属表 = 本轮实际写入的事实;某领域降级时保持上次的值(不动即不回收)。
|
|
133
130
|
try {
|
|
134
|
-
await engine.ports.updateNamespace(settings,
|
|
131
|
+
await engine.ports.updateNamespace(settings, SEMI2_SETTINGS_ENTRY, { ...run.owned });
|
|
135
132
|
}
|
|
136
133
|
catch (error) {
|
|
137
134
|
failures.push(engine.noteFailure('注入归属表', error));
|
|
@@ -209,10 +206,11 @@ async function reclaim(engine) {
|
|
|
209
206
|
engine.warn(`领域 ${descriptor.id} 回收失败(${codeOf(error)})`);
|
|
210
207
|
}
|
|
211
208
|
}
|
|
212
|
-
await engine.ports.updateNamespace(settings,
|
|
209
|
+
await engine.ports.updateNamespace(settings, SEMI2_SETTINGS_ENTRY, {
|
|
213
210
|
routes: [],
|
|
214
211
|
credentials: [],
|
|
215
212
|
deepseek: false,
|
|
213
|
+
deepseekSearchKeyEnv: '',
|
|
216
214
|
});
|
|
217
215
|
}
|
|
218
216
|
catch (error) {
|
|
@@ -1,20 +1,34 @@
|
|
|
1
|
-
// @fa-software/semi2-dsh/service/namespaces ——
|
|
2
|
-
//
|
|
3
|
-
// `
|
|
1
|
+
// @fa-software/semi2-dsh/service/namespaces —— 插件自身设置段的字段与读取(无环叶子模块)。
|
|
2
|
+
// dsh 的设置段 = Profile 插件条目,一个条目只有一段:状态 / 健康 / 工作区 tab / 归属表 / 工作区映射
|
|
3
|
+
// 同住 `SEMI2_SETTINGS_ENTRY` 这一段,各写各的键(写入是合并,谁也不冲掉谁)。
|
|
4
4
|
import z from '@deepseek-ai/schemastery';
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export const
|
|
5
|
+
import { SEMI2_SETTINGS_ENTRY } from '../contract.js';
|
|
6
|
+
export { SEMI2_SETTINGS_ENTRY };
|
|
7
|
+
/**
|
|
8
|
+
* 归属表字段:预设注入的路由与密钥引用。
|
|
9
|
+
* 「上次注入的条目」与「用户自己写的同名条目」无法从名字分辨,这份账本是唯一依据。
|
|
10
|
+
*/
|
|
11
|
+
export const OWNED_FIELDS = {
|
|
12
12
|
routes: z.array(z.string()).default([]),
|
|
13
13
|
credentials: z.array(z.string()).default([]),
|
|
14
14
|
deepseek: z.boolean().default(false),
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
deepseekSearchKeyEnv: z.string().default(''),
|
|
16
|
+
};
|
|
17
|
+
/** 工作区 → git 项目映射的字段:本地事实(换台机器要重解析),只存目录与解析结果。 */
|
|
18
|
+
export const WORKSPACES_FIELDS = {
|
|
19
|
+
entries: z
|
|
20
|
+
.array(z.object({
|
|
21
|
+
path: z.string().default(''),
|
|
22
|
+
title: z.string().default(''),
|
|
23
|
+
slug: z.string().default(''),
|
|
24
|
+
projectId: z.string().default(''),
|
|
25
|
+
name: z.string().default(''),
|
|
26
|
+
syncedAt: z.string().default(''),
|
|
27
|
+
}))
|
|
28
|
+
.default([]),
|
|
29
|
+
};
|
|
30
|
+
/** 一个设置段的当前有效值:官方描述符的 `value` 是不透明值,本插件只认对象段,其余按空段处理。 */
|
|
31
|
+
export function sectionValue(settings, ns = SEMI2_SETTINGS_ENTRY) {
|
|
18
32
|
const value = settings.describe().find(entry => entry.ns === ns)?.value;
|
|
19
33
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
20
34
|
? value
|
|
@@ -25,12 +39,28 @@ function stringsOf(input) {
|
|
|
25
39
|
? input.filter((item) => typeof item === 'string')
|
|
26
40
|
: [];
|
|
27
41
|
}
|
|
42
|
+
/** 本插件自铸的密钥引用名(`connectionApiKeyEnv` 的形状):账本丢了也认得出「这是我们写过的键」。 */
|
|
43
|
+
const OWNED_KEY_ENV = /^SEMI2_MODEL_KEY_[\dA-F]+$/;
|
|
44
|
+
/** 官方段里留着的自铸引用 = 上一轮注入的痕迹(该段的值在,账本不在)。 */
|
|
45
|
+
function ownedKeyEnvOf(settings, ns) {
|
|
46
|
+
const value = sectionValue(settings, ns).apiKeyEnv;
|
|
47
|
+
return typeof value === 'string' && OWNED_KEY_ENV.test(value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 本插件写过什么的账本;账本缺失(升级时旧设置段迁不进来、或换了台机器)时按自铸引用名认领:
|
|
51
|
+
* 认领只会多回收自己写过的东西,不会碰用户手写的段。
|
|
52
|
+
*/
|
|
28
53
|
export function readOwned(settings) {
|
|
29
|
-
const value = sectionValue(settings
|
|
54
|
+
const value = sectionValue(settings);
|
|
55
|
+
const deepseekSearchKeyEnv = typeof value.deepseekSearchKeyEnv === 'string' &&
|
|
56
|
+
value.deepseekSearchKeyEnv !== ''
|
|
57
|
+
? value.deepseekSearchKeyEnv
|
|
58
|
+
: null;
|
|
30
59
|
return {
|
|
31
60
|
routes: stringsOf(value.routes),
|
|
32
61
|
credentials: stringsOf(value.credentials),
|
|
33
|
-
deepseek: value.deepseek === true,
|
|
62
|
+
deepseek: value.deepseek === true || ownedKeyEnvOf(settings, 'llm-deepseek') !== null,
|
|
63
|
+
deepseekSearchKeyEnv: deepseekSearchKeyEnv ?? ownedKeyEnvOf(settings, 'web-search-deepseek'),
|
|
34
64
|
};
|
|
35
65
|
}
|
|
36
66
|
function text(value) {
|
|
@@ -39,7 +69,7 @@ function text(value) {
|
|
|
39
69
|
}
|
|
40
70
|
/** 持久化段 → 工作区条目;形状不合的行直接丢弃,不让坏数据拖垮启动。 */
|
|
41
71
|
export function readWorkspaces(settings) {
|
|
42
|
-
const value = sectionValue(settings
|
|
72
|
+
const value = sectionValue(settings);
|
|
43
73
|
const rows = Array.isArray(value.entries) ? value.entries : [];
|
|
44
74
|
const entries = [];
|
|
45
75
|
for (const row of rows) {
|
package/lib/service/ports.js
CHANGED
|
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
2
2
|
import { ConfigurationError } from '../hub/errors.js';
|
|
3
3
|
const SETTINGS_TIMEOUT_MS = 5_000;
|
|
4
4
|
const NAMESPACE_WAIT_MS = 2_000;
|
|
5
|
-
/**
|
|
5
|
+
/** 设置段就绪轮询间隔(纯内部预算,不单列到 Config)。 */
|
|
6
6
|
const NAMESPACE_POLL_MS = 50;
|
|
7
7
|
export function createPorts(ctx, warn, options = {}) {
|
|
8
8
|
const settingsTimeoutMs = options.settingsTimeoutMs ?? SETTINGS_TIMEOUT_MS;
|
|
@@ -28,14 +28,20 @@ export function createPorts(ctx, warn, options = {}) {
|
|
|
28
28
|
credentialsNow: () => ctx.get('credentials', false),
|
|
29
29
|
agentDefaultModel: () => ctx.get('agentDefaultModel', false),
|
|
30
30
|
hasWorkspaceController: () => ctx.get('workspaceController', false) !== undefined,
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
awaitNamespace: (settings, ns) => waitForNamespace(settings, ns, namespaceWaitMs),
|
|
32
|
+
updateNamespace: async (settings, ns, patch) => {
|
|
33
|
+
await requireNamespace(settings, ns, namespaceWaitMs);
|
|
34
|
+
await settings.update(ns, patch);
|
|
35
|
+
},
|
|
36
|
+
replaceNamespace: async (settings, ns, section) => {
|
|
37
|
+
await requireNamespace(settings, ns, namespaceWaitMs);
|
|
33
38
|
const replace = settings.replace?.bind(settings);
|
|
34
39
|
if (replace === undefined) {
|
|
35
40
|
warn(`宿主 settings 无 replace,${ns} 退回合并写入:服务端已撤下的条目可能残留`);
|
|
36
|
-
|
|
41
|
+
await settings.update(ns, section);
|
|
42
|
+
return;
|
|
37
43
|
}
|
|
38
|
-
|
|
44
|
+
await replace(ns, section);
|
|
39
45
|
},
|
|
40
46
|
clearKeys: async (settings, ns, keys) => {
|
|
41
47
|
const mutate = settings.mutate?.bind(settings);
|
|
@@ -43,22 +49,39 @@ export function createPorts(ctx, warn, options = {}) {
|
|
|
43
49
|
warn(`宿主 settings 无 mutate,${ns} 无法按路径删除:服务端已撤销的覆盖可能残留`);
|
|
44
50
|
return;
|
|
45
51
|
}
|
|
52
|
+
await requireNamespace(settings, ns, namespaceWaitMs);
|
|
46
53
|
const ops = keys.map(key => ({ op: 'unset', path: [key] }));
|
|
47
|
-
await
|
|
54
|
+
await mutate(ns, ops);
|
|
55
|
+
},
|
|
56
|
+
hideSettingsPage: (settings, owner) => {
|
|
57
|
+
const configure = settings.configure?.bind(settings);
|
|
58
|
+
if (configure === undefined)
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
// 回收器挂在本插件光纤上:插件卸载后不留一条指向已死光纤的展示策略。
|
|
62
|
+
const dispose = configure({ auto: false }, owner.fiber);
|
|
63
|
+
owner.effect(() => dispose);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
warn(`设置页自动生成关闭失败(${error instanceof Error ? error.message : String(error)})`);
|
|
67
|
+
}
|
|
48
68
|
},
|
|
49
69
|
};
|
|
50
70
|
}
|
|
51
|
-
/**
|
|
52
|
-
async function
|
|
71
|
+
/** 等设置段可描述(条目 ACTIVE 且声明了 volatile 字段);超时返回 false,不抛。 */
|
|
72
|
+
async function waitForNamespace(settings, ns, waitMs) {
|
|
53
73
|
const deadline = Date.now() + waitMs;
|
|
54
74
|
for (;;) {
|
|
55
|
-
if (settings.describe().some(entry => entry.ns === ns))
|
|
56
|
-
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
75
|
+
if (settings.describe().some(entry => entry.ns === ns))
|
|
76
|
+
return true;
|
|
59
77
|
if (Date.now() >= deadline)
|
|
60
|
-
|
|
78
|
+
return false;
|
|
61
79
|
await delay(NAMESPACE_POLL_MS);
|
|
62
80
|
}
|
|
81
|
+
}
|
|
82
|
+
/** 写路径的就绪门:条目还不可描述就拒绝,由调用方降级(不静默写进空气)。 */
|
|
83
|
+
async function requireNamespace(settings, ns, waitMs) {
|
|
84
|
+
if (await waitForNamespace(settings, ns, waitMs))
|
|
85
|
+
return;
|
|
63
86
|
throw new ConfigurationError('CONFIG_APPLY_FAILED');
|
|
64
87
|
}
|
package/lib/service/publish.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { statusFields, workspaceTabs, WORKSPACE_TABS_FIELD, } from '../hub/status.js';
|
|
2
|
-
import {
|
|
3
|
-
import { SETTINGS_NAMESPACE } from './namespaces.js';
|
|
2
|
+
import { SEMI2_SETTINGS_ENTRY } from './namespaces.js';
|
|
4
3
|
import { aggregateHealth, healthText } from './health.js';
|
|
5
4
|
import { DOMAINS } from './registry.js';
|
|
6
5
|
import { reportEvent } from './audit.js';
|
|
7
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* 状态 + 健康 + 工作区 tab 一次写回。
|
|
8
|
+
*
|
|
9
|
+
* 用合并写入而不是 `replace`:这一段落里还有归属表与工作区映射,`replace` 会把它们一起重置。
|
|
10
|
+
* 状态字段名是编译期常量集合,合并写入不存在「服务端撤下的条目残留」问题。
|
|
11
|
+
*/
|
|
8
12
|
export async function publishStatus(engine) {
|
|
9
13
|
const settings = engine.settings();
|
|
10
14
|
if (settings === undefined)
|
|
@@ -29,13 +33,9 @@ export async function publishStatus(engine) {
|
|
|
29
33
|
`${item.state}:${healthText(item)}`,
|
|
30
34
|
])),
|
|
31
35
|
[WORKSPACE_TABS_FIELD]: workspaceTabs(engine.state()),
|
|
32
|
-
[SYNC_FIELD]: '',
|
|
33
36
|
};
|
|
34
37
|
try {
|
|
35
|
-
|
|
36
|
-
await settings.replace(SETTINGS_NAMESPACE, section);
|
|
37
|
-
else
|
|
38
|
-
await settings.update(SETTINGS_NAMESPACE, section);
|
|
38
|
+
await engine.ports.updateNamespace(settings, SEMI2_SETTINGS_ENTRY, section);
|
|
39
39
|
}
|
|
40
40
|
catch {
|
|
41
41
|
engine.warn('设置页状态写入失败');
|
package/lib/service/registry.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultModelDomain } from '../domains/default-model/index.js';
|
|
2
2
|
import { modelsDomain } from '../domains/models/index.js';
|
|
3
|
+
import { redmineDomain } from '../domains/redmine/index.js';
|
|
3
4
|
import { secretsDomain } from '../domains/secrets/index.js';
|
|
4
5
|
import { selfPortraitDomain } from '../domains/self-portrait/index.js';
|
|
5
6
|
import { skillsDomain } from '../domains/skills/index.js';
|
|
@@ -12,4 +13,5 @@ export const DOMAINS = [
|
|
|
12
13
|
workspaceDomain,
|
|
13
14
|
defaultModelDomain,
|
|
14
15
|
selfPortraitDomain,
|
|
16
|
+
redmineDomain,
|
|
15
17
|
];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @fa-software/semi2-dsh/service/sessions —— dsh 会话生命周期上报:一次会话一行。
|
|
2
|
+
// 只报顶层会话(子 agent 的会话跳过);dsh-session 服务缺席或上报失败都只 warn 降级,
|
|
3
|
+
// 会话照跑——hub 的客户端会话日志是旁路事实,不是会话的前提。
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { canonicalPath } from '../hub/workspace-book.js';
|
|
6
|
+
import { reportEvent } from './audit.js';
|
|
7
|
+
/**
|
|
8
|
+
* 本面实例标识:同名 dsh 会话(`session-1`)在不同容器里会撞车,hub 靠它分开。
|
|
9
|
+
* 每次进程启动生成一个,进程内所有会话共用。
|
|
10
|
+
*/
|
|
11
|
+
const CLIENT_ID = randomUUID();
|
|
12
|
+
/** 子 agent 的会话不是「用户开的会话」:不建行、不计数。 */
|
|
13
|
+
function isUserSession(session) {
|
|
14
|
+
return session.header.origin !== 'subagent';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 会话归属:cwd 命中的已登记工作区给 slug,项目由工作区领域按 cwd 解析。
|
|
18
|
+
* 拿不到就是拿不到(不落占位值)——headless 面没有工作区服务时也走这条。
|
|
19
|
+
*/
|
|
20
|
+
async function scopeOf(engine, cwd) {
|
|
21
|
+
const directory = cwd?.trim() ?? '';
|
|
22
|
+
if (directory === '')
|
|
23
|
+
return { workspacePath: null, workspaceSlug: null, projectSlug: null };
|
|
24
|
+
const canonical = await canonicalPath(directory);
|
|
25
|
+
const entry = engine
|
|
26
|
+
.workspace()
|
|
27
|
+
.tracked()
|
|
28
|
+
.find(item => item.path === canonical);
|
|
29
|
+
const project = await engine.peer('workspace')?.projectForCwd(canonical);
|
|
30
|
+
return {
|
|
31
|
+
workspacePath: canonical,
|
|
32
|
+
workspaceSlug: entry?.slug ?? null,
|
|
33
|
+
projectSlug: project?.slug ?? null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** 会话创建:带上工作区与默认模型选择,hub 据此建行。 */
|
|
37
|
+
async function reportCreated(engine, session) {
|
|
38
|
+
const scope = await scopeOf(engine, session.header.cwd);
|
|
39
|
+
const selection = engine.ctx.get('agentDefaultModel', false)?.currentSelection?.();
|
|
40
|
+
await reportEvent(engine, {
|
|
41
|
+
action: 'SESSION_CREATED',
|
|
42
|
+
outcome: 'SUCCESS',
|
|
43
|
+
code: 'created',
|
|
44
|
+
sessionId: session.id,
|
|
45
|
+
clientId: CLIENT_ID,
|
|
46
|
+
...(scope.workspacePath === null ? {} : { workspacePath: scope.workspacePath }),
|
|
47
|
+
...(scope.workspaceSlug === null ? {} : { workspaceSlug: scope.workspaceSlug }),
|
|
48
|
+
...(scope.projectSlug === null ? {} : { projectSlug: scope.projectSlug }),
|
|
49
|
+
...(selection?.model === undefined || selection.model === ''
|
|
50
|
+
? {}
|
|
51
|
+
: { modelId: selection.model }),
|
|
52
|
+
...(selection?.reasoningEffort === undefined ||
|
|
53
|
+
selection.reasoningEffort === ''
|
|
54
|
+
? {}
|
|
55
|
+
: { reasoningLevel: selection.reasoningEffort }),
|
|
56
|
+
detail: { cwd: scope.workspacePath },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** 会话销毁:回填结束时间与原因码;进程被强杀时没有这一条(页面显示「无结束行」)。 */
|
|
60
|
+
async function reportDisposed(engine, session) {
|
|
61
|
+
await reportEvent(engine, {
|
|
62
|
+
action: 'SESSION_DISPOSED',
|
|
63
|
+
outcome: 'SUCCESS',
|
|
64
|
+
code: 'dispose',
|
|
65
|
+
sessionId: session.id,
|
|
66
|
+
clientId: CLIENT_ID,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 挂上会话订阅:dsh 的会话是事件源的(`session/created` / `session/disposed`),
|
|
71
|
+
* 本插件只旁听,不改会话行为。`sessions` 服务缺席时整块跳过。
|
|
72
|
+
* 返回订阅光纤(可 await 到就绪),测试据它确认监听已挂上。
|
|
73
|
+
*/
|
|
74
|
+
export function installSessionReporting(engine, ctx = engine.ctx) {
|
|
75
|
+
return ctx.inject(['sessions'], (scoped) => {
|
|
76
|
+
const track = (session, report) => {
|
|
77
|
+
if (!isUserSession(session))
|
|
78
|
+
return;
|
|
79
|
+
void report(engine, session).catch((error) => {
|
|
80
|
+
engine.warn(`会话上报失败:${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
scoped.on('session/created', (session) => {
|
|
84
|
+
track(session, reportCreated);
|
|
85
|
+
});
|
|
86
|
+
scoped.on('session/disposed', (session) => {
|
|
87
|
+
track(session, reportDisposed);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -1,29 +1,22 @@
|
|
|
1
|
-
// @fa-software/semi2-dsh/service/settings-sections ——
|
|
2
|
-
//
|
|
1
|
+
// @fa-software/semi2-dsh/service/settings-sections —— 插件自身设置段的字段声明(按注册表聚合生成)。
|
|
2
|
+
// dsh 的设置段 = Profile 插件条目(id = `SEMI2_SETTINGS_ENTRY`):只有声明为 volatile 的字段才会被
|
|
3
|
+
// `describe()` 看见、才能实时写入,所以状态 / 健康 / 工作区 tab / 归属表 / 工作区映射全部在这里声明;
|
|
4
|
+
// 数值旋钮不在此列——它们是加载期参数,改动理应重挂载。
|
|
3
5
|
import z from '@deepseek-ai/schemastery';
|
|
4
6
|
import { STATUS_BASE_FIELDS, WORKSPACE_TABS_FIELD } from '../hub/status.js';
|
|
7
|
+
import { OWNED_FIELDS, WORKSPACES_FIELDS } from './namespaces.js';
|
|
5
8
|
import { DOMAINS } from './registry.js';
|
|
6
|
-
|
|
7
|
-
/** UI 触发重载的字段:写入任意非空值即触发(写后由状态回报复位为空,无令牌比对)。 */
|
|
8
|
-
export const SYNC_FIELD = 'sync';
|
|
9
|
-
/** 完整状态字段顺序(基座 + 按注册表顺序的领域字段);UI 与健康字段共用命名空间。 */
|
|
9
|
+
/** 完整状态字段顺序(基座 + 按注册表顺序的领域字段);UI 与健康字段共用这一段。 */
|
|
10
10
|
export const STATUS_FIELDS = [
|
|
11
11
|
...STATUS_BASE_FIELDS,
|
|
12
12
|
...DOMAINS.flatMap(domain => [...domain.statusKeys]),
|
|
13
13
|
];
|
|
14
|
-
/**
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
slug: z.string().default(''),
|
|
21
|
-
projectId: z.string().default(''),
|
|
22
|
-
name: z.string().default(''),
|
|
23
|
-
syncedAt: z.string().default(''),
|
|
24
|
-
}))
|
|
25
|
-
.default([]),
|
|
26
|
-
});
|
|
14
|
+
/** 健康字段 schema 键(能力 + 领域,值是 `ok:正常` 形式的编码串;读侧拆码)。 */
|
|
15
|
+
export const HEALTH_FIELDS_SCHEMA_KEYS = [
|
|
16
|
+
'healthWeb',
|
|
17
|
+
'healthCodegraph',
|
|
18
|
+
...DOMAINS.map(domain => `health:${domain.id}`),
|
|
19
|
+
];
|
|
27
20
|
/** tab 列表字段:结构化数组(界面按作用域渲染用;扁平字符串继续留给纯文本展示)。 */
|
|
28
21
|
const WORKSPACE_TABS_SCHEMA = z.array(z.object({
|
|
29
22
|
slug: z.string().default(''),
|
|
@@ -32,47 +25,15 @@ const WORKSPACE_TABS_SCHEMA = z.array(z.object({
|
|
|
32
25
|
project: z.string().default(''),
|
|
33
26
|
syncedAt: z.string().default(''),
|
|
34
27
|
implicit: z.boolean().default(false),
|
|
35
|
-
}))
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'healthCodegraph',
|
|
40
|
-
...DOMAINS.map(domain => `health:${domain.id}`),
|
|
41
|
-
];
|
|
42
|
-
const STATUS_SCHEMA = Object.fromEntries([...STATUS_FIELDS, ...HEALTH_FIELDS_SCHEMA_KEYS].map(key => [key, z.string().default('')]));
|
|
43
|
-
/** 安装三个命名空间;只在拿到 settings 服务后调用一次。 */
|
|
44
|
-
export function installSections(ctx, settings, hooks) {
|
|
45
|
-
settings.installSection(ctx, SETTINGS_NAMESPACE, z.object({
|
|
46
|
-
[SYNC_FIELD]: z.string().default(''),
|
|
47
|
-
...STATUS_SCHEMA,
|
|
48
|
-
[WORKSPACE_TABS_FIELD]: WORKSPACE_TABS_SCHEMA,
|
|
49
|
-
}), {
|
|
50
|
-
...Object.fromEntries([SYNC_FIELD, ...STATUS_FIELDS, ...HEALTH_FIELDS_SCHEMA_KEYS].map(key => [key, ''])),
|
|
51
|
-
[WORKSPACE_TABS_FIELD]: [],
|
|
52
|
-
}, {
|
|
53
|
-
setSource: () => { },
|
|
54
|
-
onChange: () => {
|
|
55
|
-
// 用户写入才会走到这里;读有效值判断是否 sync 触发(本插件自己的 replace 也会触发 onChange,
|
|
56
|
-
// 但回报时 sync 恒写空串,不会成环)。
|
|
57
|
-
const value = sectionValue(settings, SETTINGS_NAMESPACE)[SYNC_FIELD];
|
|
58
|
-
if (typeof value === 'string' && value !== '')
|
|
59
|
-
hooks.onSyncRequest(value);
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
// 归属表由插件自己写、自己读,不暴露给用户;没有它就无法区分
|
|
63
|
-
// 「上次注入的条目」与「用户自己写的同名条目」。
|
|
64
|
-
settings.installSection(ctx, OWNED_NAMESPACE, OWNED_SCHEMA, {
|
|
65
|
-
routes: [],
|
|
66
|
-
credentials: [],
|
|
67
|
-
deepseek: false,
|
|
68
|
-
}, { setSource: () => { }, onChange: () => { } });
|
|
69
|
-
// 工作区映射同理:纯本地事实,用户不编辑(工作区增删在 dsh 自己的工作区面板)。
|
|
70
|
-
settings.installSection(ctx, WORKSPACES_NAMESPACE, WORKSPACES_SCHEMA, {
|
|
71
|
-
entries: [],
|
|
72
|
-
}, { setSource: () => { }, onChange: () => { } });
|
|
73
|
-
}
|
|
74
|
-
/** 读 `semi2` 命名空间的一个字段(用户写入后、回报前的过渡值)。 */
|
|
75
|
-
export function readSetting(settings, key) {
|
|
76
|
-
const value = sectionValue(settings, SETTINGS_NAMESPACE)[key];
|
|
77
|
-
return typeof value === 'string' ? value : '';
|
|
28
|
+
}));
|
|
29
|
+
/** 一个字段表 → 全部置 volatile(整体一个可实时写的节点,子路径共用该性质)。 */
|
|
30
|
+
function allVolatile(fields) {
|
|
31
|
+
return Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.volatile()]));
|
|
78
32
|
}
|
|
33
|
+
/** 插件 Config 的可写字段表(并入入口的 Config schema)。 */
|
|
34
|
+
export const STATE_CONFIG_FIELDS = {
|
|
35
|
+
...allVolatile(Object.fromEntries([...STATUS_FIELDS, ...HEALTH_FIELDS_SCHEMA_KEYS].map(key => [key, z.string().default('')]))),
|
|
36
|
+
[WORKSPACE_TABS_FIELD]: WORKSPACE_TABS_SCHEMA.default([]).volatile(),
|
|
37
|
+
...allVolatile(OWNED_FIELDS),
|
|
38
|
+
...allVolatile(WORKSPACES_FIELDS),
|
|
39
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** 启动装配:五个用户级领域一个文档(项目绑定按工作区另行按需取)。 */
|
|
2
|
-
export declare const RUNTIME_BOOT_DOCUMENT = "query RuntimeBoot {\n runtimeViewer {\n id\n username\n displayName\n }\n runtimeModels {\n id\n upstreamId\n displayName\n metadata { contextWindow maxTokens input reasoningLevels }\n defaultReasoningLevel\n connection { id provider protocol baseUrl }\n }\n runtimeDefaultModelId\n runtimeHeadlessModels {\n planModelId\n executeModelId\n }\n runtimeModelCredentials {\n connectionId\n value\n }\n
|
|
2
|
+
export declare const RUNTIME_BOOT_DOCUMENT = "query RuntimeBoot {\n runtimeViewer {\n id\n username\n displayName\n }\n runtimeModels {\n id\n upstreamId\n displayName\n metadata { contextWindow maxTokens input reasoningLevels }\n defaultReasoningLevel\n connection { id provider protocol baseUrl }\n }\n runtimeDefaultModelId\n runtimeHeadlessModels {\n planModelId\n executeModelId\n }\n runtimeModelCredentials {\n connectionId\n value\n }\n runtimeSkillCatalog {\n id\n projectId\n name\n description\n whenToUse\n invocation {\n modelInvocable\n userInvocable\n }\n metadata\n content\n version\n files {\n path\n sizeBytes\n mimeType\n isExecutable\n }\n fileCount\n filesBytes\n }\n runtimeSelfPortrait\n runtimeRedmine {\n baseUrl\n apiKey\n }\n}";
|
|
3
3
|
/** 技能附属资源字节:boot 文档只带清单,真正加载某个技能时才按需取这一条。可空根字段:读取者不可见 / 不存在回空数组(权威「没有」),领域失败回 null(降级,不物化)。 */
|
|
4
4
|
export declare const RUNTIME_SKILL_FILES_DOCUMENT = "query RuntimeSkillFiles($skillId: ID!) {\n runtimeSkillFiles(skillId: $skillId) {\n path\n content\n encoding\n sizeBytes\n mimeType\n isExecutable\n }\n}";
|
|
5
5
|
/** 项目绑定按 slug 取:只问已登记工作区命中的那几个项目,绝不把整张项目表拉回本地。`slugs` 是读范围之上的过滤(不是绕过),空数组 = 空集合。 */
|
|
@@ -14,21 +14,34 @@ export type InputModality = 'IMAGE' | 'TEXT';
|
|
|
14
14
|
export type ModelProtocol = 'ANTHROPIC' | 'DEEPSEEK' | 'OPENAI_CHAT' | 'OPENAI_RESPONSES';
|
|
15
15
|
export type ReasoningLevel = 'HIGH' | 'LOW' | 'MAX' | 'MEDIUM' | 'MINIMAL' | 'OFF' | 'XHIGH';
|
|
16
16
|
/** 运行面可上报的动作:会话启停、拒绝、交付分支与交付核对。 */
|
|
17
|
-
export type RuntimeEventAction = 'DELIVERY_READY' | 'DELIVERY_VERIFIED' | '
|
|
17
|
+
export type RuntimeEventAction = 'DELIVERY_READY' | 'DELIVERY_VERIFIED' | 'SESSION_CREATED' | 'SESSION_DISPOSED' | 'SESSION_FAILED' | 'SESSION_REFUSED' | 'SESSION_START';
|
|
18
18
|
/**
|
|
19
19
|
* 运行面上报:只接受用户密钥身份,动作码限闭集;detail 由服务端脱敏后落库。
|
|
20
|
-
* 目标项目按 slug
|
|
20
|
+
* 目标项目按 slug 解析:命中可读项目时挂项目 id,否则只留 slug 快照。
|
|
21
|
+
* 带会话身份(runRef 或 sessionId + clientId)的事件会折叠进同一行客户端会话。
|
|
21
22
|
*/
|
|
22
23
|
export type RuntimeEventInput = {
|
|
23
24
|
action: RuntimeEventAction;
|
|
24
|
-
/**
|
|
25
|
+
/** 运行面实例标识(插件进程内生成):同名 dsh 会话在不同容器里靠它分开。 */
|
|
26
|
+
clientId?: string | null | undefined;
|
|
27
|
+
/** 原因码 / 交付状态码(例 SETUP_FAILED、delivered、dispose)。 */
|
|
25
28
|
code?: string | null | undefined;
|
|
26
29
|
detail?: unknown;
|
|
30
|
+
/** 会话使用的模型与档位(拿不到留空)。 */
|
|
31
|
+
modelId?: string | null | undefined;
|
|
27
32
|
outcome: AuditOutcome;
|
|
28
|
-
/** 项目 slug(CNB 仓库 slug
|
|
33
|
+
/** 项目 slug(CNB 仓库 slug 或已登记工作区 slug),用于把事件挂到项目上。 */
|
|
29
34
|
projectSlug?: string | null | undefined;
|
|
35
|
+
reasoningLevel?: string | null | undefined;
|
|
36
|
+
/** 任务引用 `<repo>#<issue|pr>#<iid>`:NPC 一次任务的稳定身份,会话尚未创建也带。 */
|
|
37
|
+
runRef?: string | null | undefined;
|
|
38
|
+
/** dsh 会话 id(会话创建 / 销毁事件给出)。 */
|
|
39
|
+
sessionId?: string | null | undefined;
|
|
30
40
|
/** 目标展示名(例 `issue-12` / `PR #34`)。 */
|
|
31
41
|
targetLabel?: string | null | undefined;
|
|
42
|
+
/** 本次会话的 cwd 与命中的已登记工作区 slug。 */
|
|
43
|
+
workspacePath?: string | null | undefined;
|
|
44
|
+
workspaceSlug?: string | null | undefined;
|
|
32
45
|
};
|
|
33
46
|
/** 技能附件内容的编码:文本类用 UTF8,其余(图片 / 二进制)用 BASE64。 */
|
|
34
47
|
export type SkillFileEncoding = 'BASE64' | 'UTF8';
|
|
@@ -69,16 +82,21 @@ export type RuntimeBootQuery = {
|
|
|
69
82
|
connectionId: string;
|
|
70
83
|
value: string;
|
|
71
84
|
}> | null;
|
|
72
|
-
|
|
85
|
+
runtimeSkillCatalog: Array<{
|
|
73
86
|
id: string;
|
|
74
87
|
projectId: string | null;
|
|
75
88
|
name: string;
|
|
76
89
|
description: string;
|
|
77
90
|
whenToUse: string | null;
|
|
91
|
+
metadata: unknown;
|
|
78
92
|
content: string;
|
|
79
93
|
version: number;
|
|
80
94
|
fileCount: number;
|
|
81
95
|
filesBytes: number;
|
|
96
|
+
invocation: {
|
|
97
|
+
modelInvocable: boolean;
|
|
98
|
+
userInvocable: boolean;
|
|
99
|
+
};
|
|
82
100
|
files: Array<{
|
|
83
101
|
path: string;
|
|
84
102
|
sizeBytes: number;
|
|
@@ -86,6 +104,10 @@ export type RuntimeBootQuery = {
|
|
|
86
104
|
isExecutable: boolean;
|
|
87
105
|
}>;
|
|
88
106
|
}> | null;
|
|
107
|
+
runtimeRedmine: {
|
|
108
|
+
baseUrl: string | null;
|
|
109
|
+
apiKey: string | null;
|
|
110
|
+
} | null;
|
|
89
111
|
};
|
|
90
112
|
export type RuntimeSkillFilesQueryVariables = Exact<{
|
|
91
113
|
skillId: string | number;
|