abelien-harness 0.3.0 → 0.4.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/README.md +9 -3
- package/package.json +1 -1
- package/skills/abelien-3dgen/SKILL.md +4 -2
- package/skills/abelien-3dgen/providers/tripo.json +64 -1
- package/skills/abelien-3dgen/references/engineering-lowpoly-qualification.md +20 -0
- package/skills/abelien-3dgen/references/host-api.md +128 -0
- package/skills/abelien-3dgen/references/provider-profile.md +4 -0
- package/skills/abelien-appearance/SKILL.md +2 -0
- package/skills/abelien-appearance/references/local-component-edit.md +31 -0
- package/skills/abelien-clarify/SKILL.md +8 -3
- package/skills/abelien-clarify/references/examples.md +9 -0
- package/skills/abelien-clarify/scripts/check_result.py +53 -1
- package/skills/abelien-clarify/scripts/test_check_result.py +40 -0
- package/skills/abelien-guide/SKILL.md +2 -0
- package/skills/abelien-guide/references/shared-response-rules.md +2 -2
- package/skills/abelien-hardware/SKILL.md +5 -0
- package/skills/abelien-hardware/domain.json +2 -2
- package/skills/abelien-hardware/references/search-index-format.md +31 -2
- package/skills/abelien-hardware/references/search-index-release.md +9 -0
- package/skills/abelien-hardware/references/selection-service-contract.md +3 -0
- package/skills/abelien-hardware/scripts/build-search-index.mjs +50 -6
- package/skills/abelien-hardware/scripts/dimensions.mjs +184 -0
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +11 -0
- package/skills/abelien-hardware/scripts/publish-search-index.mjs +2 -1
- package/skills/abelien-hardware/scripts/search-index-release.mjs +15 -2
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +38 -20
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +12 -1
- package/skills/abelien-hardware-selection/scripts/select.mjs +9 -0
- package/skills/abelien-harness/references/workflow-spec.md +15 -2
- package/skills/abelien-harness/scripts/generate3d-host.mjs +398 -0
- package/skills/abelien-harness/scripts/generate3d.mjs +54 -15
- package/skills/abelien-harness/scripts/receipt.mjs +8 -2
- package/skills/abelien-harness/scripts/studio.mjs +18 -10
- package/skills/abelien-structure/SKILL.md +3 -1
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/scripts/run_stage.py +8 -1
- package/skills/abelien-workspace/SKILL.md +2 -0
- package/skills/abelien-workspace/domain.json +1 -1
- package/skills/abelien-workspace/workflows/definitions/a6e8254e1839d94530ae243cba779e423953b7169cbce338f0503d748d44800b.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/b37f3b47e1e8bd1208859de57456152b149416e21949297b22bba937c51a5670.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/b780bf280d61596b43eb6dbc6a417cf8df1ec9a7ae79a6de10f048da9ed58729.json +229 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +1 -1
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +1 -1
- package/skills/abelien-workspace/workflows/user-journey-hardware.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 硬件 Wiki 分类索引格式
|
|
2
2
|
|
|
3
|
-
本文件只在发布、校验或迁移 TOS 硬件 Wiki 搜索索引时读取。完整 Wiki
|
|
3
|
+
本文件只在发布、校验或迁移 TOS 硬件 Wiki 搜索索引时读取。完整 Wiki 是型号选型证据;尺寸仅以数据库导出快照为编辑源,生成 Wiki 和索引须逐字段同源。索引在 Top-K 前执行保守尺寸筛选,不替代 Wiki。
|
|
4
4
|
|
|
5
5
|
## 对象布局
|
|
6
6
|
|
|
@@ -28,7 +28,7 @@ npm run build:hardware-search-index -- \
|
|
|
28
28
|
--output /absolute/path/to/tos-search-index
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
构建器逐页验证 `record_id`、三级 `category_path` 和 Wiki SHA-256与 Manifest 一致,随后输出 `_catalog.json` 和 `indexes/releases/<release_id>/**/*.json.gz
|
|
31
|
+
构建器逐页验证 `record_id`、三级 `category_path` 和 Wiki SHA-256与 Manifest 一致,随后输出 `_catalog.json` 和 `indexes/releases/<release_id>/**/*.json.gz`。不带尺寸快照时输出仅有搜索索引;带 `--component-snapshot` 时另生成 Wiki 和 Manifest,见下节。
|
|
32
32
|
|
|
33
33
|
使用[发布命令](search-index-release.md)先上传并回读全部不可变分片,最后以条件 PUT 替换 `_catalog.json`。这样搜索端不会看到指向尚未上传分片的新目录,并发发布也不会静默覆盖。发布完成后自动验证目录记录总数、Manifest、分类数和每个分片 SHA-256,并通过正式 `hardware.search` 执行真实冒烟。
|
|
34
34
|
|
|
@@ -39,3 +39,32 @@ npm run build:hardware-search-index -- \
|
|
|
39
39
|
默认缺少 `_catalog.json` 时返回 `WIKI_SEARCH_INDEX_MISSING`,不会悄悄下载整个分类。迁移期可显式设置 `TOS_ALLOW_LEGACY_MANIFEST_FALLBACK=true`;此时响应的 `retrieval_mode` 为 `legacy_manifest_full_scan`,并在 `unknowns[]` 写入 `SEARCH_INDEX_FALLBACK`。迁移开关不应作为长期生产配置。
|
|
40
40
|
|
|
41
41
|
正常响应的 `retrieval_mode` 为 `category_index`。每个槽位同时返回 `available_documents`、`indexed_candidates` 和 `fetched_documents`,用于核对“索引看了多少、全文实际下载多少”。
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## 数据库尺寸快照 v1
|
|
45
|
+
|
|
46
|
+
数据库导出格式为 `abelien.component-dimensions.v1`,不是可手填的 Wiki 事实。`records` 按 component_id 的 Unicode 编码顺序排序且不得重复。每项包含 component_id、revision、dimensions_mm(正有限三元组或 null)、dimension_basis(measured/datasheet/estimate/unknown)、dimension_geometry(solid_box/bounding_box/unknown),可含 evidence_ref。revision 是去掉 revision 的记录递归按键排序、无空格 UTF-8 JSON 的 SHA-256 十六进制;snapshot_id 同法哈希整个 records 数组。缺尺寸必须 null/unknown/unknown。占位、默认、未证明的库尺寸不能升级成 measured 或 datasheet;solid_box 只能来自明确形体证据,不从 AABB、名称或占位 CAD 推断。
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run build:hardware-search-index -- \
|
|
50
|
+
--source /absolute/path/to/硬件wiki \
|
|
51
|
+
--component-snapshot /absolute/path/to/component-dimensions.json \
|
|
52
|
+
--output /absolute/path/to/generated-release
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
source 与 output 须是互不嵌套的目录。每个 Wiki record_id 必须命中数据库导出,缺行失败;多余数据库行允许存在。构建器保留原人工 frontmatter 和正文,仅在 output/wiki 生成尺寸字段及来源,原文件不改。输出路径为 `wiki/components/<snapshot_id>/<content_sha>/<原相对路径>`,Manifest 与导出另存;catalog 指向不可变 `wiki/snapshots/<manifest_sha>/` 清单和数据库快照。再次构建已生成源时会核对 `_component-dimensions.json` 和全部尺寸字段;没有快照却手填尺寸、摘要不符或尺寸篡改均拒绝。
|
|
56
|
+
|
|
57
|
+
新增 dimension_source/dimensions_mm 以单行严格 JSON 写入并解析(包括有限科学计数值);原有 capability_coverage 等 YAML inline map 沿用旧解析语义,不因尺寸扩展改写或拒绝。
|
|
58
|
+
|
|
59
|
+
候选和 selected_models 携带 dimensions_mm、dimension_basis、dimension_geometry、dimension_source={component_id,revision,snapshot_id,evidence_ref?}、dimension_check={status,decision_required,reasons,method}。status 只表达粗筛装配结果:clear 为所有粗筛通过,risk 为至少一项失败但不构成证明,incompatible 为用户 hard 上限+measured/datasheet 尺寸下的证明,unknown 为无法判定;依据与上限权威仍看 dimension_basis 和 limit source,不另编码进 status。decision_required 仅在用户 hard 上限+measured/datasheet 尺寸且轴对齐装法全部失败时为 true。索引读入时每条 Wiki sha256 必须存在且合法;即使该行会被尺寸剔除,也不能缺 hash。实际抓取时验证正文 hash、身份、分类及全部尺寸元数据与索引相同。source_snapshot 包含 catalog 和实际分片内容摘要,不以 ETag 代替内容身份。
|
|
60
|
+
|
|
61
|
+
旧 v1 索引缺内容 hash 必须重新构建,迁移开关也不绕过 hash;缺尺寸或缺 geometry 的旧条目仍可读,但 dimension_check=unknown,不获得 hard rejection 或 clear。新索引为 legacy 无尺寸记录显式输出 null/unknown。搜索保留所有 unknown 和 estimate 候选;`dimension_screen` 报 evaluated/rejected/retained/demoted 和逐个硬拒绝原因。按相关度排序后,risk 候选保留但不占 Top-K 名额,因此每个 risk 会多带入一个可能装得下的替补,总抓取数不超过 TOS_MAX_FULL_WIKI_FETCH_PER_SLOT;返回列表先列非 risk、后列 risk,各层内保持相关度顺序,unknown 不降级。`all_indexed_candidates_rejected` 仅指该槽位已评估分类索引,不能说所有硬件都不适用。
|
|
62
|
+
|
|
63
|
+
## 尺寸判定与占用边界
|
|
64
|
+
|
|
65
|
+
`requirements.limits[].envelope` 支持 box.max_mm/min_mm 与 cylinder.max_diameter_mm/max_height_mm/min_diameter_mm/min_height_mm;保留同一条 text/id/source/strength。范围按最大外形减去两倍明确保证的 wall_mm.minimum(缺省 0)求最大腔体;estimate 不参与硬拒绝。所有尺寸有限正数,范围有序,无混合形状字段;最低壁厚必须留出正的最大腔体。较小范围端或估算壁厚不可行不扩大硬拒绝范围。
|
|
66
|
+
|
|
67
|
+
只有 user+hard 边界和 measured/datasheet 组件可硬拒绝。solid_box 可用最小边、对角线、实体体积三项必要条件;任意 bounding_box 只可用最大支撑跨度超过容器最大弦长,不能用包围盒最小边、对角线或体积。六轴置换、圆柱 footprint 只是可装下粗筛,失败是 risk,未知形体是 unknown。clear 只表示单件粗筛,不代表多件装配、CAD 或工程通过。
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
新域包 hardware 1.2.0 / selection 0.5.0 / workspace 1.3.0 使用此加法契约。已有持久结果只读原样;旧 workflow JSON 摘要不固定模块代码,因此不可用旧定义配新包宣称旧行为仍相同。宿主必须固定完整发布包,为尺寸筛选开新运行,不能原地改写旧 CAD 输入或已失败/未知副作用。
|
|
@@ -65,3 +65,12 @@ npm run smoke:hardware-search -- \
|
|
|
65
65
|
构建器以源 Manifest SHA-256 作为 `release_id`,分片写入 `indexes/releases/<release_id>/...`。发布器先验证远端 Manifest,再以不可变语义上传并回读全部分片;只有全部 SHA、Schema、分类和记录数通过后,才用条件 PUT 替换 `_catalog.json`。并发发布导致 Catalog ETag 变化时显式失败,不覆盖别人的新版本。
|
|
66
66
|
|
|
67
67
|
Catalog 激活失败只会留下未引用的不可变分片,不影响旧版本;搜索端始终从唯一 `_catalog.json` 读取一个完整 release。发布命令默认最后执行真实 `hardware.search`,确保 Catalog、分类索引和至少一篇 Wiki 正文能够按生产读取路径访问。`--skip-smoke` 只用于已有独立冒烟门禁的受控 CI,不能把单元测试当成生产连通性证明。
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## 尺寸快照发布
|
|
71
|
+
|
|
72
|
+
数据库导出由 Web 仓库显式只读命令产生;Harness 不接收数据库凭据。尺寸发布使用同一命令的 `--component-snapshot /absolute/path/to/component-dimensions.json`,先运行 `--dry-run --output /absolute/path/to/review` 检查生成 Wiki、Manifest、导出和索引。确定发布环境后去掉 dry-run,才会执行远端写入。
|
|
73
|
+
|
|
74
|
+
这条路径按 content-addressed key 上传生成 Wiki,再上传不可变导出和 Manifest、索引分片,最后条件激活 `_catalog.json`;每个对象冲突即失败,不覆盖旧 Wiki、旧清单或旧快照。生成路径从 built.generated_source 读取,不能把原始 source 目录的 Wiki 当成新的正文。无需 bootstrap-manifest,catalog 使用该 release 自己的版本化 Manifest。故障只留下未引用的不可变文件;本地测试的内存 TOS 只证明执行顺序和摘要,不证明线上发布成功。
|
|
75
|
+
|
|
76
|
+
v1 迁移:所有索引记录必须具备 Wiki 内容 SHA-256。旧无 hash 索引重建再发布;旧无尺寸/geometry 记录保持 unknown。dimensions 更新会改变源 revision、snapshot_id、生成 Wiki 内容 hash、Manifest 身份与分片摘要;旧固定产物保持原字节,不原地“补齐”过去的候选。
|
|
@@ -23,3 +23,6 @@ URL 来自 `ABELIEN_HARDWARE_REVISE_URL`。请求包含 `operation: "revise_exis
|
|
|
23
23
|
## Supplemental source binding
|
|
24
24
|
|
|
25
25
|
For new journeys, `hardware.web-search` and `hardware.web-merge` follow [web-evidence-contract](web-evidence-contract.md). Proposal-only finalize consumes the merged recall; formal selection uses its persisted ID and revalidates original recall/evidence. Finalize now keeps only model assessment fields from the proposal and uses candidate facts for manufacturer/model/classification/source/geometry/CAD. No self-reported source or CAD claim can override an external candidate.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Dimension metadata is copied only from frozen candidates, with deterministic single-part screening shared by search/finalize/select. Name comes from the candidate title; `bom_item_id` is explicitly a selection-line identity, not a catalog BOM identity. Active envelope output may add advisory occupancy. The [local selection contract](../../abelien-hardware-selection/references/selection-service-contract.md#结构化尺寸与正式门禁) defines unknown/pending behavior and the [index contract](search-index-format.md#数据库尺寸快照-v1) defines database provenance. Finalize never upgrades its proposed result to formal verified authority.
|
|
@@ -5,6 +5,8 @@ import path from "node:path";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import zlib from "node:zlib";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { validateComponentSnapshot, fieldsFromSnapshotRecord, dimensionFields } from './dimensions.mjs';
|
|
9
|
+
import { digest } from './selection-request.mjs';
|
|
8
10
|
import { parseHardwareWiki } from "./tos-wiki-search.mjs";
|
|
9
11
|
|
|
10
12
|
const CATALOG_SCHEMA = "abelien.hardware-wiki-search-catalog.v1";
|
|
@@ -57,13 +59,49 @@ function compactRecord(entry, parsed) {
|
|
|
57
59
|
functional_capabilities: meta.functional_capabilities ?? [],
|
|
58
60
|
capability_coverage: meta.capability_coverage ?? [],
|
|
59
61
|
selection_profile: meta.selection_profile,
|
|
62
|
+
...dimensionFields(meta),
|
|
60
63
|
search_text: searchText,
|
|
61
64
|
};
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
function generatedSource(source, output, snapshotFile) {
|
|
68
|
+
if (source === output || source.startsWith(output + path.sep) || output.startsWith(source + path.sep)) throw fail("generated output and source must be separate directories");
|
|
69
|
+
const snapshot = validateComponentSnapshot(JSON.parse(fs.readFileSync(snapshotFile, "utf8")));
|
|
70
|
+
const records = new Map(snapshot.records.map(record => [record.component_id, record]));
|
|
71
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(source, "_manifest.json"), "utf8"));
|
|
72
|
+
if (!Array.isArray(manifest)) throw fail("manifest must be an array");
|
|
73
|
+
const generated = path.join(output, "wiki");
|
|
74
|
+
const nextManifest = manifest.map(entry => {
|
|
75
|
+
const parsed = parseHardwareWiki(fs.readFileSync(safeSourceFile(source, entry.path), "utf8"));
|
|
76
|
+
if (parsed.meta.record_id !== entry.record_id) throw fail(`record_id mismatch for ${entry.path}`);
|
|
77
|
+
if (entry.sha256 && entry.sha256 !== sha256(parsed.content)) throw fail(`sha256 mismatch for ${entry.path}`);
|
|
78
|
+
const record = records.get(entry.record_id);
|
|
79
|
+
if (!record) throw fail(`component snapshot missing Wiki component: ${entry.record_id}`);
|
|
80
|
+
const fields = fieldsFromSnapshotRecord(record, snapshot.snapshot_id);
|
|
81
|
+
// Only replace generated top-level dimension keys; preserve every other frontmatter line and prose.
|
|
82
|
+
const lines = parsed.frontMatter.split("\n"), kept = [];
|
|
83
|
+
let skipping = false;
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (/^[^\s#][^:]*:/.test(line)) skipping = /^(dimensions_mm|dimension_basis|dimension_geometry|dimension_source):/.test(line);
|
|
86
|
+
if (!skipping) kept.push(line);
|
|
87
|
+
}
|
|
88
|
+
const content = `---\n${kept.join("\n")}\n${Object.entries(fields).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join("\n")}\n---\n${parsed.body}`;
|
|
89
|
+
const generatedPath = `wiki/components/${snapshot.snapshot_id}/${sha256(content)}/${entry.path}`;
|
|
90
|
+
const target = safeSourceFile(generated, generatedPath); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content);
|
|
91
|
+
return { ...entry, path: generatedPath, sha256: sha256(content) };
|
|
92
|
+
}).sort((a, b) => a.record_id < b.record_id ? -1 : a.record_id > b.record_id ? 1 : 0);
|
|
93
|
+
fs.mkdirSync(generated, { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(generated, "_manifest.json"), `${JSON.stringify(nextManifest, null, 2)}\n`);
|
|
95
|
+
fs.writeFileSync(path.join(generated, "_component-dimensions.json"), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
96
|
+
return generated;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildSearchIndex({ sourceDir, outputDir, releaseId, componentSnapshot }) {
|
|
66
100
|
const output = path.resolve(outputDir);
|
|
101
|
+
const source = componentSnapshot ? generatedSource(path.resolve(sourceDir), output, componentSnapshot) : path.resolve(sourceDir);
|
|
102
|
+
const sourceSnapshotFile = path.join(source, "_component-dimensions.json");
|
|
103
|
+
const snapshot = fs.existsSync(sourceSnapshotFile) ? validateComponentSnapshot(JSON.parse(fs.readFileSync(sourceSnapshotFile, "utf8"))) : null;
|
|
104
|
+
const sourceRecords = new Map(snapshot?.records.map(row => [row.component_id, row]) ?? []);
|
|
67
105
|
const manifestFile = path.join(source, "_manifest.json");
|
|
68
106
|
if (!fs.existsSync(manifestFile)) throw fail(`manifest not found: ${manifestFile}`);
|
|
69
107
|
const manifestBytes = fs.readFileSync(manifestFile);
|
|
@@ -82,6 +120,11 @@ export function buildSearchIndex({ sourceDir, outputDir, releaseId }) {
|
|
|
82
120
|
if (recordIds.has(entry.record_id)) throw fail(`duplicate record_id in manifest: ${entry.record_id}`);
|
|
83
121
|
recordIds.add(entry.record_id);
|
|
84
122
|
const parsed = parseHardwareWiki(fs.readFileSync(safeSourceFile(source, entry.path), "utf8"));
|
|
123
|
+
const hasDimensions = ["dimensions_mm", "dimension_basis", "dimension_source", "dimension_geometry"].some(key => Object.hasOwn(parsed.meta, key));
|
|
124
|
+
if (hasDimensions) {
|
|
125
|
+
const expected = sourceRecords.get(entry.record_id);
|
|
126
|
+
if (!expected || digest(dimensionFields(parsed.meta)) !== digest(fieldsFromSnapshotRecord(expected, snapshot.snapshot_id))) throw fail(`Wiki dimensions do not match component snapshot: ${entry.path}`);
|
|
127
|
+
} else if (snapshot) throw fail(`snapshot-backed Wiki missing dimension fields: ${entry.path}`);
|
|
85
128
|
const record = compactRecord(entry, parsed);
|
|
86
129
|
const key = entry.category_path.join("/");
|
|
87
130
|
if (!grouped.has(key)) grouped.set(key, []);
|
|
@@ -122,18 +165,19 @@ export function buildSearchIndex({ sourceDir, outputDir, releaseId }) {
|
|
|
122
165
|
schema: CATALOG_SCHEMA,
|
|
123
166
|
release_id: resolvedReleaseId,
|
|
124
167
|
generated_at: latestSourceUpdate,
|
|
125
|
-
source_manifest: { object_key: "_manifest.json", sha256: sourceManifestSha, record_count: manifest.length },
|
|
168
|
+
source_manifest: { object_key: snapshot ? `wiki/snapshots/${sourceManifestSha}/_manifest.json` : "_manifest.json", sha256: sourceManifestSha, record_count: manifest.length },
|
|
126
169
|
categories,
|
|
170
|
+
...(snapshot ? { component_snapshot: { snapshot_id: snapshot.snapshot_id, object_key: `wiki/snapshots/${sourceManifestSha}/_component-dimensions.json`, sha256: sha256(fs.readFileSync(sourceSnapshotFile)) } } : {}),
|
|
127
171
|
};
|
|
128
172
|
fs.writeFileSync(path.join(output, "_catalog.json"), `${JSON.stringify(catalog, null, 2)}\n`);
|
|
129
|
-
return { documents: manifest.length, categories: categories.length, output, release_id: resolvedReleaseId, source_manifest_sha256: sourceManifestSha };
|
|
173
|
+
return { documents: manifest.length, categories: categories.length, output, release_id: resolvedReleaseId, source_manifest_sha256: sourceManifestSha, ...(snapshot ? { generated_source: source, component_snapshot_id: snapshot.snapshot_id } : {}) };
|
|
130
174
|
}
|
|
131
175
|
|
|
132
176
|
function parseArgs(argv) {
|
|
133
177
|
const flags = {};
|
|
134
178
|
for (let i = 0; i < argv.length; i++) {
|
|
135
179
|
const name = argv[i];
|
|
136
|
-
if (name !== "--source" && name !== "--output" && name !== "--release-id") throw fail(`unknown argument: ${name}`);
|
|
180
|
+
if (name !== "--source" && name !== "--output" && name !== "--release-id" && name !== "--component-snapshot") throw fail(`unknown argument: ${name}`);
|
|
137
181
|
if (!argv[i + 1]) throw fail(`${name} requires a value`);
|
|
138
182
|
flags[name.slice(2)] = argv[++i];
|
|
139
183
|
}
|
|
@@ -144,7 +188,7 @@ function parseArgs(argv) {
|
|
|
144
188
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
145
189
|
try {
|
|
146
190
|
const flags = parseArgs(process.argv.slice(2));
|
|
147
|
-
process.stdout.write(`${JSON.stringify({ ok: true, data: buildSearchIndex({ sourceDir: flags.source, outputDir: flags.output, releaseId: flags["release-id"] }) })}\n`);
|
|
191
|
+
process.stdout.write(`${JSON.stringify({ ok: true, data: buildSearchIndex({ sourceDir: flags.source, outputDir: flags.output, releaseId: flags["release-id"], componentSnapshot: flags["component-snapshot"] }) })}\n`);
|
|
148
192
|
} catch (cause) {
|
|
149
193
|
process.stderr.write(`${JSON.stringify({ ok: false, error: { code: cause.code ?? "INDEX_BUILD_FAILED", message: cause.message } })}\n`);
|
|
150
194
|
process.exitCode = 1;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Coarse single-part screening. This module never grants CAD/engineering authority.
|
|
2
|
+
import { canonical, digest } from './selection-request.mjs';
|
|
3
|
+
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
4
|
+
const positive = v => typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
5
|
+
const nonnegative = v => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
6
|
+
const text = v => typeof v === 'string' && v.trim().length > 0;
|
|
7
|
+
const hash = v => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
8
|
+
const tuple = v => Array.isArray(v) && v.length === 3 && v.every(positive);
|
|
9
|
+
const invalid = message => { throw Object.assign(new Error(message), { code: 'VALIDATION' }); };
|
|
10
|
+
function keys(value, required, optional, label) {
|
|
11
|
+
if (!object(value) || required.some(key => !Object.hasOwn(value, key)) || Object.keys(value).some(key => ![...required, ...optional].includes(key))) invalid(`${label} has missing or unsupported fields`);
|
|
12
|
+
}
|
|
13
|
+
export function validateEnvelope(envelope) {
|
|
14
|
+
const box = envelope?.shape === 'box';
|
|
15
|
+
const cylinder = envelope?.shape === 'cylinder';
|
|
16
|
+
if (!box && !cylinder) invalid('envelope shape must be box or cylinder');
|
|
17
|
+
keys(envelope, box ? ['shape', 'max_mm'] : ['shape', 'max_diameter_mm', 'max_height_mm'], box ? ['min_mm', 'wall_mm'] : ['min_diameter_mm', 'min_height_mm', 'wall_mm'], 'envelope');
|
|
18
|
+
if (box) {
|
|
19
|
+
if (!tuple(envelope.max_mm) || (envelope.min_mm !== undefined && (!tuple(envelope.min_mm) || envelope.min_mm.some((v, i) => v > envelope.max_mm[i])))) invalid('box dimensions must be finite positive ordered triples');
|
|
20
|
+
} else {
|
|
21
|
+
for (const axis of ['diameter', 'height']) {
|
|
22
|
+
const max = envelope[`max_${axis}_mm`], min = envelope[`min_${axis}_mm`];
|
|
23
|
+
if (!positive(max) || (min !== undefined && (!positive(min) || min > max))) invalid('cylinder dimensions must be finite positive ordered ranges');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (envelope.wall_mm !== undefined) {
|
|
27
|
+
keys(envelope.wall_mm, ['minimum'], ['estimate'], 'wall_mm');
|
|
28
|
+
const { minimum, estimate } = envelope.wall_mm;
|
|
29
|
+
if (!nonnegative(minimum) || (estimate !== undefined && (!nonnegative(estimate) || estimate < minimum))) invalid('wall minimum/estimate must be finite nonnegative ordered values');
|
|
30
|
+
}
|
|
31
|
+
const max = box ? envelope.max_mm : [envelope.max_diameter_mm, envelope.max_height_mm];
|
|
32
|
+
if (max.some(v => v - 2 * (envelope.wall_mm?.minimum ?? 0) <= 0)) invalid('guaranteed minimum wall leaves no positive maximum cavity');
|
|
33
|
+
return envelope;
|
|
34
|
+
}
|
|
35
|
+
export function validateRequirementEnvelopes(requirements) {
|
|
36
|
+
for (const row of requirements?.limits ?? []) if (row?.envelope !== undefined) validateEnvelope(row.envelope);
|
|
37
|
+
}
|
|
38
|
+
const BASE_FIELDS = ['component_id', 'dimensions_mm', 'dimension_basis', 'dimension_geometry'];
|
|
39
|
+
function dimensionRecord(value) {
|
|
40
|
+
const record = Object.fromEntries(BASE_FIELDS.map(key => [key, value[key]]));
|
|
41
|
+
if (value.evidence_ref !== undefined) record.evidence_ref = value.evidence_ref;
|
|
42
|
+
return record;
|
|
43
|
+
}
|
|
44
|
+
function validRecord(value) {
|
|
45
|
+
return object(value) && text(value.component_id) && (value.dimensions_mm === null || tuple(value.dimensions_mm))
|
|
46
|
+
&& ['measured', 'datasheet', 'estimate', 'unknown'].includes(value.dimension_basis)
|
|
47
|
+
&& ['solid_box', 'bounding_box', 'unknown'].includes(value.dimension_geometry)
|
|
48
|
+
&& (value.evidence_ref === undefined || text(value.evidence_ref))
|
|
49
|
+
&& (value.dimensions_mm !== null || (value.dimension_basis === 'unknown' && value.dimension_geometry === 'unknown'));
|
|
50
|
+
}
|
|
51
|
+
export function validateComponentSnapshot(snapshot) {
|
|
52
|
+
keys(snapshot, ['schema', 'snapshot_id', 'records'], [], 'component snapshot');
|
|
53
|
+
if (snapshot.schema !== 'abelien.component-dimensions.v1' || !Array.isArray(snapshot.records) || !hash(snapshot.snapshot_id)) invalid('unsupported component dimension snapshot');
|
|
54
|
+
let previous;
|
|
55
|
+
for (const row of snapshot.records) {
|
|
56
|
+
keys(row, [...BASE_FIELDS, 'revision'], ['evidence_ref'], 'component snapshot record');
|
|
57
|
+
if (!validRecord(row) || !hash(row.revision) || row.revision !== digest(dimensionRecord(row))) invalid('component dimension record or revision is invalid');
|
|
58
|
+
if (previous !== undefined && previous >= row.component_id) invalid('component records must be unique and sorted by component_id');
|
|
59
|
+
previous = row.component_id;
|
|
60
|
+
}
|
|
61
|
+
if (snapshot.snapshot_id !== digest(snapshot.records)) invalid('component snapshot hash does not match records');
|
|
62
|
+
return snapshot;
|
|
63
|
+
}
|
|
64
|
+
// Only source-bound records can contribute a trusted basis. Legacy unbound fields stay display-only unknown.
|
|
65
|
+
export function dimensionFields(value) {
|
|
66
|
+
const source = value?.dimension_source;
|
|
67
|
+
const id = value?.component_id ?? value?.record_id;
|
|
68
|
+
const validSize = tuple(value?.dimensions_mm);
|
|
69
|
+
const fields = { dimensions_mm: validSize ? [...value.dimensions_mm] : null, dimension_basis: 'unknown', dimension_geometry: 'unknown', dimension_source: null };
|
|
70
|
+
if (!object(source) || source.component_id !== id || !hash(source.revision) || !hash(source.snapshot_id)) return fields;
|
|
71
|
+
const record = { component_id: id, dimensions_mm: fields.dimensions_mm, dimension_basis: value.dimension_basis,
|
|
72
|
+
dimension_geometry: value.dimension_geometry ?? 'unknown', ...(source.evidence_ref !== undefined ? { evidence_ref: source.evidence_ref } : {}) };
|
|
73
|
+
// Missing geometry is a legacy gap, not permission to apply solid-box predicates.
|
|
74
|
+
if (value.dimension_geometry === undefined) { fields.dimension_source = { ...source }; return fields; }
|
|
75
|
+
if (!validRecord(record) || source.revision !== digest(record)) invalid('dimension source revision does not match candidate fields');
|
|
76
|
+
return { dimensions_mm: record.dimensions_mm, dimension_basis: record.dimension_basis, dimension_geometry: record.dimension_geometry, dimension_source: { ...source } };
|
|
77
|
+
}
|
|
78
|
+
export function fieldsFromSnapshotRecord(record, snapshotId) {
|
|
79
|
+
return { dimensions_mm: record.dimensions_mm, dimension_basis: record.dimension_basis, dimension_geometry: record.dimension_geometry,
|
|
80
|
+
dimension_source: { component_id: record.component_id, revision: record.revision, snapshot_id: snapshotId, ...(record.evidence_ref !== undefined ? { evidence_ref: record.evidence_ref } : {}) } };
|
|
81
|
+
}
|
|
82
|
+
// Compare raw dimension-bearing fields too: normalizing unknowns must not hide index/Wiki drift.
|
|
83
|
+
export function sameDimensionMetadata(a, b) {
|
|
84
|
+
const pick = value => ({ dimensions_mm: value.dimensions_mm ?? null, dimension_basis: value.dimension_basis ?? 'unknown', dimension_geometry: value.dimension_geometry ?? 'unknown', dimension_source: value.dimension_source ?? null });
|
|
85
|
+
return canonical(pick(a)) === canonical(pick(b));
|
|
86
|
+
}
|
|
87
|
+
function cavity(envelope, wall) {
|
|
88
|
+
if (envelope.shape === 'box') {
|
|
89
|
+
const sides = envelope.max_mm.map(v => v - 2 * wall);
|
|
90
|
+
return { sides, width: Math.min(...sides), chord: Math.hypot(...sides), volume: sides.reduce((a, b) => a * b, 1), valid: sides.every(positive) };
|
|
91
|
+
}
|
|
92
|
+
const diameter = envelope.max_diameter_mm - 2 * wall, height = envelope.max_height_mm - 2 * wall;
|
|
93
|
+
return { diameter, height, width: Math.min(diameter, height), chord: Math.hypot(diameter, height), volume: Math.PI * (diameter / 2) ** 2 * height, valid: positive(diameter) && positive(height) };
|
|
94
|
+
}
|
|
95
|
+
const exceeds = (a, b) => a - b > 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
|
|
96
|
+
function coarseFit(dims, container) {
|
|
97
|
+
if (!container.valid) return false;
|
|
98
|
+
return [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]].some(p => container.sides
|
|
99
|
+
? p.every((axis, i) => !exceeds(dims[axis], container.sides[i]))
|
|
100
|
+
: !exceeds(dims[p[2]], container.height) && !exceeds(Math.hypot(dims[p[0]], dims[p[1]]), container.diameter));
|
|
101
|
+
}
|
|
102
|
+
// status is the coarse fit result only: clear = every coarse check passed, risk = some check failed without proof,
|
|
103
|
+
// incompatible = proof under a user hard limit with measured/datasheet sizes. Data/limit authority stays in basis and limit source.
|
|
104
|
+
// decision_required = user hard limit + trusted sizes, yet no axis-aligned mounting fits: only the user can trade the part against the limit.
|
|
105
|
+
export function evaluateDimensions(candidate, requirements) {
|
|
106
|
+
validateRequirementEnvelopes(requirements);
|
|
107
|
+
const fields = dimensionFields(candidate), reasons = [], method = 'coarse-single-part-v2';
|
|
108
|
+
const unknown = code => ({ status: 'unknown', decision_required: false, reasons: [{ code }], method });
|
|
109
|
+
const limits = (requirements?.limits ?? []).filter(row => row?.envelope !== undefined);
|
|
110
|
+
if (!limits.length) return unknown('PRODUCT_ENVELOPE_MISSING');
|
|
111
|
+
if (!fields.dimensions_mm || fields.dimension_basis === 'unknown' || fields.dimension_geometry === 'unknown') return unknown('COMPONENT_DIMENSIONS_UNKNOWN');
|
|
112
|
+
const dims = fields.dimensions_mm;
|
|
113
|
+
if (!positive(dims.reduce((a, b) => a * b, 1)) || !positive(Math.hypot(...dims))
|
|
114
|
+
|| limits.some(row => { const c = cavity(row.envelope, row.envelope.wall_mm?.minimum ?? 0); return !positive(c.volume) || !positive(c.chord); }))
|
|
115
|
+
return unknown('NUMERIC_RANGE_UNSUPPORTED');
|
|
116
|
+
let incompatible = false, risk = false, decisionRequired = false;
|
|
117
|
+
for (const row of limits) {
|
|
118
|
+
const hard = row.source === 'user' && row.strength === 'hard' && ['measured', 'datasheet'].includes(fields.dimension_basis);
|
|
119
|
+
const container = cavity(row.envelope, row.envelope.wall_mm?.minimum ?? 0);
|
|
120
|
+
const failures = fields.dimension_geometry === 'solid_box'
|
|
121
|
+
? [['MIN_SUPPORT_WIDTH', Math.min(...dims), container.width], ['SOLID_DIAGONAL', Math.hypot(...dims), container.chord], ['SOLID_VOLUME', dims.reduce((a, b) => a * b, 1), container.volume]]
|
|
122
|
+
: [['MAX_SUPPORT_SPAN', Math.max(...dims), container.chord]];
|
|
123
|
+
for (const [code, observed, maximum] of failures) if (exceeds(observed, maximum)) {
|
|
124
|
+
reasons.push({ code, limit_id: row.id, observed, maximum, hard }); if (hard) incompatible = true; else risk = true;
|
|
125
|
+
}
|
|
126
|
+
// Parts are mounted axis-aligned in practice; failing that is a risk, never a proof, because a tilted part may still fit.
|
|
127
|
+
if (!coarseFit(dims, container)) {
|
|
128
|
+
risk = true; if (hard) decisionRequired = true;
|
|
129
|
+
reasons.push({ code: 'AXIS_ALIGNED_FIT_FAILS', limit_id: row.id, hard: false });
|
|
130
|
+
} else if (row.envelope.wall_mm?.estimate !== undefined && !coarseFit(dims, cavity(row.envelope, row.envelope.wall_mm.estimate))) {
|
|
131
|
+
// Estimated walls only hint; they never reject and never ask the user to decide.
|
|
132
|
+
risk = true; reasons.push({ code: 'ESTIMATED_WALL_FIT_FAILS', limit_id: row.id, hard: false });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { status: incompatible ? 'incompatible' : risk ? 'risk' : 'clear', decision_required: !incompatible && decisionRequired, reasons, method };
|
|
136
|
+
}
|
|
137
|
+
export function screenDimensions(records, requirements) {
|
|
138
|
+
const checks = records.map(record => ({ record, check: evaluateDimensions(record, requirements) }));
|
|
139
|
+
const rejected = checks.filter(item => item.check.status === 'incompatible');
|
|
140
|
+
const survivors = checks.filter(item => item.check.status !== 'incompatible');
|
|
141
|
+
return { survivors, summary: {
|
|
142
|
+
status: records.length && rejected.length === records.length ? 'all_indexed_candidates_rejected' : records.length ? 'screened' : 'no_indexed_candidates',
|
|
143
|
+
scope: 'indexed_category_candidates', evaluated: records.length, rejected: rejected.length, retained: survivors.length,
|
|
144
|
+
demoted: survivors.filter(item => item.check.status === 'risk').length,
|
|
145
|
+
rejections: rejected.map(({ record, check }) => ({ component_id: record.component_id ?? record.record_id, ...check })),
|
|
146
|
+
} };
|
|
147
|
+
}
|
|
148
|
+
// Risk candidates stay visible but do not consume Top-K slots, so each one brings in a replacement that may fit.
|
|
149
|
+
// Input must already be sorted by relevance; output lists non-risk candidates first, each tier keeping relevance order.
|
|
150
|
+
export function shortlistByDimensionTier(ranked, topK, cap, statusOf) {
|
|
151
|
+
const picked = [];
|
|
152
|
+
let fitting = 0;
|
|
153
|
+
for (const item of ranked) {
|
|
154
|
+
if (fitting >= topK || picked.length >= cap) break;
|
|
155
|
+
picked.push(item);
|
|
156
|
+
if (statusOf(item) !== 'risk') fitting++;
|
|
157
|
+
}
|
|
158
|
+
return orderByDimensionTier(picked, statusOf);
|
|
159
|
+
}
|
|
160
|
+
export function orderByDimensionTier(items, statusOf) {
|
|
161
|
+
return [...items.filter(item => statusOf(item) !== 'risk'), ...items.filter(item => statusOf(item) === 'risk')];
|
|
162
|
+
}
|
|
163
|
+
export function assemblyOccupancy(models, requirements, policy = {}) {
|
|
164
|
+
validateRequirementEnvelopes(requirements);
|
|
165
|
+
let estimated_volume_mm3 = 0, unknown_lines = 0, includedLines = 0;
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
for (const model of models) {
|
|
168
|
+
if (seen.has(model.bom_item_id)) continue;
|
|
169
|
+
if (model.bom_item_id) seen.add(model.bom_item_id);
|
|
170
|
+
const fields = dimensionFields(model);
|
|
171
|
+
if (!fields.dimensions_mm || fields.dimension_basis === 'unknown' || !Number.isSafeInteger(model.quantity) || model.quantity < 1) { unknown_lines++; continue; }
|
|
172
|
+
const volume = fields.dimensions_mm.reduce((a, b) => a * b, model.quantity);
|
|
173
|
+
if (!positive(volume) || !Number.isFinite(estimated_volume_mm3 + volume)) { unknown_lines++; continue; }
|
|
174
|
+
estimated_volume_mm3 += volume;
|
|
175
|
+
includedLines++;
|
|
176
|
+
}
|
|
177
|
+
const containers = (includedLines ? requirements?.limits ?? [] : []).filter(row => row?.envelope).flatMap(row => {
|
|
178
|
+
const value = cavity(row.envelope, row.envelope.wall_mm?.minimum ?? 0).volume;
|
|
179
|
+
const ratio = estimated_volume_mm3 / value;
|
|
180
|
+
return positive(value) && nonnegative(ratio) ? [{ limit_id: row.id, cavity_volume_mm3: value, ratio }] : [];
|
|
181
|
+
});
|
|
182
|
+
return { advisory: true, method: 'sum-selected-line-bounding-volumes-v1', estimated_volume_mm3, unknown_lines, containers,
|
|
183
|
+
...(positive(policy.occupancy_warning_ratio) ? { warning: containers.some(c => c.ratio > policy.occupancy_warning_ratio), warning_ratio: policy.occupancy_warning_ratio } : {}) };
|
|
184
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { assemblyOccupancy, dimensionFields, evaluateDimensions } from "./dimensions.mjs";
|
|
3
|
+
import { selectionRequestDigest } from "./selection-request.mjs";
|
|
2
4
|
|
|
3
5
|
function error(message, details) {
|
|
4
6
|
const out = new Error(message);
|
|
@@ -23,6 +25,7 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
23
25
|
if (!recall || recall.status !== "completed" || !recall.source_snapshot) throw error("completed knowledge_recall is required");
|
|
24
26
|
if (!["proposed", "verified", "needs_confirmation"].includes(proposal.status)) throw error("selection_proposal.status is not recognized");
|
|
25
27
|
if (!["adapt_system", "assemble_new"].includes(proposal.plan_mode)) throw error("selection_proposal.plan_mode is not recognized");
|
|
28
|
+
if (recall.request_digest && recall.request_digest !== selectionRequestDigest(input)) throw error("recall does not match selection requirements and slots");
|
|
26
29
|
const proposedModels = requireArray(proposal.selected_models, "selected_models");
|
|
27
30
|
const coverageLedger = requireArray(proposal.coverage_ledger, "coverage_ledger");
|
|
28
31
|
const remainingSlots = requireArray(proposal.remaining_slots, "remaining_slots");
|
|
@@ -71,7 +74,14 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
71
74
|
slot_id: selected.slot_id, component_id: selected.component_id,
|
|
72
75
|
});
|
|
73
76
|
}
|
|
77
|
+
const dimensionCheck = evaluateDimensions(candidate, input.requirements);
|
|
78
|
+
if (dimensionCheck.status === "incompatible") throw error("selected component is dimensionally incompatible; choose a surviving candidate", { slot_id: selected.slot_id, component_id: selected.component_id, dimension_check: dimensionCheck });
|
|
74
79
|
return {
|
|
80
|
+
name: String(candidate.title ?? candidate.name ?? candidate.model ?? candidate.component_id),
|
|
81
|
+
// This is a deterministic selection-line identity, NOT an upstream catalog BOM identity.
|
|
82
|
+
bom_item_id: `selection-line-${digest({ slot_id: selected.slot_id, component_id: candidate.component_id }).slice(0, 24)}`,
|
|
83
|
+
...dimensionFields(candidate), dimension_check: dimensionCheck,
|
|
84
|
+
...(candidate.interfaces !== undefined ? { interfaces: candidate.interfaces } : {}),
|
|
75
85
|
slot_id: selected.slot_id, component_id: selected.component_id, quantity: selected.quantity,
|
|
76
86
|
selection_reason: selected.selection_reason, wiki_evidence: selected.wiki_evidence,
|
|
77
87
|
...(selected.constraint_checks !== undefined ? { constraint_checks: selected.constraint_checks } : {}),
|
|
@@ -97,6 +107,7 @@ export async function finalizeHardwareSelection({ input }) {
|
|
|
97
107
|
revision: `r-${hash.slice(0, 12)}`,
|
|
98
108
|
plan_mode: proposal.plan_mode,
|
|
99
109
|
selected_models: selectedModels,
|
|
110
|
+
...(input.requirements?.limits?.some(row => row?.envelope) ? { occupancy: assemblyOccupancy(selectedModels, input.requirements, input.policy) } : {}),
|
|
100
111
|
coverage_ledger: coverageLedger,
|
|
101
112
|
remaining_slots: remainingSlots,
|
|
102
113
|
unknowns,
|
|
@@ -8,7 +8,7 @@ import { publishHardwareSearchIndex } from "./search-index-release.mjs";
|
|
|
8
8
|
function parseArgs(argv) {
|
|
9
9
|
const flags = {};
|
|
10
10
|
const booleans = new Set(["--dry-run", "--skip-smoke", "--bootstrap-manifest"]);
|
|
11
|
-
const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency"]);
|
|
11
|
+
const valued = new Set(["--source", "--output", "--release-id", "--env-file", "--concurrency", "--component-snapshot"]);
|
|
12
12
|
for (let i = 0; i < argv.length; i++) {
|
|
13
13
|
const name = argv[i];
|
|
14
14
|
if (booleans.has(name)) flags[name.slice(2)] = true;
|
|
@@ -30,6 +30,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
30
30
|
sourceDir: path.resolve(cwd, flags.source),
|
|
31
31
|
outputDir: flags.output ? path.resolve(cwd, flags.output) : undefined,
|
|
32
32
|
releaseId: flags["release-id"],
|
|
33
|
+
componentSnapshot: flags["component-snapshot"] ? path.resolve(cwd, flags["component-snapshot"]) : undefined,
|
|
33
34
|
env,
|
|
34
35
|
fetchImpl: deps.fetchImpl || fetch,
|
|
35
36
|
dryRun: Boolean(flags["dry-run"]),
|
|
@@ -69,6 +69,7 @@ export function validateLocalRelease(outputDir) {
|
|
|
69
69
|
if (index.source_manifest_sha256 !== catalog.source_manifest?.sha256) {
|
|
70
70
|
throw releaseError("INDEX_RELEASE_INVALID", `index Manifest digest mismatch: ${descriptor.index_key}`);
|
|
71
71
|
}
|
|
72
|
+
if (index.records.some(record => !/^[a-f0-9]{64}$/.test(record.sha256 ?? ""))) throw releaseError("INDEX_RELEASE_INVALID", "every Wiki needs a content SHA-256; rebuild legacy indexes");
|
|
72
73
|
documents += index.records.length;
|
|
73
74
|
files.push({ key: descriptor.index_key, file, bytes, sha256: descriptor.sha256 });
|
|
74
75
|
}
|
|
@@ -242,12 +243,12 @@ export async function smokePublishedIndex({ env, fetchImpl = fetch, categoryPath
|
|
|
242
243
|
};
|
|
243
244
|
}
|
|
244
245
|
|
|
245
|
-
export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
|
|
246
|
+
export async function publishHardwareSearchIndex({ sourceDir, outputDir, releaseId, componentSnapshot, env, fetchImpl = fetch, dryRun = false, smoke = true, concurrency, bootstrapManifest = false } = {}) {
|
|
246
247
|
if (!sourceDir) throw releaseError("USAGE", "sourceDir is required");
|
|
247
248
|
const temporary = !outputDir;
|
|
248
249
|
const output = outputDir ? path.resolve(outputDir) : fs.mkdtempSync(path.join(os.tmpdir(), "abelien-hardware-index-"));
|
|
249
250
|
try {
|
|
250
|
-
const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId });
|
|
251
|
+
const built = buildSearchIndex({ sourceDir, outputDir: output, releaseId, componentSnapshot });
|
|
251
252
|
const local = validateLocalRelease(output);
|
|
252
253
|
if (dryRun) return {
|
|
253
254
|
status: "validated",
|
|
@@ -262,6 +263,18 @@ export async function publishHardwareSearchIndex({ sourceDir, outputDir, release
|
|
|
262
263
|
};
|
|
263
264
|
|
|
264
265
|
const config = createTosConfig(env);
|
|
266
|
+
if (built.generated_source) {
|
|
267
|
+
const manifestBytes = fs.readFileSync(path.join(built.generated_source, "_manifest.json"));
|
|
268
|
+
const entries = JSON.parse(manifestBytes);
|
|
269
|
+
const limit = Number(concurrency || env?.TOS_PUBLISH_CONCURRENCY || 6);
|
|
270
|
+
await mapConcurrent(entries, Number.isFinite(limit) ? limit : 6, async entry => {
|
|
271
|
+
const bytes = fs.readFileSync(safeFile(built.generated_source, entry.path));
|
|
272
|
+
if (sha256(bytes) !== entry.sha256) throw releaseError("SOURCE_WIKI_MISMATCH", "generated Wiki changed after build");
|
|
273
|
+
await putImmutable(config, `${config.prefix}${entry.path}`, bytes, fetchImpl);
|
|
274
|
+
});
|
|
275
|
+
await putImmutable(config, `${config.prefix}${local.catalog.component_snapshot.object_key}`, fs.readFileSync(path.join(built.generated_source, "_component-dimensions.json")), fetchImpl);
|
|
276
|
+
await putImmutable(config, `${config.prefix}${local.catalog.source_manifest.object_key}`, manifestBytes, fetchImpl);
|
|
277
|
+
}
|
|
265
278
|
let manifest = await requireMatchingManifest(config, local.catalog, fetchImpl, { optional: true });
|
|
266
279
|
if (!manifest && !bootstrapManifest) {
|
|
267
280
|
throw releaseError("SOURCE_MANIFEST_MISSING", "TOS Wiki Manifest is missing; rerun with --bootstrap-manifest to verify every remote Wiki object before creating it", {
|