@mokuyoaxis/dsh-iris 0.1.3 → 0.2.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/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +77 -2
- package/README.en.md +98 -19
- package/README.md +98 -19
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +82 -52
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
- package/docs/HOST_DOCTOR.md +49 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +42 -76
- package/docs/SECURITY.md +10 -5
- package/docs/TASK_SEMANTICS.md +3 -2
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.1.4.md +59 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +673 -525
- package/lib/adapters.js +58 -21
- package/lib/api.js +182 -16
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +1116 -86
- package/lib/command-service.js +420 -0
- package/lib/config.js +245 -49
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +260 -30
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +240 -0
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +255 -166
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +397 -0
- package/lib/provider-adapters.js +342 -0
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-health.js +59 -0
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +45 -9
- package/lib/vision.js +51 -56
- package/package.json +20 -14
- package/user_guide.md +67 -12
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
- package/docs/releases/0.1.3.md +0 -30
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Core Task 的用户侧只读投影。
|
|
4
|
+
*
|
|
5
|
+
* Core 事实轴(lib/task-semantics.js)保持不变;这里只做一层只读映射,
|
|
6
|
+
* 把任务收敛成五类用户状态:running / observation_paused / attention /
|
|
7
|
+
* delivery_failed / succeeded。投影不读文件、不写事实、不接触 Provider,
|
|
8
|
+
* 也不包含 providerId、providerBinding、错误原文或任何路径。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const CORE_USER_STATES = Object.freeze([
|
|
12
|
+
'running',
|
|
13
|
+
'observation_paused',
|
|
14
|
+
'attention',
|
|
15
|
+
'delivery_failed',
|
|
16
|
+
'succeeded'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 五类状态的判定与文案。终态事实(成功/失败/取消)优先于观察姿态:
|
|
21
|
+
* 「已经明确失败」绝不能继续显示成「观察暂停」;观察暂停必须明确说明
|
|
22
|
+
* 远端可能仍在运行,避免误导用户以为任务已经终止。
|
|
23
|
+
*/
|
|
24
|
+
export function projectCoreTaskUserView(task) {
|
|
25
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)
|
|
26
|
+
|| !['outcome', 'deliveryState', 'watchState', 'acceptance', 'phase'].some((axis) => axis in task)) {
|
|
27
|
+
return Object.freeze({ state: 'attention', label: '记录无法识别,需要检查' });
|
|
28
|
+
}
|
|
29
|
+
const outcome = String(task.outcome || 'none');
|
|
30
|
+
const delivery = String(task.deliveryState || 'none');
|
|
31
|
+
const watch = String(task.watchState || 'idle');
|
|
32
|
+
const acceptance = String(task.acceptance || 'none');
|
|
33
|
+
if (outcome === 'succeeded' && delivery === 'ready') {
|
|
34
|
+
return Object.freeze({ state: 'succeeded', label: '已完成,作品可用' });
|
|
35
|
+
}
|
|
36
|
+
if (outcome === 'succeeded' && delivery === 'failed') {
|
|
37
|
+
return Object.freeze({ state: 'delivery_failed', label: '已生成,作品取回失败' });
|
|
38
|
+
}
|
|
39
|
+
if (outcome === 'succeeded') {
|
|
40
|
+
return Object.freeze({ state: 'running', label: '已生成,正在保存作品' });
|
|
41
|
+
}
|
|
42
|
+
if (outcome === 'failed') {
|
|
43
|
+
return Object.freeze({ state: 'attention', label: '已失败' });
|
|
44
|
+
}
|
|
45
|
+
if (outcome === 'canceled') {
|
|
46
|
+
return Object.freeze({ state: 'attention', label: '已取消' });
|
|
47
|
+
}
|
|
48
|
+
if (outcome === 'unknown') {
|
|
49
|
+
return Object.freeze({ state: 'attention', label: '任务结果未知,需要检查' });
|
|
50
|
+
}
|
|
51
|
+
if (acceptance === 'unknown') {
|
|
52
|
+
return Object.freeze({ state: 'attention', label: '提交结果未知,请检查后处理' });
|
|
53
|
+
}
|
|
54
|
+
if (delivery === 'failed') {
|
|
55
|
+
return Object.freeze({ state: 'attention', label: '结果状态异常,需要检查' });
|
|
56
|
+
}
|
|
57
|
+
if (watch === 'suspended' || watch === 'exhausted') {
|
|
58
|
+
return Object.freeze({ state: 'observation_paused', label: '观察已暂停,远端可能仍在运行' });
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({ state: 'running', label: '运行中' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 只有状态时(如客户端角标归类)使用;完整视图见 projectCoreTaskUserView。 */
|
|
64
|
+
export function projectCoreTaskUserState(task) {
|
|
65
|
+
return projectCoreTaskUserView(task).state;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 与 task.observe / task.reobserve 同一组受理事实门:已受理、有远端 ID、
|
|
70
|
+
* 结果未定论且非终态。只读持久化事实轴,不解析 Provider、不接触网络;
|
|
71
|
+
* 服务端的真正门禁仍在 Command Service(含 binding 与能力校验)。
|
|
72
|
+
*/
|
|
73
|
+
export function coreTaskObservable(task) {
|
|
74
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) return false;
|
|
75
|
+
return String(task.acceptance || '') === 'accepted'
|
|
76
|
+
&& Boolean(String(task.remoteTaskId || '').trim())
|
|
77
|
+
&& ['none', 'unknown'].includes(String(task.outcome || 'none'))
|
|
78
|
+
&& String(task.phase || '') !== 'terminal';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 与 task.redeliver 同一组交付事实门:远端已成功、产物取回失败、远端 ID 仍在。
|
|
83
|
+
* 只读持久化事实轴,不解析 Provider、不接触网络;binding 与能力校验仍在
|
|
84
|
+
* Command Service。五类状态里只有 delivery_failed 会标记 redeliverable。
|
|
85
|
+
*/
|
|
86
|
+
export function coreTaskRedeliverable(task) {
|
|
87
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) return false;
|
|
88
|
+
return String(task.outcome || '') === 'succeeded'
|
|
89
|
+
&& String(task.deliveryState || '') === 'failed'
|
|
90
|
+
&& Boolean(String(task.remoteTaskId || '').trim());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 与 task.cancel 同一组受理事实门:可观察门的超集条件(外加从未请求过取消)。
|
|
95
|
+
* 只读持久化事实轴,不解析 Provider、不接触网络;Provider 是否支持远端取消
|
|
96
|
+
* 只有 Command 层用真实 adapter 才能回答,这里不预演。
|
|
97
|
+
*/
|
|
98
|
+
export function coreTaskCancelable(task) {
|
|
99
|
+
return coreTaskObservable(task) && String(task.cancelState || 'none') === 'none';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 与 task.retry 同一组门:终态且未成功交付(succeeded+ready 的任务不需要重试;
|
|
104
|
+
* delivery_failed 有 redeliver 但重试也合法)。重试产生新的真实计费,
|
|
105
|
+
* 必须显式确认——门只决定入口是否出现,确认在动作侧强制。
|
|
106
|
+
*/
|
|
107
|
+
export function coreTaskRetryable(task) {
|
|
108
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) return false;
|
|
109
|
+
return String(task.phase || '') === 'terminal'
|
|
110
|
+
&& !(String(task.outcome || '') === 'succeeded' && String(task.deliveryState || '') === 'ready');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function modelLabelOf(record) {
|
|
114
|
+
const ref = String(record && record.modelRef || '');
|
|
115
|
+
const separator = ref.indexOf('::');
|
|
116
|
+
if (separator >= 0 && ref.slice(separator + 2)) return ref.slice(separator + 2);
|
|
117
|
+
if (ref) return ref;
|
|
118
|
+
const attempts = Array.isArray(record && record.attempts) ? record.attempts : [];
|
|
119
|
+
const last = attempts[attempts.length - 1];
|
|
120
|
+
const attemptModel = last && typeof last.model === 'string' ? last.model : '';
|
|
121
|
+
const attemptSeparator = attemptModel.indexOf('::');
|
|
122
|
+
if (attemptSeparator >= 0 && attemptModel.slice(attemptSeparator + 2)) {
|
|
123
|
+
return attemptModel.slice(attemptSeparator + 2);
|
|
124
|
+
}
|
|
125
|
+
return attemptModel || '未记录';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 生成用户任务区消费的安全行 DTO。
|
|
130
|
+
* artifacts 是快照里已读出的 Artifact 摘要数组,用于把「作品可用」与实际的
|
|
131
|
+
* Artifact 存在性对齐;DTO 绝不携带 supplier 身份、binding 或错误原文。
|
|
132
|
+
*/
|
|
133
|
+
export function projectCoreTaskUserRow(record, artifacts = []) {
|
|
134
|
+
const view = projectCoreTaskUserView(record);
|
|
135
|
+
const artifactList = Array.isArray(artifacts) ? artifacts : [];
|
|
136
|
+
const knownIds = new Set(artifactList.map((item) => item && item.id));
|
|
137
|
+
const rawIds = Array.isArray(record && record.artifactIds) ? record.artifactIds : [];
|
|
138
|
+
const artifactIds = rawIds.filter((id) => typeof id === 'string');
|
|
139
|
+
const mediaReady = Boolean(record)
|
|
140
|
+
&& record.outcome === 'succeeded'
|
|
141
|
+
&& record.deliveryState === 'ready'
|
|
142
|
+
&& artifactIds.length > 0
|
|
143
|
+
&& artifactIds.every((id) => knownIds.has(id));
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
id: String(record && record.id || ''),
|
|
146
|
+
capability: String(record && record.capability || 'image'),
|
|
147
|
+
userState: view.state,
|
|
148
|
+
label: view.label,
|
|
149
|
+
model: modelLabelOf(record),
|
|
150
|
+
updatedAt: String(record && record.updatedAt || ''),
|
|
151
|
+
revision: Number.isSafeInteger(record && record.revision) ? record.revision : 0,
|
|
152
|
+
artifactIds: Object.freeze(artifactIds),
|
|
153
|
+
mediaReady,
|
|
154
|
+
observable: coreTaskObservable(record),
|
|
155
|
+
redeliverable: coreTaskRedeliverable(record),
|
|
156
|
+
cancelable: coreTaskCancelable(record),
|
|
157
|
+
retryable: coreTaskRetryable(record)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 完成提示防重的稳定键:同一条 Task 同一 revision 只提示一次。 */
|
|
162
|
+
export function coreCompletionKey(row) {
|
|
163
|
+
return 'core:' + String(row && row.id || '') + '@' + String(Number(row && row.revision) || 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 首次加载的基线:把既有完成项记为已见,避免会话开始时就刷一堆提示。 */
|
|
167
|
+
export function seedCoreCompletions(rows) {
|
|
168
|
+
const keys = new Set();
|
|
169
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
170
|
+
if (row && row.userState === 'succeeded') keys.add(coreCompletionKey(row));
|
|
171
|
+
}
|
|
172
|
+
return keys;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 同一份快照重复渲染不应重复提示:返回相对 seen 新出现的完成项,
|
|
177
|
+
* 以及合并后的键集合(调用方保存供下次比较)。提示不持久化,
|
|
178
|
+
* 刷新页面后由事实快照重新计算基线。
|
|
179
|
+
*/
|
|
180
|
+
export function diffCoreCompletions(rows, seen) {
|
|
181
|
+
const known = seen instanceof Set ? new Set(seen) : seedCoreCompletions(rows);
|
|
182
|
+
const added = [];
|
|
183
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
184
|
+
if (!row || row.userState !== 'succeeded') continue;
|
|
185
|
+
const key = coreCompletionKey(row);
|
|
186
|
+
if (!known.has(key)) {
|
|
187
|
+
known.add(key);
|
|
188
|
+
added.push(row);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return Object.freeze({ added: Object.freeze(added), keys: known });
|
|
192
|
+
}
|
package/lib/doctor.js
CHANGED
|
@@ -5,6 +5,13 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { attentionDisposition, deriveUserState, semanticViolations } from './task-semantics.js';
|
|
8
|
+
import { HOST_CONTRACT_VERSION, hostCapabilitySnapshot } from './host-contract.js';
|
|
9
|
+
import { inspectCoreStorage } from './core-doctor.js';
|
|
10
|
+
import { providerMediaBaseUrl, providerApiKey } from './provider-protocol.js';
|
|
11
|
+
import {
|
|
12
|
+
EXPECTED_IRIS_CLIENT_SEATS, EXPECTED_IRIS_ROUTES, EXPECTED_IRIS_SKILLS, EXPECTED_IRIS_TOOLS,
|
|
13
|
+
HOST_CLIENT_PROTOCOL_VERSION, IRIS_PLUGIN_ID
|
|
14
|
+
} from './host-runtime.js';
|
|
8
15
|
|
|
9
16
|
const PACKAGE_FILE = new URL('../package.json', import.meta.url);
|
|
10
17
|
|
|
@@ -45,7 +52,7 @@ function isGroupOrWorldAccessible(target) {
|
|
|
45
52
|
|
|
46
53
|
function privateFileModeProblems(dataDir) {
|
|
47
54
|
if (process.platform === 'win32') return [];
|
|
48
|
-
return ['providers.json', 'tasks.json', 'prompt-optimizer.json']
|
|
55
|
+
return ['providers.json', 'tasks.json', 'artifacts.json', 'prompt-optimizer.json']
|
|
49
56
|
.filter((name) => isGroupOrWorldAccessible(path.join(dataDir, name)));
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -98,10 +105,11 @@ function inspectConfig(dataDir, checks) {
|
|
|
98
105
|
for (const provider of providers) {
|
|
99
106
|
if (!plain(provider)) { incomplete++; continue; }
|
|
100
107
|
if (provider.enabled !== false) enabled++;
|
|
101
|
-
if (provider.enabled !== false && (!
|
|
108
|
+
if (provider.enabled !== false && (!providerMediaBaseUrl(provider)
|
|
109
|
+
|| (provider.auth !== 'none' && !providerApiKey(provider).trim()))) incomplete++;
|
|
102
110
|
models += Array.isArray(provider.models) ? provider.models.length : 0;
|
|
103
111
|
}
|
|
104
|
-
if (incomplete) checks.push(result('providers', 'warn', `${incomplete}
|
|
112
|
+
if (incomplete) checks.push(result('providers', 'warn', `${incomplete} 个启用供应商缺少端点、所需凭据或结构无效(auth:none 不要求密钥)`, '在 Iris 工作台补全或停用对应供应商。'));
|
|
105
113
|
else if (!enabled) checks.push(result('providers', 'warn', '没有启用的供应商', '在 Iris 工作台启用至少一个供应商。'));
|
|
106
114
|
else checks.push(result('providers', 'ok', `${enabled} 个供应商已启用;凭据未输出`));
|
|
107
115
|
const assignments = config.value.assignments || {};
|
|
@@ -151,6 +159,30 @@ function inspectArtifacts(dataDir, tasks, checks) {
|
|
|
151
159
|
for (const item of task.media || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
152
160
|
for (const item of task.attachments || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
153
161
|
}
|
|
162
|
+
|
|
163
|
+
const library = readJson(path.join(dataDir, 'artifacts.json'));
|
|
164
|
+
if (library.exists && (library.error || !plain(library.value) || !Array.isArray(library.value.artifacts))) {
|
|
165
|
+
checks.push(result('artifact-index', 'warn', 'artifacts.json 结构无效或无法解析', '保留损坏索引作为证据;重新启动 Iris 后会从 outputs/ 重建最小作品索引。'));
|
|
166
|
+
} else if (library.exists) {
|
|
167
|
+
let invalid = 0;
|
|
168
|
+
for (const item of library.value.artifacts) {
|
|
169
|
+
if (!plain(item) || !String(item.id || '').startsWith('a_') || !String(item.token || '').match(/^[a-f0-9]{32}$/)
|
|
170
|
+
|| !String(item.file || '') || path.basename(String(item.file)) !== String(item.file)) {
|
|
171
|
+
invalid++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const safe = String(item.file);
|
|
175
|
+
referenced.add(safe);
|
|
176
|
+
try {
|
|
177
|
+
const stat = fs.lstatSync(path.join(outputs, safe));
|
|
178
|
+
if (!stat.isFile() || stat.isSymbolicLink()) missing++;
|
|
179
|
+
} catch (_) { missing++; }
|
|
180
|
+
}
|
|
181
|
+
checks.push(invalid
|
|
182
|
+
? result('artifact-index', 'warn', `${invalid} 个作品索引条目无效`, '在工作台运行“找回本地作品”重建最小索引。')
|
|
183
|
+
: result('artifact-index', 'ok', `${library.value.artifacts.length} 个作品索引条目结构有效`));
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
let orphan = 0;
|
|
155
187
|
if (fs.existsSync(outputs)) {
|
|
156
188
|
for (const name of fs.readdirSync(outputs)) {
|
|
@@ -158,9 +190,9 @@ function inspectArtifacts(dataDir, tasks, checks) {
|
|
|
158
190
|
try { if (fs.lstatSync(target).isFile() && !referenced.has(name)) orphan++; } catch (_) {}
|
|
159
191
|
}
|
|
160
192
|
}
|
|
161
|
-
if (missing) checks.push(result('artifacts', 'warn', `${missing}
|
|
162
|
-
else if (orphan) checks.push(result('artifacts', 'warn', `${orphan}
|
|
163
|
-
else checks.push(result('artifacts', 'ok', `${referenced.size}
|
|
193
|
+
if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务或作品索引引用的文件不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行“找回本地作品”。'));
|
|
194
|
+
else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个输出文件既没有任务引用也未进入作品库`, '先运行“找回本地作品”;仍无法入库的文件再作为孤儿处理。'));
|
|
195
|
+
else checks.push(result('artifacts', 'ok', `${referenced.size} 个任务/作品引用可核对,未发现孤儿文件`));
|
|
164
196
|
|
|
165
197
|
const uploads = path.join(dataDir, 'uploads');
|
|
166
198
|
let temporary = 0;
|
|
@@ -173,9 +205,10 @@ function inspectArtifacts(dataDir, tasks, checks) {
|
|
|
173
205
|
}
|
|
174
206
|
|
|
175
207
|
/**
|
|
176
|
-
*
|
|
208
|
+
* 零网络离线诊断。默认 legacy 检查保留写入探针;显式 Core 模式全程只读。
|
|
177
209
|
*/
|
|
178
210
|
export async function doctor(options = {}) {
|
|
211
|
+
const explicitCore = options.dataRoot !== undefined;
|
|
179
212
|
const dshHome = path.resolve(options.dshHome || process.env.DSH_HOME || path.join(os.homedir(), '.dsh'));
|
|
180
213
|
const dataDir = path.join(dshHome, 'iris', 'v1');
|
|
181
214
|
const runner = options.commandRunner || ((command, args) => spawnSync(command, args, { stdio: 'ignore', shell: false }));
|
|
@@ -183,9 +216,9 @@ export async function doctor(options = {}) {
|
|
|
183
216
|
const nodeVersion = options.nodeVersion || process.versions.node;
|
|
184
217
|
const checks = [];
|
|
185
218
|
|
|
186
|
-
checks.push(atLeast(parseVersion(nodeVersion), [
|
|
219
|
+
checks.push(atLeast(parseVersion(nodeVersion), [22, 0, 0])
|
|
187
220
|
? result('node', 'ok', `Node.js ${nodeVersion}`)
|
|
188
|
-
: result('node', 'error', `Node.js ${nodeVersion} 低于最低要求
|
|
221
|
+
: result('node', 'error', `Node.js ${nodeVersion} 低于最低要求 22.0.0`, '升级 Node.js 后再运行 Iris。'));
|
|
189
222
|
try { await sharpLoader(); checks.push(result('sharp', 'ok', 'sharp 可加载')); }
|
|
190
223
|
catch (_) { checks.push(result('sharp', 'error', 'sharp 无法加载', '在当前平台重新安装 @mokuyoaxis/dsh-iris,确认 sharp 有可用的 ARM64/系统构建。')); }
|
|
191
224
|
for (const command of ['ffmpeg', 'ffprobe']) {
|
|
@@ -194,28 +227,39 @@ export async function doctor(options = {}) {
|
|
|
194
227
|
: result(command, 'warn', `${command} 不可用`, '视频抽帧与摘要需要 ffmpeg/ffprobe;其他 Iris 能力不受影响。'));
|
|
195
228
|
}
|
|
196
229
|
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
const core = inspectCoreStorage(explicitCore ? options.dataRoot : path.join(dataDir, 'core-v0'));
|
|
231
|
+
const storageComparison = {
|
|
232
|
+
legacy: legacyCounts(explicitCore ? options.dataRoot : dataDir),
|
|
233
|
+
core: { status: core.status, tasks: core.tasks?.total ?? null, artifacts: core.artifacts?.total ?? null }
|
|
234
|
+
};
|
|
235
|
+
appendCoreChecks(checks, core);
|
|
236
|
+
checks.push(result('storage-comparison', 'ok',
|
|
237
|
+
`Legacy:${storageComparison.legacy.tasks ?? '?'} 个任务、${storageComparison.legacy.artifacts ?? '?'} 个作品;Core:${storageComparison.core.tasks ?? '?'} 个任务、${storageComparison.core.artifacts ?? '?'} 个作品`));
|
|
238
|
+
|
|
239
|
+
if (!explicitCore) {
|
|
240
|
+
const dataExists = fs.existsSync(dataDir);
|
|
241
|
+
if (dataExists) {
|
|
242
|
+
let symlink = false;
|
|
243
|
+
try { symlink = fs.lstatSync(dataDir).isSymbolicLink(); } catch (_) {}
|
|
244
|
+
if (symlink) checks.push(result('storage', 'error', 'Iris 数据目录是符号链接,私有边界无法保证', '改用真实的私有目录并人工迁移数据。'));
|
|
245
|
+
else if (!writableProbe(dataDir)) checks.push(result('storage', 'error', 'Iris 数据目录不可写', '修复当前用户对 $DSH_HOME/iris/v1 的写权限。'));
|
|
246
|
+
else if (isGroupOrWorldAccessible(dataDir)) checks.push(result('storage', 'warn', 'Iris 数据目录允许组或其他用户访问', '在 POSIX 上将目录权限收紧为 0700、文件收紧为 0600。'));
|
|
247
|
+
else {
|
|
248
|
+
const exposedFiles = privateFileModeProblems(dataDir);
|
|
249
|
+
checks.push(exposedFiles.length
|
|
250
|
+
? result('storage', 'warn', `${exposedFiles.length} 个私有数据文件允许组或其他用户访问`, '在 POSIX 上将 providers.json、tasks.json 与提示词配置权限收紧为 0600。')
|
|
251
|
+
: result('storage', 'ok', 'Iris 数据目录可写、私有文件权限合格,临时探针已清理'));
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
checks.push(writableProbe(dataDir)
|
|
255
|
+
? result('storage', 'ok', 'Iris 数据目录尚未创建;父目录可写,临时探针已清理')
|
|
256
|
+
: result('storage', 'error', 'Iris 数据目录不存在且父目录不可写', '设置一个当前用户可写的 DSH_HOME。'));
|
|
209
257
|
}
|
|
210
|
-
} else {
|
|
211
|
-
checks.push(writableProbe(dataDir)
|
|
212
|
-
? result('storage', 'ok', 'Iris 数据目录尚未创建;父目录可写,临时探针已清理')
|
|
213
|
-
: result('storage', 'error', 'Iris 数据目录不存在且父目录不可写', '设置一个当前用户可写的 DSH_HOME。'));
|
|
214
|
-
}
|
|
215
258
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
259
|
+
inspectConfig(dataDir, checks);
|
|
260
|
+
const taskRecords = inspectTasks(dataDir, checks);
|
|
261
|
+
inspectArtifacts(dataDir, taskRecords, checks);
|
|
262
|
+
}
|
|
219
263
|
checks.push(result('scope', 'ok', '离线模式未启动 DSH、未检查浏览器/工具注册,也未发送供应商请求'));
|
|
220
264
|
|
|
221
265
|
const errors = checks.filter((item) => item.status === 'error').length;
|
|
@@ -223,14 +267,200 @@ export async function doctor(options = {}) {
|
|
|
223
267
|
return {
|
|
224
268
|
schemaVersion: 1,
|
|
225
269
|
package: { name: '@mokuyoaxis/dsh-iris', version: options.packageVersion || readPackageVersion() },
|
|
226
|
-
mode: 'offline',
|
|
270
|
+
mode: explicitCore ? 'core-offline' : 'offline',
|
|
227
271
|
environment: { node: nodeVersion, platform: process.platform, arch: process.arch },
|
|
228
272
|
checks,
|
|
273
|
+
core,
|
|
274
|
+
storageComparison,
|
|
275
|
+
summary: { ok: checks.length - errors - warnings, warnings, errors },
|
|
276
|
+
exitCode: errors ? 2 : (warnings ? 1 : 0)
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function legacyCounts(directory) {
|
|
281
|
+
const count = (name, field) => {
|
|
282
|
+
try {
|
|
283
|
+
const file = path.join(directory, name);
|
|
284
|
+
const stat = fs.lstatSync(file);
|
|
285
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
286
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
287
|
+
return Array.isArray(value?.[field]) ? value[field].length : null;
|
|
288
|
+
} catch (error) { return error?.code === 'ENOENT' ? 0 : null; }
|
|
289
|
+
};
|
|
290
|
+
return { tasks: count('tasks.json', 'tasks'), artifacts: count('artifacts.json', 'artifacts') };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function appendCoreChecks(checks, core) {
|
|
294
|
+
checks.push(result('core-storage', core.status === 'invalid' ? 'error' : 'ok',
|
|
295
|
+
core.status === 'present' ? 'Core 数据根可读取(只读检查,未执行写入探针)'
|
|
296
|
+
: core.status === 'absent' ? 'Core 数据根尚未创建;未创建目录' : 'Core 数据根无法读取或不是目录'));
|
|
297
|
+
if (core.status !== 'present') return;
|
|
298
|
+
const lease = core.lease;
|
|
299
|
+
const leaseRemediation = lease.status === 'present' && lease.ownerStatus === 'missing'
|
|
300
|
+
? `确认没有跨主机写者后,显式运行 dsh-iris runtime recover --data-root <absolute-path> --confirm-stale-pid ${lease.pid}`
|
|
301
|
+
: lease.status === 'present' && lease.ownerStatus === 'alive'
|
|
302
|
+
? `先停止 PID ${lease.pid} 对应的写者,再重新运行 Doctor;活跃 owner 不可恢复。`
|
|
303
|
+
: '租约证据不完整或进程状态无法确认;不要删除或恢复,保留现场排查。';
|
|
304
|
+
checks.push(result('core-writer-lease', lease.status === 'absent' ? 'ok' : 'warn',
|
|
305
|
+
lease.status === 'absent' ? '未发现写者租约'
|
|
306
|
+
: lease.status === 'invalid' ? '写者租约证据不完整或不安全,已保留现场'
|
|
307
|
+
: `写者租约存在:PID ${lease.pid},进程状态 ${lease.ownerStatus},年龄 ${Math.floor(lease.ageMs / 1000)} 秒`,
|
|
308
|
+
lease.status === 'absent' ? undefined : leaseRemediation));
|
|
309
|
+
const tasks = core.tasks;
|
|
310
|
+
checks.push(result('core-tasks', tasks.status === 'invalid' ? 'error' : tasks.invalid || tasks.unresolved ? 'warn' : 'ok',
|
|
311
|
+
tasks.status === 'invalid' ? 'Core Task Store 结构不完整或不安全'
|
|
312
|
+
: `Core Task:${tasks.total} 个有效记录,${tasks.invalid} 个损坏记录,${tasks.unresolved} 个未解析条目`));
|
|
313
|
+
const artifacts = core.artifacts;
|
|
314
|
+
checks.push(result('core-artifacts', artifacts.status === 'invalid' ? 'error'
|
|
315
|
+
: artifacts.invalid || artifacts.orphanObjects || artifacts.orphanManifests || artifacts.unresolved ? 'warn' : 'ok',
|
|
316
|
+
artifacts.status === 'invalid' ? 'Core Artifact Store 结构不完整或不安全'
|
|
317
|
+
: `Core Artifact:${artifacts.total} 个有效记录,${artifacts.invalid} 个无效记录,${artifacts.orphanObjects} 个孤立对象,${artifacts.orphanManifests} 个未提交 Manifest,${artifacts.unresolved} 个未解析条目`));
|
|
318
|
+
if (artifacts.status !== 'invalid') checks.push(result('core-artifact-index', artifacts.index === 'invalid' ? 'warn' : 'ok',
|
|
319
|
+
artifacts.index === 'invalid' ? 'Core Artifact Index 缺失、校验失败或与提交记录不一致'
|
|
320
|
+
: artifacts.index === 'absent' ? 'Core Artifact Store 尚未创建' : 'Core Artifact Index 自校验与提交记录一致',
|
|
321
|
+
artifacts.index === 'invalid' ? '保留现场;确认租约可用后,显式执行 artifact rebuild。Doctor 不会修复索引。' : undefined));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
function summarizeChecks(checks) {
|
|
326
|
+
const errors = checks.filter((item) => item.status === 'error').length;
|
|
327
|
+
const warnings = checks.filter((item) => item.status === 'warn').length;
|
|
328
|
+
return {
|
|
229
329
|
summary: { ok: checks.length - errors - warnings, warnings, errors },
|
|
230
330
|
exitCode: errors ? 2 : (warnings ? 1 : 0)
|
|
231
331
|
};
|
|
232
332
|
}
|
|
233
333
|
|
|
334
|
+
function dshVersionSupported(value) {
|
|
335
|
+
/* 当前 0.2 候选只支持经真实安装服务隔离验收的准确 rc.2。
|
|
336
|
+
Iris 0.1.4 的旧宿主窗口保留在历史发行中,不用于放行当前源码。 */
|
|
337
|
+
const version = String(value || '').trim().replace(/\s+/g, '');
|
|
338
|
+
return /^0\.2\.0-rc\.2(?:\+.*)?$/.test(version);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function names(value) {
|
|
342
|
+
return [...new Set((Array.isArray(value) ? value : []).map((item) => String(item || '').trim()).filter(Boolean))];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function missing(expected, actual) {
|
|
346
|
+
const found = new Set(names(actual));
|
|
347
|
+
return expected.filter((item) => !found.has(item));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function capabilityCheck(checks, snapshot, id, port, label, severity = 'warn') {
|
|
351
|
+
const capability = snapshot.capabilities[port];
|
|
352
|
+
if (capability && capability.status === 'available') {
|
|
353
|
+
checks.push(result(id, 'ok', label + ' Host Port 可用'));
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
const status = severity === 'error' ? 'error' : 'warn';
|
|
357
|
+
const kind = capability?.status === 'incompatible' ? '接口不兼容' : '能力缺失';
|
|
358
|
+
checks.push(result(id, status, label + '不可用(' + kind + ')' + (capability?.reason ? ':' + capability.reason : ''),
|
|
359
|
+
capability?.status === 'incompatible'
|
|
360
|
+
? '核对当前 DSH 版本与 Iris 支持范围,必要时回退到已验证版本。'
|
|
361
|
+
: '启用对应 DSH 服务;不需要该能力时可忽略此降级。'));
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 运行中宿主诊断。只消费安全快照和注册账本,不调用任何端口,因此默认零网络、
|
|
367
|
+
* 零计费,也不会打开 Browser、读取附件/会话或调用模型。
|
|
368
|
+
*/
|
|
369
|
+
export function hostDoctor(host, evidence = {}, options = {}) {
|
|
370
|
+
const snapshot = hostCapabilitySnapshot(host);
|
|
371
|
+
const packageVersion = String(options.packageVersion || evidence?.server?.version || readPackageVersion());
|
|
372
|
+
const checks = [];
|
|
373
|
+
|
|
374
|
+
checks.push(snapshot.contractVersion === HOST_CONTRACT_VERSION
|
|
375
|
+
? result('host-contract', 'ok', 'Host Adapter v' + snapshot.contractVersion + ' · ' + snapshot.host.id)
|
|
376
|
+
: result('host-contract', 'error', 'Host Adapter 契约版本不兼容',
|
|
377
|
+
'使用与当前 Iris 包匹配的 Host Adapter。'));
|
|
378
|
+
|
|
379
|
+
if (snapshot.host.id === 'deepseek-harness') {
|
|
380
|
+
if (snapshot.host.version === 'unknown') {
|
|
381
|
+
checks.push(result('dsh-version', 'warn', '无法从当前进程识别 DSH 版本',
|
|
382
|
+
'使用 dsh --version 核对;当前候选仅支持 DSH 0.2.0-rc.2。'));
|
|
383
|
+
} else if (dshVersionSupported(snapshot.host.version)) {
|
|
384
|
+
checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
|
|
385
|
+
} else {
|
|
386
|
+
checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
|
|
387
|
+
'当前候选需要 DSH 0.2.0-rc.2;旧 DSH 0.1.2 线或 0.1.5-rc.1 使用 Iris @mokuyoaxis/dsh-iris@0.1.4。其他版本须另行验收。'));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const server = evidence && typeof evidence.server === 'object' ? evidence.server : {};
|
|
392
|
+
if (server.loaded === true && server.pluginId === IRIS_PLUGIN_ID) {
|
|
393
|
+
checks.push(result('plugin', 'ok', IRIS_PLUGIN_ID + ' 服务端已装载 · ' + packageVersion));
|
|
394
|
+
} else {
|
|
395
|
+
checks.push(result('plugin', 'error', '无法确认 Iris 服务端插件身份或装载事实',
|
|
396
|
+
'检查 profile 中是否启用完整 scoped 包名 @mokuyoaxis/dsh-iris,并查看 DSH 启动日志。'));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const toolsAvailable = capabilityCheck(checks, snapshot, 'tools-port', 'tools', 'Agent 工具注册', 'error');
|
|
400
|
+
const missingTools = missing(EXPECTED_IRIS_TOOLS, evidence.tools);
|
|
401
|
+
checks.push(toolsAvailable && missingTools.length === 0
|
|
402
|
+
? result('tools', 'ok', EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具均已登记')
|
|
403
|
+
: result('tools', 'error', '缺少 ' + missingTools.length + '/' + EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具'
|
|
404
|
+
+ (missingTools.length ? ':' + missingTools.join(', ') : ''),
|
|
405
|
+
'检查单工具注册隔离日志;不要因一项失败重启或重复提交媒体任务。'));
|
|
406
|
+
|
|
407
|
+
const routesAvailable = capabilityCheck(checks, snapshot, 'routes-port', 'routes', 'Web 路由注册', 'error');
|
|
408
|
+
const missingRoutes = missing(EXPECTED_IRIS_ROUTES, evidence.routes);
|
|
409
|
+
checks.push(routesAvailable && missingRoutes.length === 0
|
|
410
|
+
? result('routes', 'ok', EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由均已登记')
|
|
411
|
+
: result('routes', 'error', '缺少 ' + missingRoutes.length + '/' + EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由'
|
|
412
|
+
+ (missingRoutes.length ? ':' + missingRoutes.join(', ') : ''),
|
|
413
|
+
'检查 webServer/httpServer 与路由挂载日志。'));
|
|
414
|
+
|
|
415
|
+
const skillsAvailable = capabilityCheck(checks, snapshot, 'skills-port', 'skills', 'Skill 注册');
|
|
416
|
+
const missingSkills = missing(EXPECTED_IRIS_SKILLS, evidence.skills);
|
|
417
|
+
checks.push(skillsAvailable && missingSkills.length === 0
|
|
418
|
+
? result('skills', 'ok', EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill 均已登记')
|
|
419
|
+
: result('skills', 'warn', '缺少 ' + missingSkills.length + '/' + EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill'
|
|
420
|
+
+ (missingSkills.length ? ':' + missingSkills.join(', ') : ''),
|
|
421
|
+
'工具仍可直接调用;检查 DSH Skill registry 和插件启动日志。'));
|
|
422
|
+
|
|
423
|
+
capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser');
|
|
424
|
+
capabilityCheck(checks, snapshot, 'attachments', 'attachments', '附件读写');
|
|
425
|
+
capabilityCheck(checks, snapshot, 'sessions', 'sessions', '会话查询');
|
|
426
|
+
capabilityCheck(checks, snapshot, 'text-model', 'textModel', '宿主文本模型');
|
|
427
|
+
capabilityCheck(checks, snapshot, 'vision-model', 'visionModel', '宿主视觉模型');
|
|
428
|
+
|
|
429
|
+
const client = evidence && typeof evidence.client === 'object' ? evidence.client : null;
|
|
430
|
+
if (!client || client.loaded !== true) {
|
|
431
|
+
checks.push(result('client', 'warn', '尚未收到 Iris 浏览器客户端握手',
|
|
432
|
+
'在同一 DSH 实例打开或刷新 Web 页面,再重新访问 /iris/api/doctor。'));
|
|
433
|
+
} else if (client.pluginId !== IRIS_PLUGIN_ID
|
|
434
|
+
|| client.protocolVersion !== HOST_CLIENT_PROTOCOL_VERSION
|
|
435
|
+
|| client.version !== packageVersion) {
|
|
436
|
+
checks.push(result('client', 'error',
|
|
437
|
+
'Iris 服务端/客户端版本不一致:server ' + packageVersion + ' · client ' + String(client.version || 'unknown')
|
|
438
|
+
+ ' · protocol ' + String(client.protocolVersion),
|
|
439
|
+
'重新打开 DSH 输出的认证 URL并强制刷新;若仍不一致,清理该 profile 的旧插件链接后重装。'));
|
|
440
|
+
} else {
|
|
441
|
+
const missingSeats = missing(EXPECTED_IRIS_CLIENT_SEATS, client.seats);
|
|
442
|
+
checks.push(missingSeats.length
|
|
443
|
+
? result('client', 'error', '浏览器客户端缺少 ' + missingSeats.length + '/' + EXPECTED_IRIS_CLIENT_SEATS.length
|
|
444
|
+
+ ' 个 UI Slot:' + missingSeats.join(', '),
|
|
445
|
+
'检查客户端模块表和 DSH Slot API;服务端任务不受影响。')
|
|
446
|
+
: result('client', 'ok', '客户端 ' + client.version + ' 已装载,4 个 UI Slot 均已登记'));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
checks.push(result('scope', 'ok', 'Host Doctor 只读取能力快照和 Iris 注册账本;未调用端口、Browser、模型或供应商'));
|
|
450
|
+
|
|
451
|
+
const totals = summarizeChecks(checks);
|
|
452
|
+
return {
|
|
453
|
+
schemaVersion: 1,
|
|
454
|
+
package: { name: IRIS_PLUGIN_ID, version: packageVersion },
|
|
455
|
+
mode: 'host',
|
|
456
|
+
environment: { node: process.versions.node, platform: process.platform, arch: process.arch },
|
|
457
|
+
host: snapshot.host,
|
|
458
|
+
capabilities: snapshot.capabilities,
|
|
459
|
+
checks,
|
|
460
|
+
...totals
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
234
464
|
export function formatDoctorReport(report) {
|
|
235
465
|
const mark = { ok: '✓', warn: '!', error: '✗' };
|
|
236
466
|
const lines = [`Iris Doctor ${report.package.version} · ${report.mode}`];
|