@haaaiawd/loom 1.2.2 → 2.0.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/CHANGELOG.md +11 -58
- package/CONTRIBUTING.md +37 -0
- package/EVIL_EVAL.md +112 -0
- package/README.md +193 -446
- package/README.zh-CN.md +174 -0
- package/SECURITY.md +11 -0
- package/cli/bin/loom.js +170 -995
- package/cli/src/protocol.js +367 -0
- package/cli/src/store.js +626 -0
- package/design.md +194 -0
- package/docs/PROMPT_CATALOG.md +99 -0
- package/docs/RELEASE_CHECKLIST.md +53 -0
- package/docs/UX_FLOW.md +171 -0
- package/docs/brand/loom-mark.svg +18 -0
- package/docs/brand/loom-readme-header.svg +34 -0
- package/docs/brand/loom-readme-header.zh-CN.svg +29 -0
- package/docs/loom-eval-loop.drawio +21 -0
- package/docs/loom-eval-loop.svg +56 -0
- package/docs/loom-production-loop.drawio +41 -0
- package/docs/loom-production-loop.svg +92 -0
- package/package.json +43 -40
- package/EXTERNAL_ACQUISITION_DESIGN.md +0 -143
- package/cli/help/asset.md +0 -36
- package/cli/help/atelier.md +0 -37
- package/cli/help/capability.md +0 -73
- package/cli/help/concepts.md +0 -103
- package/cli/help/doctor.md +0 -73
- package/cli/help/expertise.md +0 -51
- package/cli/help/loop.md +0 -134
- package/cli/help/patch.md +0 -33
- package/cli/help/preview.md +0 -60
- package/cli/help/proposals.md +0 -21
- package/cli/help/version.md +0 -136
- package/cli/help/workflow.md +0 -114
- package/cli/src/activate.js +0 -473
- package/cli/src/asset-library.js +0 -384
- package/cli/src/atelier.js +0 -331
- package/cli/src/auto.js +0 -116
- package/cli/src/capability-graph.js +0 -430
- package/cli/src/capability-proposals.js +0 -225
- package/cli/src/diagnostics.js +0 -766
- package/cli/src/expertise-pack.js +0 -336
- package/cli/src/guide.js +0 -495
- package/cli/src/help.js +0 -41
- package/cli/src/init.js +0 -187
- package/cli/src/intent-draft.js +0 -303
- package/cli/src/intent-map.js +0 -747
- package/cli/src/patch.js +0 -214
- package/cli/src/philosophy.js +0 -331
- package/cli/src/preview-prompt.md +0 -337
- package/cli/src/preview.js +0 -73
- package/cli/src/shared/intent-ref.js +0 -38
- package/cli/src/shared/md-utils.js +0 -125
- package/cli/src/shared/paths.js +0 -73
- package/cli/src/shared/proof-reference.js +0 -19
- package/cli/src/shared/verification-method.js +0 -32
- package/cli/src/verify.js +0 -394
- package/cli/src/version.js +0 -134
- package/dimensions/AUTHORSHIP.md +0 -45
- package/dimensions/PART_DECOMPOSITION.md +0 -42
- package/dimensions/SEARCH_METHODOLOGY.md +0 -101
- package/dimensions/examples/AGENT_SYSTEM/README.md +0 -219
- package/dimensions/examples/CLI_TOOL/README.md +0 -163
- package/dimensions/universal/COLLABORATION_PHILOSOPHY.md +0 -28
- package/dimensions/universal/ENGINEERING_CREED.md +0 -30
- package/dimensions/universal/PRODUCT_PHILOSOPHY.md +0 -32
- package/meta/BASELINE.md +0 -91
- package/meta/INTENT_LOOP.md +0 -296
- package/meta/PHILOSOPHY_WEAVER.md +0 -110
- package/meta/ROLE_ACTIVATION.md +0 -114
- package/roles/architect.md +0 -86
- package/roles/forge.md +0 -112
- package/roles/keeper.md +0 -113
- package/roles/visionary.md +0 -57
- package/templates/ASSET_LIBRARY_MANIFEST_TEMPLATE.json +0 -10
- package/templates/ATELIER_RECORD_TEMPLATE.json +0 -48
- package/templates/CAPABILITY_BRIEF_TEMPLATE.md +0 -35
- package/templates/CAPABILITY_GRAPH_TEMPLATE.json +0 -11
- package/templates/EXPERTISE_PACK_TEMPLATE.json +0 -22
- package/templates/INTENT_MAP_TEMPLATE.json +0 -85
- package/templates/PHILOSOPHY_TEMPLATE.md +0 -44
- package/templates/VISION_TEMPLATE.md +0 -44
package/cli/src/asset-library.js
DELETED
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
// asset-library — versioned, local-first source of truth for project assets.
|
|
2
|
-
// It deliberately manages bytes and provenance; it never promises a remote URL is renderable.
|
|
3
|
-
|
|
4
|
-
import { closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { createHash } from 'node:crypto';
|
|
6
|
-
import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
|
-
import { getCapabilityGraphPath, loadCapabilityGraph, validateCapabilityGraph } from './capability-graph.js';
|
|
8
|
-
import { readJsonFile } from './shared/md-utils.js';
|
|
9
|
-
|
|
10
|
-
const ROOT_DIR = '08_ASSET_LIBRARY';
|
|
11
|
-
const MANIFEST_FILE = 'manifest.json';
|
|
12
|
-
const FILES_DIR = 'files';
|
|
13
|
-
const IMPORT_JOURNAL_FILE = '.asset-import-journal.json';
|
|
14
|
-
const KINDS = ['image', 'video', 'audio', 'document', 'model', 'other'];
|
|
15
|
-
const STATUSES = ['active', 'archived', 'rejected'];
|
|
16
|
-
const APPROVALS = ['approved', 'pending', 'rejected'];
|
|
17
|
-
|
|
18
|
-
export function getAssetLibraryDir(versionDir) { return join(versionDir, ROOT_DIR); }
|
|
19
|
-
export function getAssetManifestPath(versionDir) { return join(getAssetLibraryDir(versionDir), MANIFEST_FILE); }
|
|
20
|
-
export function getAssetFilesDir(versionDir) { return join(getAssetLibraryDir(versionDir), FILES_DIR); }
|
|
21
|
-
export function getAssetImportJournalPath(versionDir) { return join(getAssetLibraryDir(versionDir), IMPORT_JOURNAL_FILE); }
|
|
22
|
-
|
|
23
|
-
function removeIfExists(path) {
|
|
24
|
-
if (existsSync(path)) unlinkSync(path);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function fsyncFile(path) {
|
|
28
|
-
const fd = openSync(path, 'r');
|
|
29
|
-
try {
|
|
30
|
-
fsyncSync(fd);
|
|
31
|
-
} catch (error) {
|
|
32
|
-
// Some Windows/virtual filesystems reject fsync even for a writable regular
|
|
33
|
-
// file. Rename + journal recovery still preserve consistency; do not turn
|
|
34
|
-
// that platform limitation into an unusable importer.
|
|
35
|
-
if (!['EPERM', 'EINVAL'].includes(error.code)) throw error;
|
|
36
|
-
} finally { closeSync(fd); }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function transactionTempPath(path, id) {
|
|
40
|
-
return join(resolve(path, '..'), `.${basename(path)}.loom-import-${id}.tmp`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// A single rename is atomic on the same filesystem. This does not make the whole
|
|
44
|
-
// import atomic across files; the journal below makes that multi-file change recoverable.
|
|
45
|
-
function writeFileAtomically(path, content, transactionId) {
|
|
46
|
-
const temp = transactionTempPath(path, transactionId);
|
|
47
|
-
writeFileSync(temp, content, 'utf-8');
|
|
48
|
-
fsyncFile(temp);
|
|
49
|
-
renameSync(temp, path);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function snapshot(path) {
|
|
53
|
-
return existsSync(path)
|
|
54
|
-
? { exists: true, content_base64: readFileSync(path).toString('base64') }
|
|
55
|
-
: { exists: false };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function restoreSnapshot(path, saved, transactionId) {
|
|
59
|
-
if (!saved || !saved.exists) {
|
|
60
|
-
removeIfExists(path);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
writeFileAtomically(path, Buffer.from(saved.content_base64, 'base64'), transactionId);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function readImportJournal(versionDir) {
|
|
67
|
-
const path = getAssetImportJournalPath(versionDir);
|
|
68
|
-
if (!existsSync(path)) return null;
|
|
69
|
-
const journal = readJsonFile(path, 'Asset import recovery journal');
|
|
70
|
-
if (!journal || journal.schema !== 'loom.asset-import-recovery.v1' || typeof journal.transaction_id !== 'string') {
|
|
71
|
-
throw new Error(`unrecoverable Asset import journal at ${ROOT_DIR}/${IMPORT_JOURNAL_FILE}; restore the manifest and graph from backup before importing again`);
|
|
72
|
-
}
|
|
73
|
-
return journal;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function writeImportJournal(versionDir, journal) {
|
|
77
|
-
mkdirSync(getAssetLibraryDir(versionDir), { recursive: true });
|
|
78
|
-
writeFileAtomically(getAssetImportJournalPath(versionDir), `${JSON.stringify(journal, null, 2)}\n`, journal.transaction_id);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Roll back an interrupted import before any caller reads or writes this library.
|
|
83
|
-
* This is deliberately recovery, not a claim of cross-file OS atomicity.
|
|
84
|
-
*/
|
|
85
|
-
export function recoverAssetImportTransaction(versionDir) {
|
|
86
|
-
const journal = readImportJournal(versionDir);
|
|
87
|
-
if (!journal) return { recovered: false };
|
|
88
|
-
const transactionId = journal.transaction_id;
|
|
89
|
-
try {
|
|
90
|
-
const destination = safeAssetPath(versionDir, journal.destination_path);
|
|
91
|
-
const temporaryAsset = safeAssetPath(versionDir, journal.temporary_asset_path);
|
|
92
|
-
restoreSnapshot(getAssetManifestPath(versionDir), journal.before.manifest, transactionId);
|
|
93
|
-
if (journal.before.graph) restoreSnapshot(getCapabilityGraphPath(versionDir), journal.before.graph, transactionId);
|
|
94
|
-
removeIfExists(destination);
|
|
95
|
-
removeIfExists(temporaryAsset);
|
|
96
|
-
removeIfExists(transactionTempPath(getAssetManifestPath(versionDir), transactionId));
|
|
97
|
-
if (journal.before.graph) removeIfExists(transactionTempPath(getCapabilityGraphPath(versionDir), transactionId));
|
|
98
|
-
removeIfExists(getAssetImportJournalPath(versionDir));
|
|
99
|
-
return { recovered: true, transaction_id: transactionId, stage: journal.stage || 'unknown' };
|
|
100
|
-
} catch (error) {
|
|
101
|
-
throw new Error(`unfinished Asset import transaction ${transactionId} could not be recovered: ${error.message}`);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function nonEmpty(value, label, errors) {
|
|
106
|
-
if (typeof value !== 'string' || value.trim() === '') errors.push(`${label} must be a non-empty string`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function safeAssetPath(versionDir, storedPath) {
|
|
110
|
-
if (typeof storedPath !== 'string' || !storedPath) throw new Error('asset.path must be a non-empty relative path');
|
|
111
|
-
if (isAbsolute(storedPath)) throw new Error(`asset.path must not be absolute: ${storedPath}`);
|
|
112
|
-
const root = resolve(getAssetLibraryDir(versionDir));
|
|
113
|
-
const candidate = resolve(root, storedPath);
|
|
114
|
-
const rel = relative(root, candidate);
|
|
115
|
-
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`asset.path escapes Asset Library: ${storedPath}`);
|
|
116
|
-
const filesRoot = resolve(getAssetFilesDir(versionDir));
|
|
117
|
-
const filesRel = relative(filesRoot, candidate);
|
|
118
|
-
if (filesRel.startsWith('..') || isAbsolute(filesRel)) throw new Error(`asset.path must be within ${ROOT_DIR}/${FILES_DIR}: ${storedPath}`);
|
|
119
|
-
return candidate;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function hashFile(path) {
|
|
123
|
-
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function normalizeTags(tags) {
|
|
127
|
-
const raw = Array.isArray(tags) ? tags : String(tags || '').split(',');
|
|
128
|
-
const seen = new Set();
|
|
129
|
-
for (const tag of raw) {
|
|
130
|
-
const normalized = String(tag).trim().replace(/\s+/g, ' ');
|
|
131
|
-
if (normalized) seen.add(normalized);
|
|
132
|
-
}
|
|
133
|
-
return [...seen];
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function validateRecord(id, asset, versionDir, errors, { checkBytes = true } = {}) {
|
|
137
|
-
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) {
|
|
138
|
-
errors.push(`assets[${id}] must be an object`);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
if (asset.id !== id) errors.push(`assets[${id}].id must match its key`);
|
|
142
|
-
if (!/^ASSET-[a-f0-9]{12,64}$/i.test(id)) errors.push(`assets[${id}] must use content-derived ASSET-<sha256-prefix> id`);
|
|
143
|
-
if (!KINDS.includes(asset.kind)) errors.push(`assets[${id}].kind is invalid`);
|
|
144
|
-
if (!Array.isArray(asset.tags) || asset.tags.length === 0 || asset.tags.some((tag) => typeof tag !== 'string' || !tag.trim())) errors.push(`assets[${id}].tags must contain at least one non-empty tag`);
|
|
145
|
-
if (!STATUSES.includes(asset.status)) errors.push(`assets[${id}].status is invalid`);
|
|
146
|
-
if (!APPROVALS.includes(asset.approval)) errors.push(`assets[${id}].approval is invalid`);
|
|
147
|
-
if (!asset.source || typeof asset.source !== 'object' || Array.isArray(asset.source)) {
|
|
148
|
-
errors.push(`assets[${id}].source must be an object`);
|
|
149
|
-
} else {
|
|
150
|
-
nonEmpty(asset.source.label, `assets[${id}].source.label`, errors);
|
|
151
|
-
nonEmpty(asset.source.author, `assets[${id}].source.author`, errors);
|
|
152
|
-
nonEmpty(asset.source.license, `assets[${id}].source.license`, errors);
|
|
153
|
-
}
|
|
154
|
-
if (!/^[a-f0-9]{64}$/i.test(asset.content_hash || '')) errors.push(`assets[${id}].content_hash must be a SHA-256 hex digest`);
|
|
155
|
-
try {
|
|
156
|
-
const filePath = safeAssetPath(versionDir, asset.path);
|
|
157
|
-
if (checkBytes && !existsSync(filePath)) errors.push(`assets[${id}].path does not exist: ${asset.path}`);
|
|
158
|
-
else if (checkBytes && hashFile(filePath) !== asset.content_hash) errors.push(`assets[${id}].content_hash does not match bytes at ${asset.path}`);
|
|
159
|
-
} catch (error) { errors.push(`assets[${id}].path invalid: ${error.message}`); }
|
|
160
|
-
if (asset.evidence_refs !== undefined && (!Array.isArray(asset.evidence_refs) || asset.evidence_refs.some((ref) => typeof ref !== 'string' || !ref.trim()))) {
|
|
161
|
-
errors.push(`assets[${id}].evidence_refs must be a string array`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function validateEvidenceReciprocity(manifest, graph, errors) {
|
|
166
|
-
if (!graph) return;
|
|
167
|
-
for (const [id, asset] of Object.entries(manifest.assets || {})) {
|
|
168
|
-
for (const evidenceId of asset.evidence_refs || []) {
|
|
169
|
-
const node = graph.nodes[evidenceId];
|
|
170
|
-
if (!node || node.kind !== 'evidence') errors.push(`assets[${id}].evidence_refs references missing evidence node ${evidenceId}`);
|
|
171
|
-
else if (!(node.asset_refs || []).includes(id)) errors.push(`assets[${id}] -> ${evidenceId} lacks reciprocal evidence.asset_refs link`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
for (const node of Object.values(graph.nodes || {})) {
|
|
175
|
-
if (!node.asset_refs) continue;
|
|
176
|
-
if (node.kind !== 'evidence') errors.push(`${node.id}.asset_refs is only allowed on evidence nodes`);
|
|
177
|
-
for (const assetId of node.asset_refs) {
|
|
178
|
-
const asset = manifest.assets[assetId];
|
|
179
|
-
if (!asset) errors.push(`${node.id}.asset_refs references missing asset ${assetId}`);
|
|
180
|
-
else if (!(asset.evidence_refs || []).includes(node.id)) errors.push(`${node.id} -> ${assetId} lacks reciprocal asset.evidence_refs link`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function validateProspectiveImport(versionDir, manifest, graph) {
|
|
186
|
-
const errors = [];
|
|
187
|
-
for (const [id, asset] of Object.entries(manifest.assets || {})) validateRecord(id, asset, versionDir, errors, { checkBytes: false });
|
|
188
|
-
if (graph) {
|
|
189
|
-
try { validateCapabilityGraph(graph); } catch (error) { errors.push(error.message); }
|
|
190
|
-
validateEvidenceReciprocity(manifest, graph, errors);
|
|
191
|
-
}
|
|
192
|
-
if (errors.length) throw new Error(`Asset import prevalidation failed:\n - ${errors.join('\n - ')}`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function validateAssetLibrary(versionDir, { checkGraph = true, recover = true } = {}) {
|
|
196
|
-
if (recover) recoverAssetImportTransaction(versionDir);
|
|
197
|
-
const manifestPath = getAssetManifestPath(versionDir);
|
|
198
|
-
if (!existsSync(manifestPath)) throw new Error(`missing ${ROOT_DIR}/${MANIFEST_FILE}`);
|
|
199
|
-
const manifest = readJsonFile(manifestPath, 'Asset Library manifest');
|
|
200
|
-
const errors = [];
|
|
201
|
-
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) errors.push('manifest must be an object');
|
|
202
|
-
if (!manifest.assets || typeof manifest.assets !== 'object' || Array.isArray(manifest.assets)) errors.push('manifest.assets must be an object');
|
|
203
|
-
else {
|
|
204
|
-
const seenHashes = new Set();
|
|
205
|
-
for (const [id, asset] of Object.entries(manifest.assets)) {
|
|
206
|
-
validateRecord(id, asset, versionDir, errors);
|
|
207
|
-
if (asset?.content_hash) {
|
|
208
|
-
if (seenHashes.has(asset.content_hash)) errors.push(`duplicate content_hash: ${asset.content_hash}`);
|
|
209
|
-
seenHashes.add(asset.content_hash);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (checkGraph && manifest.assets && existsSync(getCapabilityGraphPath(versionDir))) {
|
|
214
|
-
try {
|
|
215
|
-
const graph = loadCapabilityGraph(versionDir);
|
|
216
|
-
validateEvidenceReciprocity(manifest, graph, errors);
|
|
217
|
-
} catch (error) { errors.push(`Capability Graph evidence reference check failed: ${error.message}`); }
|
|
218
|
-
}
|
|
219
|
-
if (errors.length) throw new Error(`Asset Library validation failed:\n - ${errors.join('\n - ')}`);
|
|
220
|
-
return manifest;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function loadAssetLibrary(versionDir, options = {}) {
|
|
224
|
-
const { required = true, checkGraph = false } = options;
|
|
225
|
-
if (!existsSync(getAssetManifestPath(versionDir))) {
|
|
226
|
-
if (required) throw new Error(`missing ${ROOT_DIR}/${MANIFEST_FILE}`);
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
return validateAssetLibrary(versionDir, { checkGraph });
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function importAsset(versionDir, inputPath, metadata = {}) {
|
|
233
|
-
// Never start a new import on top of a previous interrupted one.
|
|
234
|
-
recoverAssetImportTransaction(versionDir);
|
|
235
|
-
if (!inputPath || typeof inputPath !== 'string') throw new Error('asset import requires an explicit local file path');
|
|
236
|
-
const sourcePath = resolve(inputPath);
|
|
237
|
-
if (!existsSync(sourcePath)) throw new Error(`source file does not exist: ${inputPath}`);
|
|
238
|
-
if (lstatSync(sourcePath).isSymbolicLink()) throw new Error('asset import rejects symbolic links; import the real local file explicitly');
|
|
239
|
-
if (!statSync(sourcePath).isFile()) throw new Error('asset import accepts files only');
|
|
240
|
-
const libraryRoot = resolve(getAssetLibraryDir(versionDir));
|
|
241
|
-
const sourceReal = realpathSync(sourcePath);
|
|
242
|
-
const sourceRel = relative(libraryRoot, sourceReal);
|
|
243
|
-
if (!sourceRel.startsWith('..') && !isAbsolute(sourceRel)) throw new Error('asset import rejects files already inside the library; use existing asset id instead');
|
|
244
|
-
const tags = normalizeTags(metadata.tags);
|
|
245
|
-
const kind = metadata.kind || 'image';
|
|
246
|
-
const approval = metadata.approval || 'pending';
|
|
247
|
-
const errors = [];
|
|
248
|
-
if (!KINDS.includes(kind)) errors.push(`invalid kind: ${kind}`);
|
|
249
|
-
if (!tags.length) errors.push('at least one --tags value is required');
|
|
250
|
-
if (!metadata.source) errors.push('--source is required');
|
|
251
|
-
if (!metadata.author) errors.push('--author is required');
|
|
252
|
-
if (!metadata.license) errors.push('--license is required');
|
|
253
|
-
if (approval !== 'approved') errors.push('import requires --approval approved; pending or rejected bytes cannot enter the usable library');
|
|
254
|
-
if (errors.length) throw new Error(errors.join('; '));
|
|
255
|
-
|
|
256
|
-
// The existing state must already be internally consistent before we create a
|
|
257
|
-
// candidate. This prevents a new import from laundering an old one-sided link.
|
|
258
|
-
const manifest = loadAssetLibrary(versionDir, { checkGraph: true });
|
|
259
|
-
const evidenceRefs = normalizeTags(metadata.evidenceRefs || []);
|
|
260
|
-
let graph = null;
|
|
261
|
-
if (evidenceRefs.length) {
|
|
262
|
-
graph = loadCapabilityGraph(versionDir);
|
|
263
|
-
for (const evidenceId of evidenceRefs) {
|
|
264
|
-
const node = graph.nodes[evidenceId];
|
|
265
|
-
if (!node || node.kind !== 'evidence') throw new Error(`--evidence references missing evidence node: ${evidenceId}`);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
const contentHash = hashFile(sourceReal);
|
|
269
|
-
if (Object.values(manifest.assets).some((asset) => asset.content_hash === contentHash)) throw new Error(`duplicate asset bytes: SHA-256 ${contentHash} already exists in this library`);
|
|
270
|
-
const id = `ASSET-${contentHash.slice(0, 16)}`;
|
|
271
|
-
if (manifest.assets[id]) throw new Error(`duplicate asset id: ${id}`);
|
|
272
|
-
const suffix = extname(basename(sourceReal)).toLowerCase().replace(/[^.a-z0-9]/g, '');
|
|
273
|
-
const storedPath = `${FILES_DIR}/${id}${suffix || '.bin'}`;
|
|
274
|
-
const destination = safeAssetPath(versionDir, storedPath);
|
|
275
|
-
mkdirSync(getAssetFilesDir(versionDir), { recursive: true });
|
|
276
|
-
if (existsSync(destination)) throw new Error(`destination already exists: ${storedPath}`);
|
|
277
|
-
const asset = {
|
|
278
|
-
id,
|
|
279
|
-
kind,
|
|
280
|
-
tags,
|
|
281
|
-
status: 'active',
|
|
282
|
-
approval,
|
|
283
|
-
content_hash: contentHash,
|
|
284
|
-
path: storedPath,
|
|
285
|
-
source: { label: metadata.source, author: metadata.author, license: metadata.license },
|
|
286
|
-
original_name: basename(sourceReal),
|
|
287
|
-
evidence_refs: evidenceRefs,
|
|
288
|
-
};
|
|
289
|
-
const nextManifest = structuredClone(manifest);
|
|
290
|
-
const nextGraph = graph ? structuredClone(graph) : null;
|
|
291
|
-
nextManifest.assets[id] = asset;
|
|
292
|
-
if (nextGraph) {
|
|
293
|
-
for (const evidenceId of evidenceRefs) {
|
|
294
|
-
const node = nextGraph.nodes[evidenceId];
|
|
295
|
-
node.asset_refs = [...new Set([...(node.asset_refs || []), id])];
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Full preflight happens before a journal or any live file is touched.
|
|
300
|
-
validateProspectiveImport(versionDir, nextManifest, nextGraph);
|
|
301
|
-
|
|
302
|
-
const transactionId = `${id.slice(6).toLowerCase()}-${Date.now().toString(36)}`;
|
|
303
|
-
const temporaryAsset = transactionTempPath(destination, transactionId);
|
|
304
|
-
const manifestPath = getAssetManifestPath(versionDir);
|
|
305
|
-
const graphPath = getCapabilityGraphPath(versionDir);
|
|
306
|
-
const journal = {
|
|
307
|
-
schema: 'loom.asset-import-recovery.v1',
|
|
308
|
-
transaction_id: transactionId,
|
|
309
|
-
stage: 'prepared',
|
|
310
|
-
destination_path: storedPath,
|
|
311
|
-
temporary_asset_path: relative(getAssetLibraryDir(versionDir), temporaryAsset).replace(/\\/g, '/'),
|
|
312
|
-
before: { manifest: snapshot(manifestPath), graph: nextGraph ? snapshot(graphPath) : null },
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
// Journal first. Each live-file replacement is same-directory temp + rename;
|
|
317
|
-
// the journal is what makes the three-file operation recoverable on failure.
|
|
318
|
-
writeImportJournal(versionDir, journal);
|
|
319
|
-
copyFileSync(sourceReal, temporaryAsset, 0);
|
|
320
|
-
fsyncFile(temporaryAsset);
|
|
321
|
-
if (hashFile(temporaryAsset) !== contentHash) throw new Error('copied asset hash mismatch; import aborted');
|
|
322
|
-
|
|
323
|
-
const manifestTemp = transactionTempPath(manifestPath, transactionId);
|
|
324
|
-
writeFileSync(manifestTemp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf-8');
|
|
325
|
-
fsyncFile(manifestTemp);
|
|
326
|
-
const graphTemp = nextGraph ? transactionTempPath(graphPath, transactionId) : null;
|
|
327
|
-
if (graphTemp) {
|
|
328
|
-
writeFileSync(graphTemp, `${JSON.stringify(nextGraph, null, 2)}\n`, 'utf-8');
|
|
329
|
-
fsyncFile(graphTemp);
|
|
330
|
-
}
|
|
331
|
-
journal.stage = 'staged';
|
|
332
|
-
writeImportJournal(versionDir, journal);
|
|
333
|
-
|
|
334
|
-
renameSync(temporaryAsset, destination);
|
|
335
|
-
journal.stage = 'asset_committed';
|
|
336
|
-
writeImportJournal(versionDir, journal);
|
|
337
|
-
|
|
338
|
-
renameSync(manifestTemp, manifestPath);
|
|
339
|
-
journal.stage = 'manifest_committed';
|
|
340
|
-
writeImportJournal(versionDir, journal);
|
|
341
|
-
if (metadata.failureInjection === 'after_manifest') throw new Error('injected asset import failure after manifest write');
|
|
342
|
-
if (metadata.failureInjection === 'crash_after_manifest') {
|
|
343
|
-
// Test-only crash seam: leave the durable journal in place so the next
|
|
344
|
-
// validate/startup path proves it can restore a half-committed import.
|
|
345
|
-
throw Object.assign(new Error('injected crash after manifest write'), { skipAssetImportRollback: true });
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (graphTemp) {
|
|
349
|
-
renameSync(graphTemp, graphPath);
|
|
350
|
-
journal.stage = 'graph_committed';
|
|
351
|
-
writeImportJournal(versionDir, journal);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Validate the fully committed candidate before discarding its recovery record.
|
|
355
|
-
validateAssetLibrary(versionDir, { checkGraph: true, recover: false });
|
|
356
|
-
removeIfExists(getAssetImportJournalPath(versionDir));
|
|
357
|
-
return asset;
|
|
358
|
-
} catch (error) {
|
|
359
|
-
if (error.skipAssetImportRollback) throw error;
|
|
360
|
-
try { recoverAssetImportTransaction(versionDir); } catch (recoveryError) {
|
|
361
|
-
throw new Error(`${error.message}; rollback also failed: ${recoveryError.message}`);
|
|
362
|
-
}
|
|
363
|
-
throw error;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export function listAssets(versionDir) {
|
|
368
|
-
const manifest = loadAssetLibrary(versionDir, { checkGraph: false });
|
|
369
|
-
return Object.values(manifest.assets).map((asset) => ({ ...asset }));
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
export function searchAssets(versionDir, query, { approvedOnly = true } = {}) {
|
|
373
|
-
const needle = String(query || '').trim().toLocaleLowerCase();
|
|
374
|
-
if (!needle) throw new Error('asset search requires a non-empty query');
|
|
375
|
-
return listAssets(versionDir).filter((asset) => (!approvedOnly || (asset.approval === 'approved' && asset.status === 'active'))
|
|
376
|
-
&& [asset.id, asset.kind, ...asset.tags, asset.original_name]
|
|
377
|
-
.some((value) => String(value || '').toLocaleLowerCase().includes(needle)));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
export function getAsset(versionDir, id) {
|
|
381
|
-
const asset = loadAssetLibrary(versionDir, { checkGraph: false }).assets[id];
|
|
382
|
-
if (!asset) throw new Error(`asset not found: ${id}`);
|
|
383
|
-
return asset;
|
|
384
|
-
}
|