@mokuyoaxis/dsh-iris 0.1.2 → 0.1.4
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 +57 -2
- package/README.en.md +43 -7
- package/README.md +43 -7
- package/bin/dsh-iris.js +23 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
- package/docs/HOST_DOCTOR.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +21 -11
- package/docs/SECURITY.md +57 -0
- package/docs/TASK_SEMANTICS.md +148 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/releases/0.1.4.md +59 -0
- package/lib/actions.js +860 -290
- package/lib/adapters.js +7 -7
- package/lib/api.js +110 -18
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +478 -68
- package/lib/config.js +192 -9
- package/lib/doctor.js +414 -0
- package/lib/dsh-host-adapter.js +189 -0
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +142 -157
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +371 -0
- package/lib/provider-adapters.js +279 -0
- package/lib/provider-contract.js +234 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +500 -32
- package/lib/vision.js +23 -49
- package/package.json +19 -8
- package/user_guide.md +62 -2
- 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/lib/config.js
CHANGED
|
@@ -11,6 +11,14 @@ import * as cap from './capability.js';
|
|
|
11
11
|
import * as models from './models.js';
|
|
12
12
|
import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
|
|
13
13
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
14
|
+
import { redactProviderMessage } from './provider-contract.js';
|
|
15
|
+
import {
|
|
16
|
+
PROVIDER_HEALTH_FRESH_MS,
|
|
17
|
+
aggregateHealthStates,
|
|
18
|
+
healthObservationState,
|
|
19
|
+
isDefinitiveHealthFailure,
|
|
20
|
+
newestHealthEvidence
|
|
21
|
+
} from './provider-health.js';
|
|
14
22
|
|
|
15
23
|
function isPlainObject(value) {
|
|
16
24
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -293,6 +301,163 @@ export function capabilitiesOf(p) {
|
|
|
293
301
|
return [...caps];
|
|
294
302
|
}
|
|
295
303
|
|
|
304
|
+
const HEALTH_SENSITIVE_FIELDS = new Set([
|
|
305
|
+
'type', 'baseUrl', 'apiKey', 'mediaProtocol',
|
|
306
|
+
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
function healthContainer(provider) {
|
|
310
|
+
const current = provider.health;
|
|
311
|
+
if (current && current.version === 1 && Number.isSafeInteger(current.revision)
|
|
312
|
+
&& Array.isArray(current.observations)) return current;
|
|
313
|
+
const revision = Number.isSafeInteger(current && current.revision) ? current.revision : 0;
|
|
314
|
+
provider.health = { version: 1, revision, observations: [] };
|
|
315
|
+
return provider.health;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function healthObservation(provider, modelId, capability) {
|
|
319
|
+
const health = provider && provider.health;
|
|
320
|
+
if (!health || health.version !== 1 || !Array.isArray(health.observations)) return null;
|
|
321
|
+
return health.observations.find((item) => item && item.modelId === modelId
|
|
322
|
+
&& item.capability === capability) || null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function safeHealthNote(provider, value) {
|
|
326
|
+
const raw = String(value ?? '').trim();
|
|
327
|
+
if (!raw) return '';
|
|
328
|
+
let note = redactProviderMessage(raw, 200);
|
|
329
|
+
const key = String(provider && provider.apiKey || '');
|
|
330
|
+
if (key) note = note.split(key).join('[REDACTED]');
|
|
331
|
+
return note;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function observationEvidence(provider, result, fallbackSource) {
|
|
335
|
+
const atMs = Date.parse(String(result && result.at || ''));
|
|
336
|
+
const status = Number(result && (result.httpStatus ?? result.status));
|
|
337
|
+
const note = safeHealthNote(provider, result && (result.note || result.safeMessage || result.message));
|
|
338
|
+
return {
|
|
339
|
+
at: new Date(Number.isFinite(atMs) ? atMs : Date.now()).toISOString(),
|
|
340
|
+
source: ['probe', 'task'].includes(result && result.source) ? result.source : fallbackSource,
|
|
341
|
+
category: String(result && result.category || (result && result.ok ? 'success' : 'unknown')).slice(0, 40),
|
|
342
|
+
...(Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}),
|
|
343
|
+
...(note ? { note } : {})
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function applyProviderHealth(provider, modelId, capability, result, fallbackSource = 'task') {
|
|
348
|
+
const model = String(modelId || '').trim();
|
|
349
|
+
if (!provider || !model || !cap.isKnownCapability(capability)) return false;
|
|
350
|
+
const health = healthContainer(provider);
|
|
351
|
+
let observation = health.observations.find((item) => item && item.modelId === model
|
|
352
|
+
&& item.capability === capability);
|
|
353
|
+
if (!observation) {
|
|
354
|
+
observation = { modelId: model, capability };
|
|
355
|
+
health.observations.push(observation);
|
|
356
|
+
}
|
|
357
|
+
const evidence = observationEvidence(provider, result || {}, fallbackSource);
|
|
358
|
+
if (result && result.ok === true) {
|
|
359
|
+
observation.lastSuccess = evidence;
|
|
360
|
+
} else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
|
|
361
|
+
observation.lastFailure = evidence;
|
|
362
|
+
} else {
|
|
363
|
+
observation.lastTransient = evidence;
|
|
364
|
+
}
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pruneHealthToEffectiveModels(provider) {
|
|
369
|
+
if (!provider) return;
|
|
370
|
+
const allowedByModel = new Map();
|
|
371
|
+
for (const entry of models.providerModels(provider)) {
|
|
372
|
+
allowedByModel.set(entry.id, new Set(entry.capabilities));
|
|
373
|
+
}
|
|
374
|
+
if (provider.health && Array.isArray(provider.health.observations)) {
|
|
375
|
+
provider.health.observations = provider.health.observations.filter((item) =>
|
|
376
|
+
item && allowedByModel.get(item.modelId)?.has(item.capability));
|
|
377
|
+
}
|
|
378
|
+
for (const entry of provider.models || []) {
|
|
379
|
+
if (!entry || typeof entry !== 'object' || !entry.verified || typeof entry.verified !== 'object') continue;
|
|
380
|
+
const allowed = allowedByModel.get(entry.id) || new Set();
|
|
381
|
+
entry.verified = Object.fromEntries(Object.entries(entry.verified).filter(([capability]) => allowed.has(capability)));
|
|
382
|
+
if (!Object.keys(entry.verified).length) delete entry.verified;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function pruneModelHealth(provider, modelId) {
|
|
387
|
+
if (!provider || !provider.health || !Array.isArray(provider.health.observations)) return;
|
|
388
|
+
provider.health.observations = provider.health.observations.filter((item) => item.modelId !== modelId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resetHealthOnCriticalChange(previous, next, patch) {
|
|
392
|
+
const changed = [...HEALTH_SENSITIVE_FIELDS].some((key) =>
|
|
393
|
+
Object.prototype.hasOwnProperty.call(patch, key) && previous[key] !== next[key]);
|
|
394
|
+
if (!changed) return;
|
|
395
|
+
const current = previous.health;
|
|
396
|
+
const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
|
|
397
|
+
next.health = { version: 1, revision, observations: [] };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 记录一次显式实测或真实任务观察;临时错误不会覆盖近期成功。 */
|
|
401
|
+
export function recordProviderHealth(id, modelId, capability, result) {
|
|
402
|
+
const c = load();
|
|
403
|
+
const provider = c.providers.find((item) => item.id === id);
|
|
404
|
+
if (!provider || !applyProviderHealth(provider, modelId, capability, result, result && result.source || 'task')) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
persist();
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 返回单个 Provider × Model × Capability 的安全状态。 */
|
|
412
|
+
export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
|
|
413
|
+
const provider = load().providers.find((item) => item.id === id);
|
|
414
|
+
if (!provider || provider.enabled === false || !provider.apiKey) return { status: 'unconfigured' };
|
|
415
|
+
const observation = healthObservation(provider, modelId, capability);
|
|
416
|
+
const status = healthObservationState(observation, { now });
|
|
417
|
+
const evidence = status === 'verified'
|
|
418
|
+
? observation && observation.lastSuccess
|
|
419
|
+
: (status === 'failed' ? observation && observation.lastFailure : newestHealthEvidence(observation));
|
|
420
|
+
return {
|
|
421
|
+
status,
|
|
422
|
+
...(evidence ? {
|
|
423
|
+
observedAt: evidence.at,
|
|
424
|
+
source: evidence.source,
|
|
425
|
+
category: evidence.category,
|
|
426
|
+
...(evidence.httpStatus ? { httpStatus: evidence.httpStatus } : {}),
|
|
427
|
+
...(evidence.note ? { note: evidence.note } : {})
|
|
428
|
+
} : {})
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 当前有序 failover 路径的健康快照;只含脱敏标量,不触发网络。 */
|
|
433
|
+
export function providerHealthSnapshot({ now = Date.now() } = {}) {
|
|
434
|
+
const capabilities = {};
|
|
435
|
+
for (const capability of Object.values(cap.CAPABILITIES)) {
|
|
436
|
+
const candidates = pickAllFor(capability).map((provider) => {
|
|
437
|
+
const modelId = String(provider[FIELD_OF[capability]] || '');
|
|
438
|
+
const health = modelHealth(provider.id, modelId, capability, { now });
|
|
439
|
+
return { providerId: provider.id, modelId, ...health };
|
|
440
|
+
});
|
|
441
|
+
const status = aggregateHealthStates(candidates.map((item) => item.status));
|
|
442
|
+
const newest = [...candidates]
|
|
443
|
+
.filter((item) => item.status === status && item.observedAt)
|
|
444
|
+
.sort((a, b) => String(b.observedAt).localeCompare(String(a.observedAt)))[0];
|
|
445
|
+
capabilities[capability] = {
|
|
446
|
+
status,
|
|
447
|
+
candidateCount: candidates.length,
|
|
448
|
+
...(newest ? { observedAt: newest.observedAt } : {}),
|
|
449
|
+
candidates
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
schemaVersion: 1,
|
|
454
|
+
freshForMs: PROVIDER_HEALTH_FRESH_MS,
|
|
455
|
+
generatedAt: new Date(Number(now)).toISOString(),
|
|
456
|
+
overall: aggregateHealthStates(Object.values(capabilities).map((item) => item.status)),
|
|
457
|
+
capabilities
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
296
461
|
export function upsert(provider) {
|
|
297
462
|
const c = load();
|
|
298
463
|
const { id, ...input } = provider;
|
|
@@ -301,11 +466,15 @@ export function upsert(provider) {
|
|
|
301
466
|
if (id) {
|
|
302
467
|
const i = c.providers.findIndex((p) => p.id === id);
|
|
303
468
|
if (i >= 0) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
469
|
+
const previous = c.providers[i];
|
|
470
|
+
if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? previous.baseUrl);
|
|
471
|
+
const next = { ...previous, ...rest };
|
|
472
|
+
normalizeStoredProvider(next);
|
|
473
|
+
resetHealthOnCriticalChange(previous, next, rest);
|
|
474
|
+
if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
|
|
475
|
+
c.providers[i] = next;
|
|
307
476
|
persist();
|
|
308
|
-
return
|
|
477
|
+
return next;
|
|
309
478
|
}
|
|
310
479
|
}
|
|
311
480
|
const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
|
|
@@ -352,6 +521,7 @@ export function setProviderModels(id, modelEntries) {
|
|
|
352
521
|
return entry;
|
|
353
522
|
});
|
|
354
523
|
}
|
|
524
|
+
pruneHealthToEffectiveModels(p);
|
|
355
525
|
persist();
|
|
356
526
|
return p;
|
|
357
527
|
}
|
|
@@ -376,6 +546,7 @@ export function addProviderModel(id, modelId, capabilities) {
|
|
|
376
546
|
} else if (Array.isArray(capabilities)) {
|
|
377
547
|
entry.capabilities = capabilities;
|
|
378
548
|
}
|
|
549
|
+
pruneHealthToEffectiveModels(p);
|
|
379
550
|
persist();
|
|
380
551
|
return entry;
|
|
381
552
|
}
|
|
@@ -388,6 +559,7 @@ export function removeProviderModel(id, modelId) {
|
|
|
388
559
|
const i = p.models.findIndex((m) => m && m.id === modelId);
|
|
389
560
|
if (i < 0) return false;
|
|
390
561
|
p.models.splice(i, 1);
|
|
562
|
+
pruneModelHealth(p, modelId);
|
|
391
563
|
persist();
|
|
392
564
|
return true;
|
|
393
565
|
}
|
|
@@ -399,20 +571,31 @@ export function setModelCapabilities(id, modelId, capabilities) {
|
|
|
399
571
|
if (!p) return false;
|
|
400
572
|
const entry = findModelEntry(p, modelId);
|
|
401
573
|
if (!entry) return false;
|
|
402
|
-
|
|
574
|
+
const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
|
|
575
|
+
entry.capabilities = nextCapabilities;
|
|
576
|
+
pruneHealthToEffectiveModels(p);
|
|
403
577
|
persist();
|
|
404
578
|
return true;
|
|
405
579
|
}
|
|
406
580
|
|
|
407
|
-
/**
|
|
581
|
+
/** 记录某模型的显式实测结果,并同步到新健康事实。 */
|
|
408
582
|
export function setModelVerified(id, modelId, capability, result) {
|
|
409
583
|
const c = load();
|
|
410
584
|
const p = c.providers.find((x) => x.id === id);
|
|
411
585
|
if (!p) return false;
|
|
412
586
|
const entry = findModelEntry(p, modelId);
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
587
|
+
const at = new Date().toISOString();
|
|
588
|
+
if (entry) {
|
|
589
|
+
if (!entry.verified) entry.verified = {};
|
|
590
|
+
entry.verified[capability] = {
|
|
591
|
+
ok: !!result.ok,
|
|
592
|
+
at,
|
|
593
|
+
note: safeHealthNote(p, result.note || ''),
|
|
594
|
+
source: 'probe',
|
|
595
|
+
category: String(result.category || (result.ok ? 'success' : 'unknown')).slice(0, 40)
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
applyProviderHealth(p, modelId, capability, { ...result, at, source: 'probe' }, 'probe');
|
|
416
599
|
persist();
|
|
417
600
|
return true;
|
|
418
601
|
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { attentionDisposition, deriveUserState, semanticViolations } from './task-semantics.js';
|
|
8
|
+
import { HOST_CONTRACT_VERSION, hostCapabilitySnapshot } from './host-contract.js';
|
|
9
|
+
import {
|
|
10
|
+
EXPECTED_IRIS_CLIENT_SEATS, EXPECTED_IRIS_ROUTES, EXPECTED_IRIS_SKILLS, EXPECTED_IRIS_TOOLS,
|
|
11
|
+
HOST_CLIENT_PROTOCOL_VERSION, IRIS_PLUGIN_ID
|
|
12
|
+
} from './host-runtime.js';
|
|
13
|
+
|
|
14
|
+
const PACKAGE_FILE = new URL('../package.json', import.meta.url);
|
|
15
|
+
|
|
16
|
+
function readPackageVersion() {
|
|
17
|
+
try { return JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf8')).version || 'unknown'; } catch (_) { return 'unknown'; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function result(id, status, summary, suggestion) {
|
|
21
|
+
return { id, status, summary, ...(suggestion ? { suggestion } : {}) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseVersion(value) {
|
|
25
|
+
const match = String(value || '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
26
|
+
return match ? match.slice(1).map(Number) : [0, 0, 0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function atLeast(actual, required) {
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
if (actual[i] !== required[i]) return actual[i] > required[i];
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readJson(file) {
|
|
37
|
+
if (!fs.existsSync(file)) return { exists: false, value: null };
|
|
38
|
+
try { return { exists: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) }; }
|
|
39
|
+
catch (_) { return { exists: true, value: null, error: 'JSON 无法解析' }; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function plain(value) {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isGroupOrWorldAccessible(target) {
|
|
47
|
+
if (process.platform === 'win32' || !fs.existsSync(target)) return false;
|
|
48
|
+
try { return (fs.lstatSync(target).mode & 0o077) !== 0; } catch (_) { return false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function privateFileModeProblems(dataDir) {
|
|
52
|
+
if (process.platform === 'win32') return [];
|
|
53
|
+
return ['providers.json', 'tasks.json', 'artifacts.json', 'prompt-optimizer.json']
|
|
54
|
+
.filter((name) => isGroupOrWorldAccessible(path.join(dataDir, name)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function commandAvailable(command, runner) {
|
|
58
|
+
try {
|
|
59
|
+
const run = runner(command, ['-version']);
|
|
60
|
+
return Boolean(run && run.status === 0 && !run.error);
|
|
61
|
+
} catch (_) { return false; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nearestExisting(target) {
|
|
65
|
+
let current = target;
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (fs.existsSync(current)) return current;
|
|
68
|
+
const parent = path.dirname(current);
|
|
69
|
+
if (parent === current) return null;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writableProbe(dataDir) {
|
|
75
|
+
const base = fs.existsSync(dataDir) ? dataDir : nearestExisting(path.dirname(dataDir));
|
|
76
|
+
if (!base) return false;
|
|
77
|
+
const probe = path.join(base, `.iris-doctor-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
78
|
+
try {
|
|
79
|
+
fs.writeFileSync(probe, 'probe', { flag: 'wx', mode: 0o600 });
|
|
80
|
+
fs.rmSync(probe);
|
|
81
|
+
return true;
|
|
82
|
+
} catch (_) {
|
|
83
|
+
try { fs.rmSync(probe, { force: true }); } catch (_) {}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function inspectConfig(dataDir, checks) {
|
|
89
|
+
const config = readJson(path.join(dataDir, 'providers.json'));
|
|
90
|
+
if (!config.exists) {
|
|
91
|
+
checks.push(result('config', 'warn', '尚未创建供应商配置', '启动 Iris 后在工作台添加供应商,或准备好 DSH_HOME 后再运行 doctor。'));
|
|
92
|
+
return { providers: [], assignments: {} };
|
|
93
|
+
}
|
|
94
|
+
if (config.error || !plain(config.value) || !Array.isArray(config.value.providers)
|
|
95
|
+
|| !plain(config.value.assignments || {})) {
|
|
96
|
+
checks.push(result('config', 'error', 'providers.json 结构无效或无法解析', '先备份该文件,再从 Iris 工作台重新配置;不要把密钥粘贴到诊断输出。'));
|
|
97
|
+
return { providers: [], assignments: {} };
|
|
98
|
+
}
|
|
99
|
+
const providers = config.value.providers;
|
|
100
|
+
let incomplete = 0;
|
|
101
|
+
let enabled = 0;
|
|
102
|
+
let models = 0;
|
|
103
|
+
for (const provider of providers) {
|
|
104
|
+
if (!plain(provider)) { incomplete++; continue; }
|
|
105
|
+
if (provider.enabled !== false) enabled++;
|
|
106
|
+
if (provider.enabled !== false && (!String(provider.baseUrl || '').trim() || !String(provider.apiKey || '').trim())) incomplete++;
|
|
107
|
+
models += Array.isArray(provider.models) ? provider.models.length : 0;
|
|
108
|
+
}
|
|
109
|
+
if (incomplete) checks.push(result('providers', 'warn', `${incomplete} 个启用供应商缺少 Base URL、API Key 或结构无效`, '在 Iris 工作台补全或停用对应供应商。'));
|
|
110
|
+
else if (!enabled) checks.push(result('providers', 'warn', '没有启用的供应商', '在 Iris 工作台启用至少一个供应商。'));
|
|
111
|
+
else checks.push(result('providers', 'ok', `${enabled} 个供应商已启用;凭据未输出`));
|
|
112
|
+
const assignments = config.value.assignments || {};
|
|
113
|
+
const assignmentCount = Object.keys(assignments).length;
|
|
114
|
+
if (!models) checks.push(result('models', 'warn', '模型池为空或尚未发现模型', '在工作台对供应商执行“发现模型”,再核对能力标签。'));
|
|
115
|
+
else checks.push(result('models', 'ok', `模型池记录 ${models} 个模型,能力分配 ${assignmentCount} 项`));
|
|
116
|
+
return { providers, assignments };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function inspectTasks(dataDir, checks) {
|
|
120
|
+
const stored = readJson(path.join(dataDir, 'tasks.json'));
|
|
121
|
+
if (!stored.exists) {
|
|
122
|
+
checks.push(result('tasks', 'ok', '尚无任务记录'));
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
if (stored.error || !plain(stored.value) || !Array.isArray(stored.value.tasks)) {
|
|
126
|
+
checks.push(result('tasks', 'error', 'tasks.json 结构无效或无法解析', '保留损坏文件作为证据;重新启动 Iris 会尝试隔离并建立新任务表。'));
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
let invalid = 0;
|
|
130
|
+
let attention = 0;
|
|
131
|
+
for (const task of stored.value.tasks) {
|
|
132
|
+
if (!plain(task) || !String(task.id || '').trim()) { invalid++; continue; }
|
|
133
|
+
if (task.schemaVersion === 2) {
|
|
134
|
+
if (semanticViolations(task).length) invalid++;
|
|
135
|
+
const userState = deriveUserState(task);
|
|
136
|
+
if (['watching_paused', 'needs_attention', 'artifact_unavailable'].includes(userState)
|
|
137
|
+
&& attentionDisposition(task).status !== 'acknowledged') attention++;
|
|
138
|
+
} else if (task.status === 'running' && !task.remoteTaskId) attention++;
|
|
139
|
+
}
|
|
140
|
+
if (invalid) checks.push(result('tasks', 'error', `${invalid} 条任务记录语义或结构无效`, '不要手工改写任务事实;先备份 tasks.json 并保留损坏证据。'));
|
|
141
|
+
else if (attention) checks.push(result('tasks', 'warn', `${stored.value.tasks.length} 条任务中有 ${attention} 条需要人工确认`, '在 Iris 工作台的“需要处理”分区查看,并选择重新观察、重新交付、标为已读或知情重试。'));
|
|
142
|
+
else checks.push(result('tasks', 'ok', `${stored.value.tasks.length} 条任务记录结构有效`));
|
|
143
|
+
return stored.value.tasks.filter(plain);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function inspectArtifacts(dataDir, tasks, checks) {
|
|
147
|
+
const outputs = path.join(dataDir, 'outputs');
|
|
148
|
+
const referenced = new Set();
|
|
149
|
+
let missing = 0;
|
|
150
|
+
for (const task of tasks) {
|
|
151
|
+
for (const name of task.files || []) {
|
|
152
|
+
const safe = path.basename(String(name));
|
|
153
|
+
referenced.add(safe);
|
|
154
|
+
try { if (!fs.statSync(path.join(outputs, safe)).isFile()) missing++; } catch (_) { missing++; }
|
|
155
|
+
}
|
|
156
|
+
for (const item of task.media || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
157
|
+
for (const item of task.attachments || []) if (item && item.file) referenced.add(path.basename(String(item.file)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const library = readJson(path.join(dataDir, 'artifacts.json'));
|
|
161
|
+
if (library.exists && (library.error || !plain(library.value) || !Array.isArray(library.value.artifacts))) {
|
|
162
|
+
checks.push(result('artifact-index', 'warn', 'artifacts.json 结构无效或无法解析', '保留损坏索引作为证据;重新启动 Iris 后会从 outputs/ 重建最小作品索引。'));
|
|
163
|
+
} else if (library.exists) {
|
|
164
|
+
let invalid = 0;
|
|
165
|
+
for (const item of library.value.artifacts) {
|
|
166
|
+
if (!plain(item) || !String(item.id || '').startsWith('a_') || !String(item.token || '').match(/^[a-f0-9]{32}$/)
|
|
167
|
+
|| !String(item.file || '') || path.basename(String(item.file)) !== String(item.file)) {
|
|
168
|
+
invalid++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const safe = String(item.file);
|
|
172
|
+
referenced.add(safe);
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.lstatSync(path.join(outputs, safe));
|
|
175
|
+
if (!stat.isFile() || stat.isSymbolicLink()) missing++;
|
|
176
|
+
} catch (_) { missing++; }
|
|
177
|
+
}
|
|
178
|
+
checks.push(invalid
|
|
179
|
+
? result('artifact-index', 'warn', `${invalid} 个作品索引条目无效`, '在工作台运行“找回本地作品”重建最小索引。')
|
|
180
|
+
: result('artifact-index', 'ok', `${library.value.artifacts.length} 个作品索引条目结构有效`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let orphan = 0;
|
|
184
|
+
if (fs.existsSync(outputs)) {
|
|
185
|
+
for (const name of fs.readdirSync(outputs)) {
|
|
186
|
+
const target = path.join(outputs, name);
|
|
187
|
+
try { if (fs.lstatSync(target).isFile() && !referenced.has(name)) orphan++; } catch (_) {}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (missing) checks.push(result('artifacts', 'warn', `${missing} 个任务或作品索引引用的文件不可访问;另有 ${orphan} 个孤儿文件`, '在工作台检查任务详情并运行“找回本地作品”。'));
|
|
191
|
+
else if (orphan) checks.push(result('artifacts', 'warn', `${orphan} 个输出文件既没有任务引用也未进入作品库`, '先运行“找回本地作品”;仍无法入库的文件再作为孤儿处理。'));
|
|
192
|
+
else checks.push(result('artifacts', 'ok', `${referenced.size} 个任务/作品引用可核对,未发现孤儿文件`));
|
|
193
|
+
|
|
194
|
+
const uploads = path.join(dataDir, 'uploads');
|
|
195
|
+
let temporary = 0;
|
|
196
|
+
if (fs.existsSync(uploads)) {
|
|
197
|
+
for (const name of fs.readdirSync(uploads)) if (name.endsWith('.part')) temporary++;
|
|
198
|
+
}
|
|
199
|
+
checks.push(temporary
|
|
200
|
+
? result('temporary-files', 'warn', `${temporary} 个上传临时 .part 文件残留`, '确认没有上传进行中后重启 Iris,由启动清理逻辑处理。')
|
|
201
|
+
: result('temporary-files', 'ok', '未发现上传临时文件残留'));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 零网络离线诊断。仅创建并立即删除一个写入探针;不启动 DSH、不读取或输出 API Key。
|
|
206
|
+
*/
|
|
207
|
+
export async function doctor(options = {}) {
|
|
208
|
+
const dshHome = path.resolve(options.dshHome || process.env.DSH_HOME || path.join(os.homedir(), '.dsh'));
|
|
209
|
+
const dataDir = path.join(dshHome, 'iris', 'v1');
|
|
210
|
+
const runner = options.commandRunner || ((command, args) => spawnSync(command, args, { stdio: 'ignore', shell: false }));
|
|
211
|
+
const sharpLoader = options.sharpLoader || (() => import('sharp'));
|
|
212
|
+
const nodeVersion = options.nodeVersion || process.versions.node;
|
|
213
|
+
const checks = [];
|
|
214
|
+
|
|
215
|
+
checks.push(atLeast(parseVersion(nodeVersion), [20, 10, 0])
|
|
216
|
+
? result('node', 'ok', `Node.js ${nodeVersion}`)
|
|
217
|
+
: result('node', 'error', `Node.js ${nodeVersion} 低于最低要求 20.10.0`, '升级 Node.js 后再运行 Iris。'));
|
|
218
|
+
try { await sharpLoader(); checks.push(result('sharp', 'ok', 'sharp 可加载')); }
|
|
219
|
+
catch (_) { checks.push(result('sharp', 'error', 'sharp 无法加载', '在当前平台重新安装 @mokuyoaxis/dsh-iris,确认 sharp 有可用的 ARM64/系统构建。')); }
|
|
220
|
+
for (const command of ['ffmpeg', 'ffprobe']) {
|
|
221
|
+
checks.push(commandAvailable(command, runner)
|
|
222
|
+
? result(command, 'ok', `${command} 可用`)
|
|
223
|
+
: result(command, 'warn', `${command} 不可用`, '视频抽帧与摘要需要 ffmpeg/ffprobe;其他 Iris 能力不受影响。'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const dataExists = fs.existsSync(dataDir);
|
|
227
|
+
if (dataExists) {
|
|
228
|
+
let symlink = false;
|
|
229
|
+
try { symlink = fs.lstatSync(dataDir).isSymbolicLink(); } catch (_) {}
|
|
230
|
+
if (symlink) checks.push(result('storage', 'error', 'Iris 数据目录是符号链接,私有边界无法保证', '改用真实的私有目录并人工迁移数据。'));
|
|
231
|
+
else if (!writableProbe(dataDir)) checks.push(result('storage', 'error', 'Iris 数据目录不可写', '修复当前用户对 $DSH_HOME/iris/v1 的写权限。'));
|
|
232
|
+
else if (isGroupOrWorldAccessible(dataDir)) checks.push(result('storage', 'warn', 'Iris 数据目录允许组或其他用户访问', '在 POSIX 上将目录权限收紧为 0700、文件收紧为 0600。'));
|
|
233
|
+
else {
|
|
234
|
+
const exposedFiles = privateFileModeProblems(dataDir);
|
|
235
|
+
checks.push(exposedFiles.length
|
|
236
|
+
? result('storage', 'warn', `${exposedFiles.length} 个私有数据文件允许组或其他用户访问`, '在 POSIX 上将 providers.json、tasks.json 与提示词配置权限收紧为 0600。')
|
|
237
|
+
: result('storage', 'ok', 'Iris 数据目录可写、私有文件权限合格,临时探针已清理'));
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
checks.push(writableProbe(dataDir)
|
|
241
|
+
? result('storage', 'ok', 'Iris 数据目录尚未创建;父目录可写,临时探针已清理')
|
|
242
|
+
: result('storage', 'error', 'Iris 数据目录不存在且父目录不可写', '设置一个当前用户可写的 DSH_HOME。'));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
inspectConfig(dataDir, checks);
|
|
246
|
+
const taskRecords = inspectTasks(dataDir, checks);
|
|
247
|
+
inspectArtifacts(dataDir, taskRecords, checks);
|
|
248
|
+
checks.push(result('scope', 'ok', '离线模式未启动 DSH、未检查浏览器/工具注册,也未发送供应商请求'));
|
|
249
|
+
|
|
250
|
+
const errors = checks.filter((item) => item.status === 'error').length;
|
|
251
|
+
const warnings = checks.filter((item) => item.status === 'warn').length;
|
|
252
|
+
return {
|
|
253
|
+
schemaVersion: 1,
|
|
254
|
+
package: { name: '@mokuyoaxis/dsh-iris', version: options.packageVersion || readPackageVersion() },
|
|
255
|
+
mode: 'offline',
|
|
256
|
+
environment: { node: nodeVersion, platform: process.platform, arch: process.arch },
|
|
257
|
+
checks,
|
|
258
|
+
summary: { ok: checks.length - errors - warnings, warnings, errors },
|
|
259
|
+
exitCode: errors ? 2 : (warnings ? 1 : 0)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
function summarizeChecks(checks) {
|
|
265
|
+
const errors = checks.filter((item) => item.status === 'error').length;
|
|
266
|
+
const warnings = checks.filter((item) => item.status === 'warn').length;
|
|
267
|
+
return {
|
|
268
|
+
summary: { ok: checks.length - errors - warnings, warnings, errors },
|
|
269
|
+
exitCode: errors ? 2 : (warnings ? 1 : 0)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function dshVersionSupported(value) {
|
|
274
|
+
/* 已实测的宿主窗口:>=0.1.2-rc.1 <0.1.3-0,以及 0.1.5-rc.1。
|
|
275
|
+
其他预览版没有宿主 canary 证据,不放行。 */
|
|
276
|
+
const version = String(value || '').trim().replace(/\s+/g, '');
|
|
277
|
+
if (/^0\.1\.5-rc\.1(?:\+.*)?$/.test(version)) return true;
|
|
278
|
+
const match = version.match(/^0\.1\.2(?:-rc\.(\d+))?(?:\+.*)?$/);
|
|
279
|
+
return Boolean(match && (match[1] === undefined || Number(match[1]) >= 1));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function names(value) {
|
|
283
|
+
return [...new Set((Array.isArray(value) ? value : []).map((item) => String(item || '').trim()).filter(Boolean))];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function missing(expected, actual) {
|
|
287
|
+
const found = new Set(names(actual));
|
|
288
|
+
return expected.filter((item) => !found.has(item));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function capabilityCheck(checks, snapshot, id, port, label, severity = 'warn') {
|
|
292
|
+
const capability = snapshot.capabilities[port];
|
|
293
|
+
if (capability && capability.status === 'available') {
|
|
294
|
+
checks.push(result(id, 'ok', label + ' Host Port 可用'));
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
const status = severity === 'error' ? 'error' : 'warn';
|
|
298
|
+
const kind = capability?.status === 'incompatible' ? '接口不兼容' : '能力缺失';
|
|
299
|
+
checks.push(result(id, status, label + '不可用(' + kind + ')' + (capability?.reason ? ':' + capability.reason : ''),
|
|
300
|
+
capability?.status === 'incompatible'
|
|
301
|
+
? '核对当前 DSH 版本与 Iris 支持范围,必要时回退到已验证版本。'
|
|
302
|
+
: '启用对应 DSH 服务;不需要该能力时可忽略此降级。'));
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 运行中宿主诊断。只消费安全快照和注册账本,不调用任何端口,因此默认零网络、
|
|
308
|
+
* 零计费,也不会打开 Browser、读取附件/会话或调用模型。
|
|
309
|
+
*/
|
|
310
|
+
export function hostDoctor(host, evidence = {}, options = {}) {
|
|
311
|
+
const snapshot = hostCapabilitySnapshot(host);
|
|
312
|
+
const packageVersion = String(options.packageVersion || evidence?.server?.version || readPackageVersion());
|
|
313
|
+
const checks = [];
|
|
314
|
+
|
|
315
|
+
checks.push(snapshot.contractVersion === HOST_CONTRACT_VERSION
|
|
316
|
+
? result('host-contract', 'ok', 'Host Adapter v' + snapshot.contractVersion + ' · ' + snapshot.host.id)
|
|
317
|
+
: result('host-contract', 'error', 'Host Adapter 契约版本不兼容',
|
|
318
|
+
'使用与当前 Iris 包匹配的 Host Adapter。'));
|
|
319
|
+
|
|
320
|
+
if (snapshot.host.id === 'deepseek-harness') {
|
|
321
|
+
if (snapshot.host.version === 'unknown') {
|
|
322
|
+
checks.push(result('dsh-version', 'warn', '无法从当前进程识别 DSH 版本',
|
|
323
|
+
'使用 dsh --version 核对;0.1.4 已验证 >=0.1.2-rc.1 <0.1.3-0 与 0.1.5-rc.1。'));
|
|
324
|
+
} else if (dshVersionSupported(snapshot.host.version)) {
|
|
325
|
+
checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
|
|
326
|
+
} else {
|
|
327
|
+
checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
|
|
328
|
+
'使用 >=0.1.2-rc.1 <0.1.3-0 或 0.1.5-rc.1,或先运行新的隔离 canary 再扩大支持范围。'));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const server = evidence && typeof evidence.server === 'object' ? evidence.server : {};
|
|
333
|
+
if (server.loaded === true && server.pluginId === IRIS_PLUGIN_ID) {
|
|
334
|
+
checks.push(result('plugin', 'ok', IRIS_PLUGIN_ID + ' 服务端已装载 · ' + packageVersion));
|
|
335
|
+
} else {
|
|
336
|
+
checks.push(result('plugin', 'error', '无法确认 Iris 服务端插件身份或装载事实',
|
|
337
|
+
'检查 profile 中是否启用完整 scoped 包名 @mokuyoaxis/dsh-iris,并查看 DSH 启动日志。'));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const toolsAvailable = capabilityCheck(checks, snapshot, 'tools-port', 'tools', 'Agent 工具注册', 'error');
|
|
341
|
+
const missingTools = missing(EXPECTED_IRIS_TOOLS, evidence.tools);
|
|
342
|
+
checks.push(toolsAvailable && missingTools.length === 0
|
|
343
|
+
? result('tools', 'ok', EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具均已登记')
|
|
344
|
+
: result('tools', 'error', '缺少 ' + missingTools.length + '/' + EXPECTED_IRIS_TOOLS.length + ' 个 Iris 工具'
|
|
345
|
+
+ (missingTools.length ? ':' + missingTools.join(', ') : ''),
|
|
346
|
+
'检查单工具注册隔离日志;不要因一项失败重启或重复提交媒体任务。'));
|
|
347
|
+
|
|
348
|
+
const routesAvailable = capabilityCheck(checks, snapshot, 'routes-port', 'routes', 'Web 路由注册', 'error');
|
|
349
|
+
const missingRoutes = missing(EXPECTED_IRIS_ROUTES, evidence.routes);
|
|
350
|
+
checks.push(routesAvailable && missingRoutes.length === 0
|
|
351
|
+
? result('routes', 'ok', EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由均已登记')
|
|
352
|
+
: result('routes', 'error', '缺少 ' + missingRoutes.length + '/' + EXPECTED_IRIS_ROUTES.length + ' 组 Iris 路由'
|
|
353
|
+
+ (missingRoutes.length ? ':' + missingRoutes.join(', ') : ''),
|
|
354
|
+
'检查 webServer/httpServer 与路由挂载日志。'));
|
|
355
|
+
|
|
356
|
+
const skillsAvailable = capabilityCheck(checks, snapshot, 'skills-port', 'skills', 'Skill 注册');
|
|
357
|
+
const missingSkills = missing(EXPECTED_IRIS_SKILLS, evidence.skills);
|
|
358
|
+
checks.push(skillsAvailable && missingSkills.length === 0
|
|
359
|
+
? result('skills', 'ok', EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill 均已登记')
|
|
360
|
+
: result('skills', 'warn', '缺少 ' + missingSkills.length + '/' + EXPECTED_IRIS_SKILLS.length + ' 项随包 Skill'
|
|
361
|
+
+ (missingSkills.length ? ':' + missingSkills.join(', ') : ''),
|
|
362
|
+
'工具仍可直接调用;检查 DSH Skill registry 和插件启动日志。'));
|
|
363
|
+
|
|
364
|
+
capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser');
|
|
365
|
+
capabilityCheck(checks, snapshot, 'attachments', 'attachments', '附件读写');
|
|
366
|
+
capabilityCheck(checks, snapshot, 'sessions', 'sessions', '会话查询');
|
|
367
|
+
capabilityCheck(checks, snapshot, 'text-model', 'textModel', '宿主文本模型');
|
|
368
|
+
capabilityCheck(checks, snapshot, 'vision-model', 'visionModel', '宿主视觉模型');
|
|
369
|
+
|
|
370
|
+
const client = evidence && typeof evidence.client === 'object' ? evidence.client : null;
|
|
371
|
+
if (!client || client.loaded !== true) {
|
|
372
|
+
checks.push(result('client', 'warn', '尚未收到 Iris 浏览器客户端握手',
|
|
373
|
+
'在同一 DSH 实例打开或刷新 Web 页面,再重新访问 /iris/api/doctor。'));
|
|
374
|
+
} else if (client.pluginId !== IRIS_PLUGIN_ID
|
|
375
|
+
|| client.protocolVersion !== HOST_CLIENT_PROTOCOL_VERSION
|
|
376
|
+
|| client.version !== packageVersion) {
|
|
377
|
+
checks.push(result('client', 'error',
|
|
378
|
+
'Iris 服务端/客户端版本不一致:server ' + packageVersion + ' · client ' + String(client.version || 'unknown')
|
|
379
|
+
+ ' · protocol ' + String(client.protocolVersion),
|
|
380
|
+
'重新打开 DSH 输出的认证 URL并强制刷新;若仍不一致,清理该 profile 的旧插件链接后重装。'));
|
|
381
|
+
} else {
|
|
382
|
+
const missingSeats = missing(EXPECTED_IRIS_CLIENT_SEATS, client.seats);
|
|
383
|
+
checks.push(missingSeats.length
|
|
384
|
+
? result('client', 'error', '浏览器客户端缺少 ' + missingSeats.length + '/' + EXPECTED_IRIS_CLIENT_SEATS.length
|
|
385
|
+
+ ' 个 UI Slot:' + missingSeats.join(', '),
|
|
386
|
+
'检查客户端模块表和 DSH Slot API;服务端任务不受影响。')
|
|
387
|
+
: result('client', 'ok', '客户端 ' + client.version + ' 已装载,4 个 UI Slot 均已登记'));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
checks.push(result('scope', 'ok', 'Host Doctor 只读取能力快照和 Iris 注册账本;未调用端口、Browser、模型或供应商'));
|
|
391
|
+
|
|
392
|
+
const totals = summarizeChecks(checks);
|
|
393
|
+
return {
|
|
394
|
+
schemaVersion: 1,
|
|
395
|
+
package: { name: IRIS_PLUGIN_ID, version: packageVersion },
|
|
396
|
+
mode: 'host',
|
|
397
|
+
environment: { node: process.versions.node, platform: process.platform, arch: process.arch },
|
|
398
|
+
host: snapshot.host,
|
|
399
|
+
capabilities: snapshot.capabilities,
|
|
400
|
+
checks,
|
|
401
|
+
...totals
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function formatDoctorReport(report) {
|
|
406
|
+
const mark = { ok: '✓', warn: '!', error: '✗' };
|
|
407
|
+
const lines = [`Iris Doctor ${report.package.version} · ${report.mode}`];
|
|
408
|
+
for (const check of report.checks) {
|
|
409
|
+
lines.push(`${mark[check.status] || '-'} ${check.id}: ${check.summary}`);
|
|
410
|
+
if (check.suggestion) lines.push(` 建议:${check.suggestion}`);
|
|
411
|
+
}
|
|
412
|
+
lines.push(`结果:${report.summary.errors} 错误,${report.summary.warnings} 警告`);
|
|
413
|
+
return lines.join('\n');
|
|
414
|
+
}
|