@fa-software/semi2-dsh 0.0.13 → 0.0.14-preview.20260922051239

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/index.js CHANGED
@@ -58,14 +58,6 @@ export function apply(ctx, config) {
58
58
  ctx.provide('semi2Health', configuration.health);
59
59
  // 运行面(NPC 面)回报生命周期与交付事实的出口;上报失败只 warn。
60
60
  ctx.provide('semi2Audit', configuration.audit);
61
- // 优雅卸载 = 本面会话结束:进程被强杀(SIGKILL / 容器停止)没有结束行,属已知边界。
62
- ctx.effect(() => () => {
63
- void configuration.audit.report({
64
- action: 'SESSION_END',
65
- outcome: 'SUCCESS',
66
- detail: { platform: configuration.config.platform, reason: 'dispose' },
67
- });
68
- });
69
61
  // 索引由使用方建立、工具由官方 MCP 服务提供:插件侧没有可失败的后台动作,只标注开关状态。
70
62
  configuration.registerHealth({
71
63
  key: 'healthCodegraph',
@@ -1,19 +1,33 @@
1
- /** 生命周期事件:动作名与结果名都用 hub SDL 的枚举名,两侧不各维护一张映射表。 */
2
- export async function reportEvent(engine, event) {
1
+ /**
2
+ * 上报一条运行面事件并回结果:本面标识(`face`)与运行面契约里的任务引用在这里补齐,
3
+ * 调用方(会话订阅 / 适配器)不必各自知道这两个事实从哪来。
4
+ */
5
+ export async function reportEventResult(engine, event) {
6
+ if (engine.connection() === null)
7
+ return { ok: false, code: 'MISSING_ACCESS_TOKEN' };
8
+ // 任务引用来自运行面契约(NPC 适配器解析触发后填),本插件不自己解析触发事实。
9
+ const taskRef = engine.contract()?.taskRef;
3
10
  try {
4
- if (engine.connection() === null)
5
- return;
6
11
  const outcome = await engine.api.reportEvent({
7
12
  ...event,
13
+ ...(taskRef === undefined || taskRef === '' ? {} : { runRef: taskRef }),
8
14
  detail: { face: faceOf(engine), ...(event.detail ?? {}) },
9
15
  });
10
- if (outcome.data === null)
11
- engine.warn(`运行面事件上报失败(${event.action}):${outcome.errorCode ?? '未知错误'}`);
16
+ return { ok: outcome.data !== null, code: outcome.errorCode };
12
17
  }
13
18
  catch (error) {
14
- engine.warn(`运行面事件上报失败(${event.action}):${error instanceof Error ? error.message : String(error)}`);
19
+ return {
20
+ ok: false,
21
+ code: error instanceof Error ? error.message : String(error),
22
+ };
15
23
  }
16
24
  }
25
+ /** 生命周期事件:动作名与结果名都用 hub SDL 的枚举名,两侧不各维护一张映射表。 */
26
+ export async function reportEvent(engine, event) {
27
+ const result = await reportEventResult(engine, event);
28
+ if (!result.ok)
29
+ engine.warn(`运行面事件上报失败(${event.action}):${result.code ?? '未知错误'}`);
30
+ }
17
31
  /** 本面标识:契约里有就用它;宿主直跑没有契约,记 host。 */
18
32
  export function faceOf(engine) {
19
33
  const declared = engine.contract()?.face;
@@ -1,7 +1,8 @@
1
1
  import { createSemi2Api } from '../api/index.js';
2
2
  import { createPorts } from './ports.js';
3
3
  import { Orchestrator } from './orchestrator.js';
4
- import { faceOf } from './audit.js';
4
+ import { reportEventResult } from './audit.js';
5
+ import { installSessionReporting } from './sessions.js';
5
6
  import * as lifecycle from './lifecycle.js';
6
7
  /**
7
8
  * 装配三个能力面并开始后台领取:领域挂载与首次取数都在后台——不阻塞插件树 settle,
@@ -23,9 +24,13 @@ export function installConfiguration(ctx, options = {}) {
23
24
  createSemi2Api({
24
25
  readConnection: () => engineRef.current?.connection() ?? null,
25
26
  environment: { ...process.env },
26
- readFace: () => engineRef.current?.contract()?.face,
27
+ // 契约缺席即宿主直跑:本面标识回落 `host`,别让会话落成「未标注」。
28
+ readFace: () => engineRef.current?.contract()?.face ??
29
+ (engineRef.current?.platform === 'host' ? 'host' : undefined),
27
30
  }), ports, { ...process.env }, options);
28
31
  engineRef.current = orchestrator;
32
+ // 客户端会话日志的事实来源:旁听 dsh 的会话创建 / 销毁(服务缺席时自动跳过)。
33
+ installSessionReporting(orchestrator);
29
34
  void orchestrator
30
35
  .mountDomains()
31
36
  .then(() => lifecycle.start(orchestrator))
@@ -40,26 +45,7 @@ export function installConfiguration(ctx, options = {}) {
40
45
  },
41
46
  health: { snapshot: () => orchestrator.healthSnapshot() },
42
47
  audit: {
43
- report: async (event) => {
44
- if (orchestrator.connection() === null)
45
- return { ok: false, code: 'MISSING_ACCESS_TOKEN' };
46
- try {
47
- const outcome = await orchestrator.api.reportEvent({
48
- ...event,
49
- detail: {
50
- face: faceOf(orchestrator),
51
- ...(event.detail ?? {}),
52
- },
53
- });
54
- return { ok: outcome.data !== null, code: outcome.errorCode };
55
- }
56
- catch (error) {
57
- return {
58
- ok: false,
59
- code: error instanceof Error ? error.message : String(error),
60
- };
61
- }
62
- },
48
+ report: event => reportEventResult(orchestrator, event),
63
49
  },
64
50
  registerHealth: (contributor) => {
65
51
  orchestrator.registerHealthContributor(contributor);
@@ -5,7 +5,6 @@ import { installSections } from './settings-sections.js';
5
5
  import { appliedState, publishFailed, publishStatus } from './publish.js';
6
6
  import { readOwned, readWorkspaces } from './namespaces.js';
7
7
  import { DOMAINS } from './registry.js';
8
- import { reportEvent } from './audit.js';
9
8
  import { connectionSnapshot, exchangeCnbCredential, resolveConnectionWithRetry, resolveContract, } from './connection.js';
10
9
  /** 首次加载;未配凭据或失败只落到 state.error,绝不 throw,也不注入任何配置。 */
11
10
  export async function start(engine) {
@@ -39,20 +38,6 @@ export async function start(engine) {
39
38
  else
40
39
  await load(engine);
41
40
  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
41
  }
57
42
  finally {
58
43
  engine.settle();
@@ -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,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 runtimeSkills {\n id\n projectId\n name\n description\n whenToUse\n content\n version\n files {\n path\n sizeBytes\n mimeType\n isExecutable\n }\n fileCount\n filesBytes\n }\n runtimeSelfPortrait\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 runtimeSkills {\n id\n projectId\n name\n description\n whenToUse\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' | 'SESSION_END' | 'SESSION_FAILED' | 'SESSION_REFUSED' | 'SESSION_START';
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 解析:命中可读项目时 targetId 是项目 id,否则只留 slug 快照。
20
+ * 目标项目按 slug 解析:命中可读项目时挂项目 id,否则只留 slug 快照。
21
+ * 带会话身份(runRef 或 sessionId + clientId)的事件会折叠进同一行客户端会话。
21
22
  */
22
23
  export type RuntimeEventInput = {
23
24
  action: RuntimeEventAction;
24
- /** 原因码 / 交付状态码(例 SETUP_FAILED、delivered)。 */
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';
@@ -86,6 +99,10 @@ export type RuntimeBootQuery = {
86
99
  isExecutable: boolean;
87
100
  }>;
88
101
  }> | null;
102
+ runtimeRedmine: {
103
+ baseUrl: string | null;
104
+ apiKey: string | null;
105
+ } | null;
89
106
  };
90
107
  export type RuntimeSkillFilesQueryVariables = Exact<{
91
108
  skillId: string | number;