@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,598 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
atomicWritePrivate,
|
|
8
|
+
chmodPrivateFile,
|
|
9
|
+
ensurePrivateDir,
|
|
10
|
+
privateSibling
|
|
11
|
+
} from './private-storage.js';
|
|
12
|
+
|
|
13
|
+
const STORE_DIR = 'artifact-store';
|
|
14
|
+
const STORE_VERSION = 'v0';
|
|
15
|
+
const ID = /^artifact_[a-f0-9]{24}$/;
|
|
16
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
17
|
+
const RELATION_TYPES = new Set(['derived-from', 'preview-of', 'frame-of', 'transcript-of', 'audio-for']);
|
|
18
|
+
const MEDIA_EXTENSION = Object.freeze({
|
|
19
|
+
'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif',
|
|
20
|
+
'video/mp4': '.mp4', 'video/webm': '.webm',
|
|
21
|
+
'audio/wav': '.wav', 'audio/mpeg': '.mp3', 'audio/ogg': '.ogg', 'audio/flac': '.flac',
|
|
22
|
+
'text/plain': '.txt', 'application/json': '.json'
|
|
23
|
+
});
|
|
24
|
+
const EXTENSION_MEDIA = Object.freeze(Object.fromEntries(
|
|
25
|
+
Object.entries(MEDIA_EXTENSION).map(([mediaType, extension]) => [extension, mediaType])
|
|
26
|
+
));
|
|
27
|
+
|
|
28
|
+
export class CoreArtifactError extends Error {
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'CoreArtifactError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(code, message) {
|
|
37
|
+
throw new CoreArtifactError(code, message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dirs(dataRoot) {
|
|
41
|
+
const storeRoot = path.join(dataRoot, STORE_DIR);
|
|
42
|
+
const base = path.join(storeRoot, STORE_VERSION);
|
|
43
|
+
return {
|
|
44
|
+
storeRoot,
|
|
45
|
+
base,
|
|
46
|
+
objects: path.join(base, 'objects'),
|
|
47
|
+
records: path.join(base, 'records'),
|
|
48
|
+
manifests: path.join(base, 'manifests'),
|
|
49
|
+
index: path.join(base, 'index.json')
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkedDirectory(directory, create = false) {
|
|
54
|
+
try {
|
|
55
|
+
if (create) ensurePrivateDir(directory);
|
|
56
|
+
const stat = fs.lstatSync(directory);
|
|
57
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('not a managed directory');
|
|
58
|
+
} catch (_) { fail('IRIS_ARTIFACT_STORE_INVALID', 'Artifact Store 目录缺失或不安全'); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function checkStore(dataRoot, { create = false, manifests = false } = {}) {
|
|
62
|
+
const out = dirs(dataRoot);
|
|
63
|
+
for (const directory of [out.storeRoot, out.base, out.objects, out.records]) {
|
|
64
|
+
checkedDirectory(directory, create);
|
|
65
|
+
}
|
|
66
|
+
if (create || manifests) checkedDirectory(out.manifests, create);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureStore(dataRoot) {
|
|
71
|
+
return checkStore(dataRoot, { create: true, manifests: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stableId(value) {
|
|
75
|
+
const id = String(value || '');
|
|
76
|
+
if (!ID.test(id)) fail('IRIS_ARTIFACT_ID_INVALID', 'Artifact ID 格式无效');
|
|
77
|
+
return id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function recordPath(dataRoot, id) {
|
|
81
|
+
return path.join(dirs(dataRoot).records, stableId(id) + '.json');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function manifestPath(dataRoot, id) {
|
|
85
|
+
return path.join(dirs(dataRoot).manifests, stableId(id) + '.json');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function regularFile(file, code = 'IRIS_ARTIFACT_INVALID') {
|
|
89
|
+
try {
|
|
90
|
+
const stat = fs.lstatSync(file);
|
|
91
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail(code, 'Artifact 文件不是普通文件');
|
|
92
|
+
return stat;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof CoreArtifactError) throw error;
|
|
95
|
+
fail(code, 'Artifact 文件缺失或无法读取');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function jsonFile(file, code) {
|
|
100
|
+
regularFile(file, code);
|
|
101
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
102
|
+
catch (_) { fail(code, 'Artifact JSON 记录无效'); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hashFile(file) {
|
|
106
|
+
const hash = crypto.createHash('sha256');
|
|
107
|
+
const fd = fs.openSync(file, 'r');
|
|
108
|
+
const chunk = Buffer.allocUnsafe(1024 * 1024);
|
|
109
|
+
try {
|
|
110
|
+
let read;
|
|
111
|
+
do {
|
|
112
|
+
read = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
113
|
+
if (read) hash.update(chunk.subarray(0, read));
|
|
114
|
+
} while (read);
|
|
115
|
+
} finally {
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
return hash.digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function jsonClone(value, field) {
|
|
122
|
+
if (value === undefined) return {};
|
|
123
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
124
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', field + ' 必须是 JSON 对象');
|
|
125
|
+
}
|
|
126
|
+
try { return JSON.parse(JSON.stringify(value)); }
|
|
127
|
+
catch (_) { fail('IRIS_ARTIFACT_INPUT_INVALID', field + ' 必须可序列化'); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizedRelations(value) {
|
|
131
|
+
if (value === undefined) return [];
|
|
132
|
+
if (!Array.isArray(value)) fail('IRIS_ARTIFACT_INPUT_INVALID', 'relations 必须是数组');
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
return value.map((edge) => {
|
|
135
|
+
const type = String(edge?.type || '').trim();
|
|
136
|
+
const artifactId = stableId(edge?.artifactId);
|
|
137
|
+
const key = type + ':' + artifactId;
|
|
138
|
+
if (!RELATION_TYPES.has(type) || seen.has(key)) {
|
|
139
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact relation 类型无效或重复');
|
|
140
|
+
}
|
|
141
|
+
seen.add(key);
|
|
142
|
+
return { type, artifactId };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateManifest(manifest, id) {
|
|
147
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
|
|
148
|
+
|| manifest.schemaVersion !== 0 || manifest.id !== id
|
|
149
|
+
|| typeof manifest.kind !== 'string' || !manifest.kind.trim()
|
|
150
|
+
|| !MEDIA_EXTENSION[manifest.mediaType]
|
|
151
|
+
|| !Number.isSafeInteger(manifest.size) || manifest.size <= 0
|
|
152
|
+
|| typeof manifest.createdAt !== 'string'
|
|
153
|
+
|| manifest.object !== id + MEDIA_EXTENSION[manifest.mediaType]
|
|
154
|
+
|| manifest.digest?.algorithm !== 'sha256' || !HASH.test(String(manifest.digest?.value || ''))
|
|
155
|
+
|| !manifest.metadata || typeof manifest.metadata !== 'object' || Array.isArray(manifest.metadata)
|
|
156
|
+
|| !Array.isArray(manifest.relations)) {
|
|
157
|
+
fail('IRIS_ARTIFACT_MANIFEST_INVALID', 'Artifact Manifest 结构无效');
|
|
158
|
+
}
|
|
159
|
+
normalizedRelations(manifest.relations);
|
|
160
|
+
return manifest;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function publicArtifact(manifest) {
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
schemaVersion: manifest.schemaVersion,
|
|
166
|
+
id: manifest.id,
|
|
167
|
+
kind: manifest.kind,
|
|
168
|
+
mediaType: manifest.mediaType,
|
|
169
|
+
size: manifest.size,
|
|
170
|
+
createdAt: manifest.createdAt,
|
|
171
|
+
digest: Object.freeze({ ...manifest.digest }),
|
|
172
|
+
relations: Object.freeze(manifest.relations.map((edge) => Object.freeze({ ...edge }))),
|
|
173
|
+
metadata: Object.freeze({ ...manifest.metadata })
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function verifyObject(dataRoot, manifest) {
|
|
178
|
+
const object = path.join(dirs(dataRoot).objects, manifest.object);
|
|
179
|
+
const stat = regularFile(object);
|
|
180
|
+
if (stat.size !== manifest.size) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', 'Artifact 大小与 Manifest 不一致');
|
|
181
|
+
if (hashFile(object) !== manifest.digest.value) {
|
|
182
|
+
fail('IRIS_ARTIFACT_DIGEST_MISMATCH', 'Artifact 内容哈希与 Manifest 不一致');
|
|
183
|
+
}
|
|
184
|
+
return object;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function legacyManifest(dataRoot, record, id) {
|
|
188
|
+
if (!record || record.schemaVersion !== 0 || record.id !== id
|
|
189
|
+
|| typeof record.kind !== 'string' || !MEDIA_EXTENSION[record.mediaType]
|
|
190
|
+
|| !Number.isSafeInteger(record.size) || record.size <= 0
|
|
191
|
+
|| record.object !== id + MEDIA_EXTENSION[record.mediaType]
|
|
192
|
+
|| !record.metadata || typeof record.metadata !== 'object' || Array.isArray(record.metadata)) {
|
|
193
|
+
fail('IRIS_ARTIFACT_INVALID', '早期 Core Artifact 记录结构无效');
|
|
194
|
+
}
|
|
195
|
+
const object = path.join(dirs(dataRoot).objects, record.object);
|
|
196
|
+
const stat = regularFile(object);
|
|
197
|
+
if (stat.size !== record.size) fail('IRIS_ARTIFACT_DIGEST_MISMATCH', '早期 Core Artifact 大小不一致');
|
|
198
|
+
return {
|
|
199
|
+
schemaVersion: 0,
|
|
200
|
+
id,
|
|
201
|
+
kind: record.kind,
|
|
202
|
+
mediaType: record.mediaType,
|
|
203
|
+
size: record.size,
|
|
204
|
+
createdAt: record.createdAt,
|
|
205
|
+
object: record.object,
|
|
206
|
+
digest: { algorithm: 'sha256', value: hashFile(object) },
|
|
207
|
+
relations: [],
|
|
208
|
+
metadata: { ...record.metadata }
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readArtifact(dataRoot, id, { allowLegacy = true } = {}) {
|
|
213
|
+
checkStore(dataRoot);
|
|
214
|
+
const stable = stableId(id);
|
|
215
|
+
let record;
|
|
216
|
+
try { record = jsonFile(recordPath(dataRoot, stable), 'IRIS_ARTIFACT_NOT_FOUND'); }
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error.code === 'IRIS_ARTIFACT_NOT_FOUND') throw error;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
let manifest;
|
|
222
|
+
if (record?.schemaVersion === 1 && record.id === stable && record.manifest === stable + '.json') {
|
|
223
|
+
checkedDirectory(dirs(dataRoot).manifests);
|
|
224
|
+
manifest = validateManifest(
|
|
225
|
+
jsonFile(manifestPath(dataRoot, stable), 'IRIS_ARTIFACT_MANIFEST_INVALID'), stable
|
|
226
|
+
);
|
|
227
|
+
} else if (allowLegacy) manifest = legacyManifest(dataRoot, record, stable);
|
|
228
|
+
else fail('IRIS_ARTIFACT_INVALID', 'Artifact 提交记录无效');
|
|
229
|
+
const object = verifyObject(dataRoot, manifest);
|
|
230
|
+
return { manifest, object, legacy: record.schemaVersion === 0 };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function commitRecord(dataRoot, id) {
|
|
234
|
+
atomicWritePrivate(recordPath(dataRoot, id), JSON.stringify({
|
|
235
|
+
schemaVersion: 1, id, manifest: id + '.json'
|
|
236
|
+
}, null, 2) + '\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function writeManifest(dataRoot, manifest) {
|
|
240
|
+
validateManifest(manifest, manifest.id);
|
|
241
|
+
atomicWritePrivate(manifestPath(dataRoot, manifest.id), JSON.stringify(manifest, null, 2) + '\n');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readIndexRevision(file) {
|
|
245
|
+
try {
|
|
246
|
+
const index = jsonFile(file, 'IRIS_ARTIFACT_INDEX_INVALID');
|
|
247
|
+
return Number.isSafeInteger(index.revision) && index.revision >= 0 ? index.revision : 0;
|
|
248
|
+
} catch (_) { return 0; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function committedIds(dataRoot) {
|
|
252
|
+
const directory = dirs(dataRoot).records;
|
|
253
|
+
try {
|
|
254
|
+
return fs.readdirSync(directory)
|
|
255
|
+
.map((name) => name.endsWith('.json') ? name.slice(0, -5) : '')
|
|
256
|
+
.filter((id) => ID.test(id));
|
|
257
|
+
} catch (_) { return []; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function writeIndex(dataRoot, manifests) {
|
|
261
|
+
const file = dirs(dataRoot).index;
|
|
262
|
+
const revision = readIndexRevision(file) + 1;
|
|
263
|
+
const artifacts = manifests
|
|
264
|
+
.map((manifest) => ({
|
|
265
|
+
id: manifest.id,
|
|
266
|
+
createdAt: manifest.createdAt,
|
|
267
|
+
mediaType: manifest.mediaType,
|
|
268
|
+
size: manifest.size,
|
|
269
|
+
digest: manifest.digest.value
|
|
270
|
+
}))
|
|
271
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)) || a.id.localeCompare(b.id));
|
|
272
|
+
const inventoryDigest = crypto.createHash('sha256').update(JSON.stringify(artifacts)).digest('hex');
|
|
273
|
+
atomicWritePrivate(file, JSON.stringify({
|
|
274
|
+
schemaVersion: 0, revision, count: artifacts.length, inventoryDigest, artifacts
|
|
275
|
+
}, null, 2) + '\n');
|
|
276
|
+
return { revision, artifacts };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function refreshIndex(dataRoot) {
|
|
280
|
+
const manifests = [];
|
|
281
|
+
for (const id of committedIds(dataRoot)) {
|
|
282
|
+
try { manifests.push(readArtifact(dataRoot, id).manifest); } catch (_) { /* rebuild 会报告坏记录 */ }
|
|
283
|
+
}
|
|
284
|
+
return writeIndex(dataRoot, manifests);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function validateRelationTargets(dataRoot, relations) {
|
|
288
|
+
for (const edge of relations) readArtifact(dataRoot, edge.artifactId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function finishObject(dataRoot, input) {
|
|
292
|
+
const mediaType = String(input.mediaType || '');
|
|
293
|
+
const extension = MEDIA_EXTENSION[mediaType];
|
|
294
|
+
if (!extension) fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 媒体类型不受支持');
|
|
295
|
+
const kind = String(input.kind || '').trim();
|
|
296
|
+
if (!kind) fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact kind 不能为空');
|
|
297
|
+
const relations = normalizedRelations(input.relations);
|
|
298
|
+
validateRelationTargets(dataRoot, relations);
|
|
299
|
+
const metadata = jsonClone(input.metadata, 'metadata');
|
|
300
|
+
const id = 'artifact_' + crypto.randomBytes(12).toString('hex');
|
|
301
|
+
const store = ensureStore(dataRoot);
|
|
302
|
+
const objectName = id + extension;
|
|
303
|
+
const object = path.join(store.objects, objectName);
|
|
304
|
+
let stat;
|
|
305
|
+
try {
|
|
306
|
+
input.writeObject(object);
|
|
307
|
+
stat = regularFile(object);
|
|
308
|
+
} catch (_) {
|
|
309
|
+
try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前随机 ID */ }
|
|
310
|
+
fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 对象无法安全写入');
|
|
311
|
+
}
|
|
312
|
+
if (!stat.size) {
|
|
313
|
+
try { fs.rmSync(object, { force: true }); } catch (_) { /* 只清理当前随机 ID */ }
|
|
314
|
+
fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 内容不能为空');
|
|
315
|
+
}
|
|
316
|
+
const manifest = {
|
|
317
|
+
schemaVersion: 0,
|
|
318
|
+
id,
|
|
319
|
+
kind,
|
|
320
|
+
mediaType,
|
|
321
|
+
size: stat.size,
|
|
322
|
+
createdAt: input.createdAt || new Date().toISOString(),
|
|
323
|
+
object: objectName,
|
|
324
|
+
digest: { algorithm: 'sha256', value: hashFile(object) },
|
|
325
|
+
relations,
|
|
326
|
+
metadata
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
writeManifest(dataRoot, manifest);
|
|
330
|
+
commitRecord(dataRoot, id);
|
|
331
|
+
} catch (_) {
|
|
332
|
+
try { fs.rmSync(object, { force: true }); } catch (_) { /* 当前 ID */ }
|
|
333
|
+
try { fs.rmSync(manifestPath(dataRoot, id), { force: true }); } catch (_) { /* 当前 ID */ }
|
|
334
|
+
fail('IRIS_ARTIFACT_WRITE_FAILED', 'Artifact 无法安全提交');
|
|
335
|
+
}
|
|
336
|
+
try { refreshIndex(dataRoot); } catch (_) { /* index 是可重建缓存,不撤销已提交 Artifact */ }
|
|
337
|
+
return publicArtifact(manifest);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function createCoreArtifact(dataRoot, input = {}) {
|
|
341
|
+
let bytes;
|
|
342
|
+
try { bytes = Buffer.isBuffer(input.bytes) ? input.bytes : Buffer.from(input.bytes || []); }
|
|
343
|
+
catch (_) { fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 内容必须是字节数据'); }
|
|
344
|
+
if (!bytes.length) fail('IRIS_ARTIFACT_INPUT_INVALID', 'Artifact 内容不能为空');
|
|
345
|
+
return finishObject(dataRoot, {
|
|
346
|
+
...input,
|
|
347
|
+
writeObject(object) { atomicWritePrivate(object, bytes); }
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 显式复制旧作品;不移动、不删除来源,也不保存来源路径或文件名。 */
|
|
352
|
+
export function adoptCoreArtifactFile(dataRoot, input = {}) {
|
|
353
|
+
const source = String(input.sourcePath || '').trim();
|
|
354
|
+
if (!path.isAbsolute(source)) fail('IRIS_ARTIFACT_INPUT_INVALID', '旧作品路径必须是绝对路径');
|
|
355
|
+
const stat = regularFile(source, 'IRIS_ARTIFACT_INPUT_INVALID');
|
|
356
|
+
if (!stat.size) fail('IRIS_ARTIFACT_INPUT_INVALID', '旧作品不能为空');
|
|
357
|
+
const mediaType = String(input.mediaType || EXTENSION_MEDIA[path.extname(source).toLowerCase()] || '');
|
|
358
|
+
return finishObject(dataRoot, {
|
|
359
|
+
...input,
|
|
360
|
+
mediaType,
|
|
361
|
+
kind: input.kind || 'adopted',
|
|
362
|
+
writeObject(object) {
|
|
363
|
+
const tmp = privateSibling(object, 'adopt');
|
|
364
|
+
try {
|
|
365
|
+
fs.copyFileSync(source, tmp, fs.constants.COPYFILE_EXCL);
|
|
366
|
+
chmodPrivateFile(tmp);
|
|
367
|
+
fs.renameSync(tmp, object);
|
|
368
|
+
} finally {
|
|
369
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* rename 后已不存在 */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function inspectCoreArtifact(dataRoot, id) {
|
|
376
|
+
return publicArtifact(readArtifact(dataRoot, id).manifest);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 供宿主投影层读取已经校验过内容哈希的字节;不暴露 Core 内部路径。 */
|
|
380
|
+
export function readCoreArtifactBytes(dataRoot, id) {
|
|
381
|
+
const hit = readArtifact(dataRoot, id);
|
|
382
|
+
return Object.freeze({
|
|
383
|
+
artifact: publicArtifact(hit.manifest),
|
|
384
|
+
bytes: Buffer.from(fs.readFileSync(hit.object))
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function exportCoreArtifact(dataRoot, id, outputPath) {
|
|
389
|
+
const destination = String(outputPath || '').trim();
|
|
390
|
+
if (!destination || !path.isAbsolute(destination)) {
|
|
391
|
+
fail('IRIS_ARTIFACT_EXPORT_INVALID', '导出路径必须是绝对路径');
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
if (!fs.statSync(path.dirname(destination)).isDirectory()) throw new Error('not directory');
|
|
395
|
+
} catch (_) { fail('IRIS_ARTIFACT_EXPORT_INVALID', '导出目录不存在或不可访问'); }
|
|
396
|
+
const hit = readArtifact(dataRoot, id);
|
|
397
|
+
try { fs.copyFileSync(hit.object, destination, fs.constants.COPYFILE_EXCL); }
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (error?.code === 'EEXIST') fail('IRIS_ARTIFACT_EXPORT_EXISTS', '导出目标已经存在;不会覆盖文件');
|
|
400
|
+
fail('IRIS_ARTIFACT_EXPORT_FAILED', 'Artifact 导出失败');
|
|
401
|
+
}
|
|
402
|
+
return Object.freeze({ artifact: publicArtifact(hit.manifest), exported: true, bytes: hit.manifest.size });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function listCoreArtifacts(dataRoot, { offset = 0, limit = 50 } = {}) {
|
|
406
|
+
try { fs.lstatSync(dirs(dataRoot).storeRoot); }
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (error?.code !== 'ENOENT') fail('IRIS_ARTIFACT_STORE_INVALID', 'Artifact Store 无法读取');
|
|
409
|
+
return Object.freeze({
|
|
410
|
+
schemaVersion: 0, revision: 0, total: 0, offset: 0, limit: Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50))),
|
|
411
|
+
artifacts: Object.freeze([])
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
checkStore(dataRoot, { manifests: true });
|
|
415
|
+
const index = jsonFile(dirs(dataRoot).index, 'IRIS_ARTIFACT_INDEX_INVALID');
|
|
416
|
+
const inventoryDigest = Array.isArray(index?.artifacts)
|
|
417
|
+
? crypto.createHash('sha256').update(JSON.stringify(index.artifacts)).digest('hex') : '';
|
|
418
|
+
if (!index || index.schemaVersion !== 0 || !Number.isSafeInteger(index.revision)
|
|
419
|
+
|| !Array.isArray(index.artifacts) || index.count !== index.artifacts.length
|
|
420
|
+
|| !HASH.test(String(index.inventoryDigest || '')) || index.inventoryDigest !== inventoryDigest) {
|
|
421
|
+
fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 结构或自校验无效');
|
|
422
|
+
}
|
|
423
|
+
const start = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
424
|
+
const size = Math.max(1, Math.min(200, Math.trunc(Number(limit) || 50)));
|
|
425
|
+
const artifacts = [];
|
|
426
|
+
for (const entry of index.artifacts.slice(start, start + size)) {
|
|
427
|
+
if (!entry || !ID.test(String(entry.id || ''))) fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 条目无效');
|
|
428
|
+
try {
|
|
429
|
+
const artifact = inspectCoreArtifact(dataRoot, entry.id);
|
|
430
|
+
if (entry.createdAt !== artifact.createdAt || entry.mediaType !== artifact.mediaType
|
|
431
|
+
|| entry.size !== artifact.size || entry.digest !== artifact.digest.value) {
|
|
432
|
+
fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 条目与 Manifest 不一致');
|
|
433
|
+
}
|
|
434
|
+
artifacts.push(artifact);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (error?.code === 'IRIS_ARTIFACT_INDEX_INVALID') throw error;
|
|
437
|
+
fail('IRIS_ARTIFACT_INDEX_INVALID', 'Artifact Index 与存储事实不一致;请显式重建');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return Object.freeze({
|
|
441
|
+
schemaVersion: 0,
|
|
442
|
+
revision: index.revision,
|
|
443
|
+
total: index.artifacts.length,
|
|
444
|
+
offset: start,
|
|
445
|
+
limit: size,
|
|
446
|
+
artifacts: Object.freeze(artifacts)
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function names(directory) {
|
|
451
|
+
try { return fs.readdirSync(directory); } catch (_) { return []; }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 只读盘点,复用 Manifest/对象/Index 校验;不接回孤儿、不升级旧记录。 */
|
|
455
|
+
export function inspectCoreArtifactStore(dataRoot) {
|
|
456
|
+
const store = dirs(dataRoot);
|
|
457
|
+
try { fs.lstatSync(store.storeRoot); }
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (error?.code === 'ENOENT') return { status: 'absent', total: 0, invalid: 0, orphanObjects: 0, orphanManifests: 0, unresolved: 0, index: 'absent' };
|
|
460
|
+
fail('IRIS_ARTIFACT_STORE_INVALID', 'Artifact Store 无法读取');
|
|
461
|
+
}
|
|
462
|
+
checkStore(dataRoot, { manifests: true });
|
|
463
|
+
const records = fs.readdirSync(store.records);
|
|
464
|
+
const manifests = fs.readdirSync(store.manifests);
|
|
465
|
+
const objects = fs.readdirSync(store.objects);
|
|
466
|
+
const recordIds = new Set(records.filter((name) => /^artifact_[a-f0-9]{24}\.json$/.test(name)).map((name) => name.slice(0, -5)));
|
|
467
|
+
const manifestIds = new Set(manifests.filter((name) => /^artifact_[a-f0-9]{24}\.json$/.test(name)).map((name) => name.slice(0, -5)));
|
|
468
|
+
const healthy = new Set();
|
|
469
|
+
const referencedObjects = new Set();
|
|
470
|
+
let invalid = 0;
|
|
471
|
+
let orphanManifests = 0;
|
|
472
|
+
let orphanObjects = 0;
|
|
473
|
+
let unresolved = records.length - recordIds.size + manifests.length - manifestIds.size;
|
|
474
|
+
for (const id of recordIds) {
|
|
475
|
+
try {
|
|
476
|
+
const hit = readArtifact(dataRoot, id);
|
|
477
|
+
healthy.add(id);
|
|
478
|
+
referencedObjects.add(hit.manifest.object);
|
|
479
|
+
}
|
|
480
|
+
catch (_) { invalid++; }
|
|
481
|
+
}
|
|
482
|
+
for (const id of manifestIds) {
|
|
483
|
+
if (recordIds.has(id)) continue;
|
|
484
|
+
try {
|
|
485
|
+
const manifest = validateManifest(jsonFile(manifestPath(dataRoot, id), 'IRIS_ARTIFACT_MANIFEST_INVALID'), id);
|
|
486
|
+
verifyObject(dataRoot, manifest);
|
|
487
|
+
referencedObjects.add(manifest.object);
|
|
488
|
+
orphanManifests++;
|
|
489
|
+
} catch (_) { invalid++; }
|
|
490
|
+
}
|
|
491
|
+
for (const name of objects) {
|
|
492
|
+
const id = name.slice(0, name.lastIndexOf('.'));
|
|
493
|
+
const mediaType = EXTENSION_MEDIA[path.extname(name).toLowerCase()];
|
|
494
|
+
if (!ID.test(id) || !mediaType || name !== id + MEDIA_EXTENSION[mediaType]) { unresolved++; continue; }
|
|
495
|
+
try { regularFile(path.join(store.objects, name)); }
|
|
496
|
+
catch (_) { unresolved++; continue; }
|
|
497
|
+
if (!recordIds.has(id) && !manifestIds.has(id)) orphanObjects++;
|
|
498
|
+
else if (!referencedObjects.has(name)) unresolved++;
|
|
499
|
+
}
|
|
500
|
+
let index = 'valid';
|
|
501
|
+
try {
|
|
502
|
+
const indexed = new Set();
|
|
503
|
+
let page;
|
|
504
|
+
let offset = 0;
|
|
505
|
+
do {
|
|
506
|
+
page = listCoreArtifacts(dataRoot, { offset, limit: 200 });
|
|
507
|
+
for (const artifact of page.artifacts) indexed.add(artifact.id);
|
|
508
|
+
offset += page.artifacts.length;
|
|
509
|
+
} while (offset < page.total);
|
|
510
|
+
if (indexed.size !== page.total || indexed.size !== healthy.size
|
|
511
|
+
|| [...healthy].some((id) => !indexed.has(id))) index = 'invalid';
|
|
512
|
+
} catch (_) { index = 'invalid'; }
|
|
513
|
+
return { status: 'present', total: healthy.size, invalid, orphanObjects, orphanManifests, unresolved, index };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function inferredOrphanManifest(dataRoot, id, objectName) {
|
|
517
|
+
const mediaType = EXTENSION_MEDIA[path.extname(objectName).toLowerCase()];
|
|
518
|
+
if (!mediaType || objectName !== id + MEDIA_EXTENSION[mediaType]) return null;
|
|
519
|
+
const object = path.join(dirs(dataRoot).objects, objectName);
|
|
520
|
+
const stat = regularFile(object);
|
|
521
|
+
if (!stat.size) return null;
|
|
522
|
+
return {
|
|
523
|
+
schemaVersion: 0, id, kind: 'recovered', mediaType, size: stat.size,
|
|
524
|
+
createdAt: new Date(stat.mtimeMs || Date.now()).toISOString(),
|
|
525
|
+
object: objectName,
|
|
526
|
+
digest: { algorithm: 'sha256', value: hashFile(object) },
|
|
527
|
+
relations: [], metadata: { recovered: true }
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* writer/recover 专用:升级早期 Core 记录、补全可证明的孤儿提交并重建缓存索引。
|
|
533
|
+
* 不删除任何对象;无法证明完整性的文件只计入报告。
|
|
534
|
+
*/
|
|
535
|
+
export function rebuildCoreArtifactIndex(dataRoot) {
|
|
536
|
+
const store = ensureStore(dataRoot);
|
|
537
|
+
const report = { recovered: 0, legacyUpgraded: 0, invalid: 0, unresolved: 0, danglingRelations: 0 };
|
|
538
|
+
const records = new Set(committedIds(dataRoot));
|
|
539
|
+
const manifests = new Set(names(store.manifests)
|
|
540
|
+
.filter((name) => name.endsWith('.json') && ID.test(name.slice(0, -5)))
|
|
541
|
+
.map((name) => name.slice(0, -5)));
|
|
542
|
+
const objects = names(store.objects);
|
|
543
|
+
|
|
544
|
+
for (const id of [...records]) {
|
|
545
|
+
try {
|
|
546
|
+
const hit = readArtifact(dataRoot, id);
|
|
547
|
+
if (hit.legacy) {
|
|
548
|
+
writeManifest(dataRoot, hit.manifest);
|
|
549
|
+
commitRecord(dataRoot, id);
|
|
550
|
+
report.legacyUpgraded++;
|
|
551
|
+
}
|
|
552
|
+
} catch (_) { report.invalid++; }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
for (const id of manifests) {
|
|
556
|
+
if (records.has(id)) continue;
|
|
557
|
+
try {
|
|
558
|
+
const manifest = validateManifest(jsonFile(manifestPath(dataRoot, id), 'IRIS_ARTIFACT_MANIFEST_INVALID'), id);
|
|
559
|
+
verifyObject(dataRoot, manifest);
|
|
560
|
+
commitRecord(dataRoot, id);
|
|
561
|
+
records.add(id);
|
|
562
|
+
report.recovered++;
|
|
563
|
+
} catch (_) { report.invalid++; }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for (const objectName of objects) {
|
|
567
|
+
const id = objectName.slice(0, objectName.lastIndexOf('.'));
|
|
568
|
+
if (!ID.test(id) || records.has(id) || manifests.has(id)) {
|
|
569
|
+
if (!records.has(id) && !manifests.has(id)) report.unresolved++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const manifest = inferredOrphanManifest(dataRoot, id, objectName);
|
|
574
|
+
if (!manifest) { report.unresolved++; continue; }
|
|
575
|
+
writeManifest(dataRoot, manifest);
|
|
576
|
+
commitRecord(dataRoot, id);
|
|
577
|
+
records.add(id);
|
|
578
|
+
report.recovered++;
|
|
579
|
+
} catch (_) { report.invalid++; }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const healthy = [];
|
|
583
|
+
for (const id of committedIds(dataRoot)) {
|
|
584
|
+
try { healthy.push(readArtifact(dataRoot, id).manifest); }
|
|
585
|
+
catch (_) { /* 已在上面报告或属于竞态损坏;不会进入 index */ }
|
|
586
|
+
}
|
|
587
|
+
const healthyIds = new Set(healthy.map((item) => item.id));
|
|
588
|
+
for (const manifest of healthy) {
|
|
589
|
+
for (const edge of manifest.relations) if (!healthyIds.has(edge.artifactId)) report.danglingRelations++;
|
|
590
|
+
}
|
|
591
|
+
const index = writeIndex(dataRoot, healthy);
|
|
592
|
+
return Object.freeze({
|
|
593
|
+
schemaVersion: 0,
|
|
594
|
+
total: healthy.length,
|
|
595
|
+
...report,
|
|
596
|
+
indexRevision: index.revision
|
|
597
|
+
});
|
|
598
|
+
}
|