abelien-harness 0.3.0 → 0.5.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/.env.example +3 -0
- 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 +12 -2
- package/skills/abelien-hardware/domain.json +4 -4
- package/skills/abelien-hardware/references/search-index-format.md +41 -2
- package/skills/abelien-hardware/references/search-index-release.md +12 -1
- package/skills/abelien-hardware/references/selection-service-contract.md +3 -0
- package/skills/abelien-hardware/references/web-evidence-contract.md +2 -2
- 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/electrical.mjs +125 -0
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +15 -0
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +10 -6
- package/skills/abelien-hardware/scripts/publish-search-index.mjs +2 -1
- package/skills/abelien-hardware/scripts/search-index-release.mjs +45 -19
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +40 -20
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +6 -1
- package/skills/abelien-hardware/scripts/web-common.mjs +4 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +8 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +16 -1
- package/skills/abelien-hardware/scripts/web-search.mjs +4 -2
- package/skills/abelien-hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +32 -2
- package/skills/abelien-hardware-selection/scripts/select.mjs +35 -7
- 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/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
- 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/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +3 -3
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +3 -3
- package/skills/abelien-workspace/workflows/user-journey-hardware.json +3 -3
|
@@ -20,7 +20,7 @@ proposal 包含 status=verified|needs_confirmation、plan_mode=assemble_new|adap
|
|
|
20
20
|
- quantity 显式给出且等于槽位数量,不默认补 1;每槽位最多一个型号。
|
|
21
21
|
- selection_reason 非空;wiki_evidence 为完整 Wiki 中至少 8 字符的原文引用数组,不是 ID、ETag 或自由改写摘要。
|
|
22
22
|
- constraint_checks 逐项包含 constraint_id、status=verified|unknown|incompatible、reason、evidence:[{component_id,quote}]。ID 覆盖 function、slot:<hard_constraints 的键>、limit:<每项 hard limit.id>。缺关键证据仍 unknown。
|
|
23
|
-
-
|
|
23
|
+
- 多型号须为 power、interfaces 提供 compatibility_checks(dimension/status/reason/evidence,每个所选型号都有原文证据),除非可信电气数据已确定性通过;mechanical、thermal、software 可选填写,只作提示。见下文“电气兼容与兼容报告”。
|
|
24
24
|
|
|
25
25
|
constraint_checks 非数组、ID 重复或不属于当前槽位时仍以 VALIDATION 拒绝。error.message 列明 slot_id、expected(当前槽位的 function / slot 键与全局 hard limit ID)、received(完整原顺序,非数组为 null)、unexpected(去重、首次出现顺序)与 duplicate(去重、首次重复顺序),供既有 Web 预检 issues 原样反馈并修正。其他槽位具有某个 hard_constraints 键,不使它成为当前槽位的有效 ID;例如 desk-sensor 的 slot:no-stand 不能用于缺少该键的 weather-display。只缺少某项检查仍走 CONSTRAINT_UNVERIFIED、needs_confirmation,不因诊断改为结构拒绝;Wiki 归属、incompatible 和门禁语义不变。
|
|
26
26
|
|
|
@@ -28,7 +28,7 @@ constraint_checks 非数组、ID 重复或不属于当前槽位时仍以 VALIDAT
|
|
|
28
28
|
|
|
29
29
|
## 输出与门禁
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
无结构化 envelope 时保留 Web 既有十字段:id/status/source_snapshot/revision/plan_mode/selected_models/coverage_ledger/remaining_slots/unknowns/replacement_plan。ID/版本由输入和可信召回摘要确定。数量账本由代码计算,模型原始 coverage 和 compatibility 评估仍保留。型号标记 verification_scope=wiki_evidence_review_not_engineering_test、device_tested=false。
|
|
32
32
|
|
|
33
33
|
verified 只代表同源、完整数量和有归属证据的 Agent 选型判断通过,不代表 CAD、几何、电气实测、采用或交付。缺关键评估、非空 unknowns、未覆盖槽位均返回 needs_confirmation;机器门拒绝,但保留结果。整机继承/覆盖 manifest 求解尚未实现,adapt_system 始终待确认;不能借 assemble_new 自动抵扣其他槽位。换型仍走 hardware.revise,不重新排序未点名组件。
|
|
34
34
|
|
|
@@ -55,3 +55,33 @@ V2 hardware evidence uses fixed prismer_then_volc ordered primary/fallback recei
|
|
|
55
55
|
## 审计 v1 终态与受信 blob 读取
|
|
56
56
|
|
|
57
57
|
新运行开启 `AB_HARNESS_AUDIT_V1=true` 后,needs_confirmation 进入 selected_pending_confirmation,并以组装的 selectionId/selectionStatus/selectionRevision 结束 workflow。此状态仍待用户确认,不能当作 verified 或已采用;硬件/算法组合旅程跳过全部算法步骤。默认运行保留既有守卫。新引用证据的确定性重建需要 readResource 与受信 readBlob,可选 loopback HTTP wrapper 同样传入该端口;缺失时拒绝,不重新抓取来源。安装前无定义摘要和新运行标记的硬件运行保持只读,迁移边界见内核 workflow 规范。
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## 结构化尺寸与正式门禁
|
|
61
|
+
|
|
62
|
+
limits 可附 envelope;严格形状与来源规则见 [索引尺寸契约](../../abelien-hardware/references/search-index-format.md)。hardware.search 在分类所有索引记录上先筛尺寸再 Top-K,未知和估算保留。hardware.select 读取冻结 recall 并核对 request_digest、正文 SHA-256 后,复用纯判定器重算,忽略模型伪造的尺寸、来源、名称和 dimension_check。incompatible 以 VALIDATION 拒绝所选件,必须选其他存活候选;dimension_check.decision_required=true(用户 hard 上限+measured/datasheet 尺寸,且轴对齐装法全部失败,仍不足以证明放不下)时加入 DIMENSION_DECISION_REQUIRED 并返回 needs_confirmation,模型引用、确认或机械 verified 不能覆盖。unknown、估算尺寸、system/preference 上限或估算壁厚下的 risk 只随 selected_models.dimension_check 提示,不进入 unknowns,也不单独阻止 verified;尺寸限制仍须经原 constraint_checks 的 Wiki 证据门。
|
|
63
|
+
|
|
64
|
+
没有 envelope 时旧流程仍可在原 Wiki 证据范围 verified,但 selected_models.dimension_check 始终显式为 unknown(PRODUCT_ENVELOPE_MISSING),不能宣传尺寸检查通过。clear 也只表示单件粗筛;device_tested 仍 false。hardware.finalize 仍只返回 proposed/needs_confirmation 的提案,不创建硬件正式资源。
|
|
65
|
+
|
|
66
|
+
每个型号额外携带可信候选 title 派生的 name、dimensions_mm、dimension_basis、dimension_geometry、dimension_source 和 dimension_check;interfaces 只在候选存在时透传。bom_item_id 为 `selection-line-<24hex>`,以 slot_id/component_id 派生的稳定选型行身份,不冒称数据库 BOM 主键,不使用模型提供的 ID。
|
|
67
|
+
|
|
68
|
+
有 envelope 时输出增加可选 occupancy:advisory=true、method、estimated_volume_mm3、unknown_lines、containers(limit_id/cavity_volume_mm3/ratio)。只统计 selected_models 的实际数量,按 bom_item_id 去重,不再次加入 coverage_ledger 的整机内部覆盖。它是包围体积估算,非装箱证明;unknown 基础的占位尺寸不计入体积,仅保留 unknown_lines;全未知时不输出容器密度。部分未知时体积/比例只含已知或显式估算行,不代表整体密度。浮点溢出/下溢等不可表示的几何量保持 unknown,无法表示的体积或比例省略,不序列化 NaN/Infinity;没有隐含硬阈值。工具函数只在调用方显式提供 occupancy_warning_ratio 策略时给 warning,默认没有告警阈值。接入方须接受这一可选加法,但不得将 occupancy 或 ratio 作为通过/拒绝权威。
|
|
69
|
+
|
|
70
|
+
## 电气兼容与兼容报告
|
|
71
|
+
|
|
72
|
+
多型号选型由 `electrical.mjs` 按星形拓扑做确定性电气检查:选中的唯一主控(`compute-control/controller-board|controller-module`)为中心,`power-energy` 件给主控供电,主控电压轨与已选电源给外设供电,外设所需总线由主控提供。没有主控或主控不止一个时拓扑记为 not_star,全部未知。
|
|
73
|
+
|
|
74
|
+
只有 `electrical.basis` 为 measured / datasheet、且通过 `electrical_source` 源绑定的记录能给出确定结论:
|
|
75
|
+
|
|
76
|
+
| 结果 | 含义 | 门禁 |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| pass | 全部相关件可信且规则全部通过 | power / interfaces 视为已验证,不再要求逐件原文引用 |
|
|
79
|
+
| fail | 总线类型缺失或数量不足(I2C / SPI / CAN 是共享总线,主控有即可;UART / USB / I2S / PWM / GPIO / ADC 按数量累加) | finalize 与 select 以 VALIDATION 拒绝,须替换一侧 |
|
|
80
|
+
| gap | 可信数据下没有电压轨落在外设或主控的输入范围内 | 记 `ELECTRICAL_SUPPLY_GAP`,返回 needs_confirmation;须补电源或稳压件,不拒绝外设 |
|
|
81
|
+
| risk | 电压只部分重叠、连接器不同、逻辑电平不同且未选电平转换件 | 不确定性判定,回到原文引用路径 |
|
|
82
|
+
| unknown | 数据缺失、未源绑定或非星形 | 回到原文引用路径 |
|
|
83
|
+
|
|
84
|
+
mechanical、thermal、software 只作提示,不进入 unknowns,也不阻止 verified;结构可行性由尺寸粗筛和 CAD 检查负责。任何维度只要模型明确给出 status=incompatible,都记 `COMPATIBILITY_INCOMPATIBLE` 并阻塞。
|
|
85
|
+
|
|
86
|
+
多型号时输出增加可选根字段 `compatibility_report`:`method`、`dimensions`(每维 gate=deterministic|evidence|unverified|advisory、model_status、model_verified,power / interfaces 另有 electrical 结论)和 `electrical`(拓扑、主控与逐条检查)。它是选型依据的说明,不代表实测或制造可行;接入方须显式接收这一可选字段。所选型号原样携带候选的 `electrical` 与 `electrical_source`,不采用模型自填。
|
|
87
|
+
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Shared local/HTTP contract. Quotes verify attribution, not engineering test results.
|
|
2
|
+
import { evaluateDimensions, validateRequirementEnvelopes } from "../../abelien-hardware/scripts/dimensions.mjs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
2
4
|
import { mergeHardwareWeb } from "../../abelien-hardware/scripts/web-merge.mjs";
|
|
3
5
|
import { finalizeHardwareSelection } from "../../abelien-hardware/scripts/finalize-selection.mjs";
|
|
6
|
+
import { evaluateElectrical } from "../../abelien-hardware/scripts/electrical.mjs";
|
|
4
7
|
import { canonical, digest, selectionRequestDigest, validateSelectionSlots } from "../../abelien-hardware/scripts/selection-request.mjs";
|
|
5
8
|
const record = value => value && typeof value === "object" && !Array.isArray(value);
|
|
6
9
|
const text = value => typeof value === "string" && value.trim().length > 0;
|
|
@@ -26,6 +29,7 @@ export async function selectHardware({ input, readResource, readBlob }) {
|
|
|
26
29
|
|| !Array.isArray(requirements.limits)
|
|
27
30
|
|| requirements.limits.some(item => !requirement(item) || !["hard", "preference"].includes(item.strength)))
|
|
28
31
|
invalid("clarified requirements need attributed context, reaction and limits; limit strength must be hard or preference");
|
|
32
|
+
validateRequirementEnvelopes(requirements);
|
|
29
33
|
const entries = [...requirements.reaction, ...requirements.limits];
|
|
30
34
|
if (new Set(entries.map(item => item.id)).size !== entries.length)
|
|
31
35
|
invalid("reaction and limit IDs must be unique across requirements");
|
|
@@ -53,6 +57,12 @@ export async function selectHardware({ input, readResource, readBlob }) {
|
|
|
53
57
|
const candidate = recall.results.find(item => item.slot_id === slot.slot_id)?.candidates?.find(item => item.component_id === model.component_id);
|
|
54
58
|
if (!candidate || canonical(candidate.category_path) !== canonical(slot.category_path)
|
|
55
59
|
|| !text(candidate.content) || !/^[a-f0-9]{64}$/.test(candidate.wiki?.sha256 ?? "")) invalid("candidate needs complete matching Wiki provenance");
|
|
60
|
+
if (createHash("sha256").update(candidate.content).digest("hex") !== candidate.wiki.sha256) invalid("candidate content does not match frozen Wiki SHA-256");
|
|
61
|
+
const dimensionCheck = evaluateDimensions(candidate, requirements);
|
|
62
|
+
model.dimension_check = dimensionCheck;
|
|
63
|
+
if (dimensionCheck.status === "incompatible") invalid("selected candidate is dimensionally incompatible");
|
|
64
|
+
// Only a user hard limit with trusted sizes asks the user to decide; unknown and other risk stay advisory on dimension_check.
|
|
65
|
+
if (dimensionCheck.decision_required) add("DIMENSION_DECISION_REQUIRED", { slot_id: slot.slot_id, component_id: candidate.component_id, dimension_check: dimensionCheck });
|
|
56
66
|
if (!text(model.selection_reason)) invalid("selection_reason is required");
|
|
57
67
|
const unmatched = model.wiki_evidence.filter(quote => !text(quote) || quote.trim().length < 8 || !candidate.content.includes(quote));
|
|
58
68
|
if (unmatched.length) add("WIKI_EVIDENCE_UNVERIFIED", { slot_id: slot.slot_id,
|
|
@@ -92,12 +102,29 @@ export async function selectHardware({ input, readResource, readBlob }) {
|
|
|
92
102
|
if (recall.retrieval_mode !== "category_index" || recall.unknowns?.length) add("RECALL_INCOMPLETE", { details: recall.unknowns ?? [] });
|
|
93
103
|
const compatibility = proposal.compatibility_checks ?? [];
|
|
94
104
|
if (!Array.isArray(compatibility)) invalid("compatibility_checks must be an array");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
// Power/interfaces: trusted deterministic pass, or quoted model evidence. Mechanical/thermal/software are advisory
|
|
106
|
+
// (size screening and CAD own mechanical fit); an explicit model "incompatible" still blocks every dimension.
|
|
107
|
+
let compatibilityReport;
|
|
108
|
+
if (models.size > 1) {
|
|
109
|
+
const electrical = evaluateElectrical(normalized.selected_models);
|
|
110
|
+
if (electrical.checks.some(check => check.status === "fail")) invalid("selected components are electrically incompatible");
|
|
111
|
+
const gaps = electrical.checks.filter(check => check.status === "gap");
|
|
112
|
+
if (gaps.length) add("ELECTRICAL_SUPPLY_GAP", { checks: gaps });
|
|
113
|
+
const dimensions = {};
|
|
114
|
+
for (const dimension of ["power", "interfaces", "mechanical", "thermal", "software"]) {
|
|
115
|
+
const checks = compatibility.filter(item => item?.dimension === dimension);
|
|
116
|
+
const modelVerified = checks.length === 1 && checks[0].status === "verified" && text(checks[0].reason)
|
|
117
|
+
&& validQuotes(checks[0].evidence, candidates)
|
|
118
|
+
&& candidates.every(candidate => checks[0].evidence.some(item => item.component_id === candidate.component_id));
|
|
119
|
+
const deterministic = electrical.dimensions[dimension];
|
|
120
|
+
const gate = ["mechanical", "thermal", "software"].includes(dimension) ? "advisory"
|
|
121
|
+
: deterministic === "pass" ? "deterministic" : modelVerified ? "evidence" : "unverified";
|
|
122
|
+
if (gate === "unverified") add("COMPATIBILITY_UNVERIFIED", { dimension, assessments: checks, electrical: deterministic });
|
|
123
|
+
if (checks.some(item => item?.status === "incompatible")) add("COMPATIBILITY_INCOMPATIBLE", { dimension, assessments: checks });
|
|
124
|
+
dimensions[dimension] = { gate, model_status: checks.length === 1 ? String(checks[0].status) : checks.length ? "ambiguous" : "missing",
|
|
125
|
+
model_verified: modelVerified, ...(deterministic !== undefined ? { electrical: deterministic } : {}) };
|
|
126
|
+
}
|
|
127
|
+
compatibilityReport = { method: "compatibility-gate-v1", dimensions, electrical };
|
|
101
128
|
}
|
|
102
129
|
const coverage = slots.map(slot => ({ slot_id: slot.slot_id, requested_quantity: slot.quantity,
|
|
103
130
|
selected_quantity: models.get(slot.slot_id)?.quantity ?? 0,
|
|
@@ -114,6 +141,7 @@ export async function selectHardware({ input, readResource, readBlob }) {
|
|
|
114
141
|
await checkMergedSource();
|
|
115
142
|
const hash = digest({ input, source_digest: digest(source.body) });
|
|
116
143
|
return { ...normalized, id: `selection-${hash.slice(0, 16)}`, revision: `r-${hash.slice(0, 12)}`,
|
|
117
|
-
status, coverage_ledger: coverage, remaining_slots: remaining, unknowns
|
|
144
|
+
status, coverage_ledger: coverage, remaining_slots: remaining, unknowns,
|
|
145
|
+
...(compatibilityReport ? { compatibility_report: compatibilityReport } : {}) };
|
|
118
146
|
}
|
|
119
147
|
export const run = selectHardware;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
create 输入为 `schemaVersion:1, creationOperationId, inputRevision:0, manifestDigest, executionDefinitionVersion, executionDefinitionDigest, executionPath`;不接收任意来源正文或 provider/account/policy 覆盖。必须具有 `studio.create` 和 `tools:invoke` 委托。
|
|
7
7
|
`creation active|get` 仅需 `state:read`,不借只读恢复索取工具或写入权限;创建、工具登记与调用提交仍须原有写权限,replacement request 仍须 `requests:write`。
|
|
8
8
|
返回完整安全投影:保留 frozenInputs、plannedOccurrenceBindings、executionSources、进度与所有资产 Ref,不将派生外观来源丢弃。
|
|
9
|
-
`tool register` 仅接受 creationOperationId/modificationOperationId 之一。CLI
|
|
9
|
+
`tool register` 仅接受 creationOperationId/modificationOperationId 之一。CLI 默认受信上下文路径见下文「Studio broker 调用」;模块的 contextFile 依赖注入只供受信宿主使用,不向模型开放路径、origin 或 token。
|
|
10
10
|
本地宿主必须保持原 Agent 租约,先将真实已结算 receipt 交 Harness consume/get 校验,再持久化消费确认;回执保存不等于采用、workflow done 或自动恢复。unknown 不重新生成。
|
|
11
11
|
|
|
12
12
|
## 定义文件(域包 `workflows/<name>.json`,name 必须等于文件名)
|
|
@@ -107,12 +107,19 @@ ab studio invocation get <invocationId>
|
|
|
107
107
|
以同 workflow identity 和根预算创建后继 attempt。已有点选必须与原 designContext 一致;纯文字目标
|
|
108
108
|
仍须由 Web 在当前 F2 editableTargets 唯一验证。CLI 不接受自由文字直接扩大 target 或 impact。
|
|
109
109
|
|
|
110
|
-
CLI
|
|
110
|
+
CLI 默认只读固定 `/home/agent/workspace/abelien-runtime-delegation/current.json`,与 Web EaaS driver
|
|
111
|
+
的 file-API 挂载根一致;不搜索旧 `/tmp` 路径,也不提供 CLI/环境变量/模型 context、origin 或 token override。
|
|
112
|
+
受信本地宿主仍可通过模块级 `studioCmd(...,{contextFile})` 注入隔离上下文;它不构成容器默认路径验收。
|
|
111
113
|
宿主 context 必须是当前进程所有、普通文件、0600、至多 16 KiB,且 draft、logical environment、
|
|
112
114
|
workflow、attempt、local run、generation、credential epoch、attempt fence、definition、action scope、
|
|
113
115
|
签发/过期时间须与 token claims 全量一致。Web origin 只能是纯 HTTPS origin(测试 loopback 可 HTTP)。
|
|
114
116
|
请求固定带 protocol v1、Bearer 且不带 Cookie;JSON 上限 256 KiB,响应上限 1 MiB。
|
|
115
117
|
|
|
118
|
+
G02 回归使用独立文件夹具执行默认 `readStudioContext()` 和未注入 `contextFile` 的 `studioCmd()`,
|
|
119
|
+
同时检查旧路径不回退、宿主注入与安全边界。它只证明本地源码行为;SR-SPEC-09-01 仍须发布包与
|
|
120
|
+
真实 driver 写入的容器上下文配合裸 CLI 验收,并核对安装器、默认 CLI、driver、probe、receipt launcher
|
|
121
|
+
使用同一权威路径。源码修正不代表既有安装包已更新。
|
|
122
|
+
|
|
116
123
|
create/register/submit 遇到传输未知只以相同字节尝试一次;再次未知返回 `STUDIO_WRITE_UNKNOWN`,
|
|
117
124
|
不得换 modificationOperationId、commandId、operationId 或 invocationId。读取可有界退避。Agent 输出
|
|
118
125
|
不包含 token,也不返回 provider/policy/account/price。普通首轮 production EaaS 尚无 attempt
|
|
@@ -208,3 +215,9 @@ advance、外部 resume 和 CLI status --httl 按记录摘要解析。依次尝
|
|
|
208
215
|
同事件的状态机分支必须有可证明互斥的守卫(至少一个同名键的精确值不同)。可选 enabledBy 指定宿主环境变量,值必须为字符串 true;运行时分支歧义返回 DOMAIN_INVALID,守卫不满足仍为 GUARD_FAILED。verified 分支保持原守卫。
|
|
209
216
|
|
|
210
217
|
两份新硬件定义声明 `legacyResume:read_only`:旧运行缺少新协议或摘要时,advance/resume/HTTL 返回带迁移说明的 WORKFLOW_DRIFTED,status/list 保留原记录。旧 pinned 定义也不能冒充新协议。新协议运行可按已记录 SHA 恢复,后续改开关不影响它;缺失或损坏摘要不能降级。其他无摘要 workflow 保留原步序列守卫。包内历史定义为审计和已支持的原协议恢复保留,不给旧选型补造摘要。package.json 和正式 CHANGELOG 版本由后续 release 维护,本 MR 不发布。
|
|
218
|
+
|
|
219
|
+
### 局部外观与工程回执
|
|
220
|
+
|
|
221
|
+
传输词表增加 `mesh.generate@1` 与 `cad.build@2`(purpose=appearance_adaptation),其余工具仍仅支持@1;实际步骤准入、完整 DTO 与既有 definition digest 由 Web broker 维护。creation v3 及旧 v1/v2 只按原 persisted identity 回读,不自动迁移。creation 状态允许 latestTool、cadBuildAttempts、previewCandidateId,与既有冻结来源并存。
|
|
222
|
+
|
|
223
|
+
receipt v1 增加 appearance.revision、mesh.generate、structure.adapt;这三步均必须携不可变 parentCandidateId,保持严格 creation/modification XOR,不能混入 repairBasis/repairOrdinal。EaaS waitIdentityVerified 另需核对 definitionDigest、stepKey 和 parentCandidateId;local checkpoint 仍交受信宿主判断,不代造已恢复或已采用。mesh 结果仅预览,structure.adapt 结果须同版工程和视觉检查后走既有采用事务。
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Trusted host library only. F4 remains authoritative for authorization, lease,
|
|
2
|
+
// accounting and adoption. This module binds that invocation to one provider send.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { canonicalJson, createStore, fail, sha256Hex } from './ab.mjs';
|
|
6
|
+
import {
|
|
7
|
+
assertVerified, effectiveSubmit, loadProfiles, resolveCredential, submitFlow,
|
|
8
|
+
statusFlow, artifactLocation, downloadArtifact, validateGlb,
|
|
9
|
+
} from './generate3d.mjs';
|
|
10
|
+
|
|
11
|
+
export const HOSTED_GENERATION_PROTOCOL = 1;
|
|
12
|
+
export const HOSTED_GENERATION_AUTHORITY_PROTOCOL = 1;
|
|
13
|
+
export const HOSTED_GENERATION_QUALIFICATION_PROTOCOL = 1;
|
|
14
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
15
|
+
const DEFAULT_MAX_ARTIFACT_BYTES = 128 * 1024 * 1024;
|
|
16
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
17
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
18
|
+
const SAFE_ERROR_CODES = new Set([
|
|
19
|
+
'UPLOAD_FAILED', 'HTTP_FAILED', 'HTTP_STATUS', 'TASK_FAILED', 'TASK_PENDING',
|
|
20
|
+
'CREDENTIAL_MISSING', 'DOWNLOAD_FAILED', 'ZIP_INVALID', 'GLB_INVALID',
|
|
21
|
+
'GENERATION_ABORTED', 'GENERATION_PROFILE_DRIFT', 'ARTIFACT_HASH_MISMATCH',
|
|
22
|
+
'GENERATION_AUTHORITY_STALE',
|
|
23
|
+
'GENERATION_QUALIFICATION_INVALID',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function cleanError(error) {
|
|
27
|
+
const code = SAFE_ERROR_CODES.has(error?.code) ? error.code : 'GENERATION_IO_FAILED';
|
|
28
|
+
return fail(code, `Hosted generation failed: ${code}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function diagnostic(deps, phase, identity, error) {
|
|
32
|
+
if (typeof deps.onDiagnostic !== 'function') return;
|
|
33
|
+
const redact = value => {
|
|
34
|
+
let text = String(value ?? '').slice(0, 32_768);
|
|
35
|
+
for (const secret of Object.values(deps.env ?? {})) {
|
|
36
|
+
if (typeof secret === 'string' && secret.length >= 6) text = text.split(secret).join('[redacted]');
|
|
37
|
+
}
|
|
38
|
+
return text.replace(/https?:\/\/[^\s"'<>]+/gi, '[redacted-url]')
|
|
39
|
+
.replace(/Bearer\s+[^\s"',}]+/gi, 'Bearer [redacted]');
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
const event = {phase, requestId: identity.requestId, inputDigest: identity.inputDigest,
|
|
43
|
+
error: {code: cleanError(error).code, message: redact(error?.message),
|
|
44
|
+
stack: redact(error?.stack), details: redact(JSON.stringify(error?.details ?? null))}};
|
|
45
|
+
// Private trusted observer only. Public results and gen3d resources remain code-only.
|
|
46
|
+
const result = deps.onDiagnostic(event);
|
|
47
|
+
result?.catch?.(() => {});
|
|
48
|
+
} catch { /* Observability failure cannot change paid execution/recovery. */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hostDeps(deps) {
|
|
52
|
+
if (!deps || typeof deps.workdir !== 'string' || !path.isAbsolute(deps.workdir)) {
|
|
53
|
+
throw fail('GENERATION_INPUT_INVALID', 'Host workdir must be an absolute private invocation directory');
|
|
54
|
+
}
|
|
55
|
+
const maxArtifactBytes = deps.maxArtifactBytes ?? DEFAULT_MAX_ARTIFACT_BYTES;
|
|
56
|
+
if (!Number.isSafeInteger(maxArtifactBytes) || maxArtifactBytes < 1 || maxArtifactBytes > DEFAULT_MAX_ARTIFACT_BYTES) {
|
|
57
|
+
throw fail('GENERATION_INPUT_INVALID', 'Invalid artifact byte limit');
|
|
58
|
+
}
|
|
59
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
60
|
+
return {
|
|
61
|
+
workdir: deps.workdir, env: deps.env ?? {}, credentialPolicy: 'env-only',
|
|
62
|
+
requireGlb: true, maxArtifactBytes, signal: deps.signal,
|
|
63
|
+
fetchImpl: async (url, init = {}) => {
|
|
64
|
+
if (deps.signal?.aborted) throw fail('GENERATION_ABORTED', 'Generation interrupted');
|
|
65
|
+
// The host must apply its outbound origin/DNS policy in fetchImpl.
|
|
66
|
+
// No credential-bearing redirects, including signed artifact downloads.
|
|
67
|
+
return fetchImpl(url, {...init, redirect: 'error'});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Offline profile information, not a price quote or an authorization. */
|
|
73
|
+
function modelInfo(profiles, {provider, model: modelId}) {
|
|
74
|
+
const profile = profiles[provider];
|
|
75
|
+
if (!profile) throw fail('PROVIDER_NOT_FOUND', 'Provider not found');
|
|
76
|
+
const model = profile.models[modelId];
|
|
77
|
+
if (!model || model.available === false) throw fail('MODEL_NOT_FOUND', 'Model not available');
|
|
78
|
+
const configuration = {
|
|
79
|
+
provider, model: modelId, transport: profile.transport,
|
|
80
|
+
input: model.input, imageEncoding: profile.imageEncoding,
|
|
81
|
+
images: profile.images ?? null, upload: profile.upload ?? null,
|
|
82
|
+
submit: effectiveSubmit(profile, model), poll: model.poll ?? profile.poll,
|
|
83
|
+
download: profile.download,
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
provider, model: modelId, input: model.input,
|
|
87
|
+
verified: model.verified ?? profile.verified,
|
|
88
|
+
estCostUsd: model.benchmark?.usd ?? null, historicalEstimate: true,
|
|
89
|
+
profileDigest: sha256Hex(canonicalJson(configuration)), providerIdempotency: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function inspectHostedModel(deps, identity) {
|
|
94
|
+
return modelInfo(loadProfiles(hostDeps(deps)), identity);
|
|
95
|
+
}
|
|
96
|
+
function freezeSnapshot(value) {
|
|
97
|
+
if (value && typeof value === 'object') {
|
|
98
|
+
for (const child of Object.values(value)) freezeSnapshot(child);
|
|
99
|
+
Object.freeze(value);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// An operator qualification is a trusted module dependency, never a model input,
|
|
105
|
+
// generic CLI flag or environment bypass. The controller verifies the immutable
|
|
106
|
+
// loaded release and supplies its digest independently of the manifest. Existing
|
|
107
|
+
// request claims remain the sole send ledger; this admits exactly that request.
|
|
108
|
+
function qualificationAdmission(deps, request, model, images) {
|
|
109
|
+
if (deps.qualification === undefined) return null;
|
|
110
|
+
const manifest = deps.qualification?.manifest;
|
|
111
|
+
const keys = ['schemaVersion', 'purpose', 'requestId', 'inputDigest', 'provider', 'model',
|
|
112
|
+
'profileDigest', 'imageSha256', 'releaseDigest', 'expiresAt', 'maxCreates', 'maxCredits'];
|
|
113
|
+
const invalid = () => { throw fail('GENERATION_QUALIFICATION_INVALID', 'Exact trusted qualification admission is required'); };
|
|
114
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
|
|
115
|
+
|| Object.keys(manifest).sort().join('|') !== keys.sort().join('|')) invalid();
|
|
116
|
+
if (manifest.schemaVersion !== 1 || manifest.purpose !== 'provider_qualification'
|
|
117
|
+
|| manifest.requestId !== request.requestId || manifest.inputDigest !== request.inputDigest
|
|
118
|
+
|| manifest.provider !== model.provider || manifest.model !== model.model
|
|
119
|
+
|| manifest.profileDigest !== model.profileDigest || !HASH.test(manifest.releaseDigest ?? '')
|
|
120
|
+
|| manifest.releaseDigest !== deps.qualification.releaseDigest
|
|
121
|
+
|| !Array.isArray(manifest.imageSha256)
|
|
122
|
+
|| canonicalJson(manifest.imageSha256) !== canonicalJson(images.map(image => image.sha256))
|
|
123
|
+
|| manifest.maxCreates !== 1 || !Number.isSafeInteger(manifest.maxCredits) || manifest.maxCredits <= 0) invalid();
|
|
124
|
+
const expires = Date.parse(manifest.expiresAt);
|
|
125
|
+
if (!Number.isFinite(expires) || new Date(expires).toISOString() !== manifest.expiresAt
|
|
126
|
+
|| expires <= (deps.now?.() ?? Date.now())) invalid();
|
|
127
|
+
const snapshot = freezeSnapshot(JSON.parse(JSON.stringify(manifest)));
|
|
128
|
+
return {manifest: snapshot, manifestDigest: sha256Hex(canonicalJson(snapshot))};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function identityPaths(deps, {requestId, inputDigest}) {
|
|
132
|
+
if (typeof requestId !== 'string' || !requestId.trim() || requestId.length > 512 ||
|
|
133
|
+
typeof inputDigest !== 'string' || !HASH.test(inputDigest)) {
|
|
134
|
+
throw fail('GENERATION_INPUT_INVALID', 'An exact invocation requestId and SHA-256 inputDigest are required');
|
|
135
|
+
}
|
|
136
|
+
const key = sha256Hex(requestId);
|
|
137
|
+
const localId = `g3d-host-${key}`;
|
|
138
|
+
return {
|
|
139
|
+
requestId, inputDigest, localId, id: `gen3d:${localId}`,
|
|
140
|
+
claim: path.join(deps.workdir, '.abelien', 'gen3d-send-claims', `${key}.json`),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readClaim(paths) {
|
|
145
|
+
try {
|
|
146
|
+
const claim = JSON.parse(fs.readFileSync(paths.claim, 'utf8'));
|
|
147
|
+
if (claim.hosted?.requestId !== paths.requestId || claim.hosted.inputDigest !== paths.inputDigest) {
|
|
148
|
+
throw fail('GENERATION_IDENTITY_CONFLICT', 'Invocation identity does not match the original request');
|
|
149
|
+
}
|
|
150
|
+
if (claim.hosted.protocol !== HOSTED_GENERATION_PROTOCOL || !HASH.test(claim.hosted.requestDigest)) {
|
|
151
|
+
throw fail('GENERATION_STATE_UNREADABLE', 'Submission claim cannot be verified; do not resubmit');
|
|
152
|
+
}
|
|
153
|
+
return claim;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error?.code === 'ENOENT') return null;
|
|
156
|
+
if (error?.code?.startsWith('GENERATION_')) throw error;
|
|
157
|
+
throw fail('GENERATION_STATE_UNREADABLE', 'Submission claim cannot be read; do not resubmit');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readBody(deps, paths, claim) {
|
|
162
|
+
if (!claim) return null;
|
|
163
|
+
const index = path.join(deps.workdir, '.abelien', 'store', 'index.json');
|
|
164
|
+
const body = fs.existsSync(index) ? createStore(deps.workdir).get(paths.id)?.body : null;
|
|
165
|
+
if (body && (body.hosted?.requestDigest !== claim.hosted.requestDigest ||
|
|
166
|
+
body.hosted?.inputDigest !== paths.inputDigest || body.hosted?.requestId !== paths.requestId)) {
|
|
167
|
+
throw fail('GENERATION_IDENTITY_CONFLICT', 'Stored resource does not match submission claim');
|
|
168
|
+
}
|
|
169
|
+
// A crash after exclusive claim and before store persistence is ambiguous. The
|
|
170
|
+
// immutable claim is sufficient to prohibit another send, never to prove success.
|
|
171
|
+
return body ?? claim;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function publicResult(paths, body) {
|
|
175
|
+
const status = body.status;
|
|
176
|
+
return {
|
|
177
|
+
id: paths.id, requestId: paths.requestId, inputDigest: paths.inputDigest,
|
|
178
|
+
requestDigest: body.hosted.requestDigest, profileDigest: body.hosted.profileDigest,
|
|
179
|
+
provider: body.provider, model: body.model, status,
|
|
180
|
+
...(body.providerTaskId ? {providerTaskId: body.providerTaskId} : {}),
|
|
181
|
+
recovery: body.providerTaskId ? 'poll' : status === 'failed' ? 'none' : 'unknown',
|
|
182
|
+
providerIdempotency: false,
|
|
183
|
+
...(body.errorCode ? {errorCode: body.errorCode} : {}),
|
|
184
|
+
...(body.artifact ? {artifact: body.artifact} : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** No provider call and no credential read. Null means never claimed here. */
|
|
189
|
+
export function lookupHostedGeneration(deps, identity) {
|
|
190
|
+
const trusted = hostDeps(deps), paths = identityPaths(trusted, identity);
|
|
191
|
+
const body = readBody(trusted, paths, readClaim(paths));
|
|
192
|
+
return body ? publicResult(paths, body) : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function claimExclusive(file, value) {
|
|
196
|
+
fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o700});
|
|
197
|
+
const tmpDir = fs.mkdtempSync(path.join(path.dirname(file), '.claim-'));
|
|
198
|
+
const tmp = path.join(tmpDir, 'claim.json');
|
|
199
|
+
try {
|
|
200
|
+
const fd = fs.openSync(tmp, 'wx', 0o600);
|
|
201
|
+
try { fs.writeFileSync(fd, canonicalJson(value)); fs.fsyncSync(fd); }
|
|
202
|
+
finally { fs.closeSync(fd); }
|
|
203
|
+
// Atomic no-replace publication: readers never observe a half-written claim.
|
|
204
|
+
try { fs.linkSync(tmp, file); }
|
|
205
|
+
catch (error) { if (error.code === 'EEXIST') return false; throw error; }
|
|
206
|
+
const dir = fs.openSync(path.dirname(file), 'r');
|
|
207
|
+
try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); }
|
|
208
|
+
return true;
|
|
209
|
+
} finally { fs.rmSync(tmpDir, {recursive: true, force: true}); }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Exactly one paid send per requestId. Calling again only reads the original result. */
|
|
213
|
+
export async function submitHostedGeneration(deps, request) {
|
|
214
|
+
const trusted = hostDeps(deps), paths = identityPaths(trusted, request);
|
|
215
|
+
const oldClaim = readClaim(paths);
|
|
216
|
+
if (oldClaim && (oldClaim.provider !== request.provider || oldClaim.model !== request.model)) {
|
|
217
|
+
throw fail('GENERATION_IDENTITY_CONFLICT', 'Provider/model changed for the same invocation');
|
|
218
|
+
}
|
|
219
|
+
trusted.profileSnapshot = freezeSnapshot(loadProfiles(trusted));
|
|
220
|
+
const model = modelInfo(trusted.profileSnapshot, request);
|
|
221
|
+
if (!Array.isArray(request.images) || request.images.length !== model.input.count) {
|
|
222
|
+
throw fail('IMAGE_COUNT', 'Image count does not match the selected model');
|
|
223
|
+
}
|
|
224
|
+
const prompt = request.prompt;
|
|
225
|
+
if (prompt !== undefined && typeof prompt !== 'string') throw fail('GENERATION_INPUT_INVALID', 'Prompt must be a string');
|
|
226
|
+
const requestDigestOf = sources => sha256Hex(canonicalJson({
|
|
227
|
+
requestId: paths.requestId, inputDigest: paths.inputDigest,
|
|
228
|
+
provider: request.provider, model: request.model, profileDigest: model.profileDigest,
|
|
229
|
+
images: sources.map(source => ({view: source.view, sha256: source.sha256, bytes: source.bytes})),
|
|
230
|
+
promptDigest: prompt === undefined ? null : sha256Hex(prompt),
|
|
231
|
+
}));
|
|
232
|
+
if (oldClaim) {
|
|
233
|
+
// Repeat submit: the immutable claim already records each source digest and byte
|
|
234
|
+
// length, so the original request digest is recomputable without re-reading the
|
|
235
|
+
// source files. Recovery must not depend on the host still holding them, and
|
|
236
|
+
// nothing on this path can dispatch a second paid send.
|
|
237
|
+
const recorded = Array.isArray(oldClaim.images) ? oldClaim.images : null;
|
|
238
|
+
const replayed = recorded !== null && recorded.length === request.images.length
|
|
239
|
+
&& recorded.every((source, index) => source?.view === model.input.views[index]
|
|
240
|
+
&& source?.sha256 === request.images[index]?.sha256
|
|
241
|
+
&& Number.isSafeInteger(source?.bytes) && source.bytes >= 0);
|
|
242
|
+
if (!replayed || requestDigestOf(recorded) !== oldClaim.hosted.requestDigest) {
|
|
243
|
+
throw fail('GENERATION_IDENTITY_CONFLICT', 'Request content changed');
|
|
244
|
+
}
|
|
245
|
+
return publicResult(paths, readBody(trusted, paths, oldClaim));
|
|
246
|
+
}
|
|
247
|
+
const images = request.images.map((image, index) => {
|
|
248
|
+
if (!image || typeof image.path !== 'string' || !path.isAbsolute(image.path) ||
|
|
249
|
+
!HASH.test(image.sha256 ?? '') || !IMAGE_EXTENSIONS.has(path.extname(image.path).toLowerCase())) {
|
|
250
|
+
throw fail('GENERATION_INPUT_INVALID', 'Images require private absolute paths and SHA-256 hashes');
|
|
251
|
+
}
|
|
252
|
+
let bytes;
|
|
253
|
+
try {
|
|
254
|
+
if (fs.statSync(image.path).size > MAX_IMAGE_BYTES) throw Error('too large');
|
|
255
|
+
bytes = fs.readFileSync(image.path);
|
|
256
|
+
} catch { throw fail('IMAGE_READ', 'Image could not be read within the size limit'); }
|
|
257
|
+
if (bytes.length > MAX_IMAGE_BYTES) throw fail('IMAGE_READ', 'Image exceeds byte limit');
|
|
258
|
+
if (sha256Hex(bytes) !== image.sha256) throw fail('IMAGE_HASH_MISMATCH', 'Source image changed');
|
|
259
|
+
return {bytes, sha256: image.sha256, view: model.input.views[index], extension: path.extname(image.path).toLowerCase()};
|
|
260
|
+
});
|
|
261
|
+
const requestDigest = requestDigestOf(images.map(image => ({view: image.view, sha256: image.sha256, bytes: image.bytes.length})));
|
|
262
|
+
if (request.confirmSpend !== true) throw fail('SPEND_CONFIRM_REQUIRED', 'Host must confirm existing spend authorization');
|
|
263
|
+
if (typeof deps.beforePaidSubmit !== 'function') throw fail('GENERATION_AUTHORITY_REQUIRED', 'Trusted final paid authority callback is required');
|
|
264
|
+
const qualification = qualificationAdmission(deps, request, model, images);
|
|
265
|
+
if (!qualification) assertVerified({...model, name: model.provider}, false, `model ${model.provider}/${model.model}`);
|
|
266
|
+
// Never mutate the installed catalog or borrow its provider-level verification.
|
|
267
|
+
// Only this frozen engine call receives the operator's exact admission.
|
|
268
|
+
const submitProfileSnapshot = qualification ? freezeSnapshot({
|
|
269
|
+
...trusted.profileSnapshot,
|
|
270
|
+
[request.provider]: {...trusted.profileSnapshot[request.provider], models: {
|
|
271
|
+
...trusted.profileSnapshot[request.provider].models,
|
|
272
|
+
[request.model]: {...trusted.profileSnapshot[request.provider].models[request.model], verified: true},
|
|
273
|
+
}},
|
|
274
|
+
}) : trusted.profileSnapshot;
|
|
275
|
+
if (deps.signal?.aborted) throw fail('GENERATION_ABORTED', 'Generation interrupted before dispatch');
|
|
276
|
+
const profile = trusted.profileSnapshot[request.provider];
|
|
277
|
+
if (!resolveCredential(trusted, profile).value) throw fail('CREDENTIAL_MISSING', 'Explicit host credential is missing');
|
|
278
|
+
|
|
279
|
+
// Freeze bytes before any await/upload. Source paths can later change without
|
|
280
|
+
// changing the paid request or its persisted image identities.
|
|
281
|
+
const inputRoot = path.join(trusted.workdir, '.abelien', 'gen3d-inputs');
|
|
282
|
+
fs.mkdirSync(inputRoot, {recursive: true, mode: 0o700});
|
|
283
|
+
const inputDir = fs.mkdtempSync(path.join(inputRoot, 'input-'));
|
|
284
|
+
const files = images.map((image, index) => {
|
|
285
|
+
const file = path.join(inputDir, `${index}${image.extension}`);
|
|
286
|
+
fs.writeFileSync(file, image.bytes, {mode: 0o400}); return file;
|
|
287
|
+
});
|
|
288
|
+
const flags = {image: files, ...(prompt === undefined ? {} : {prompt}), 'confirm-spend': true};
|
|
289
|
+
let claimed = false, sent = false;
|
|
290
|
+
try {
|
|
291
|
+
// Exact existing engine validation, including unsupported prompt and templates,
|
|
292
|
+
// before irrevocably claiming this invocation.
|
|
293
|
+
await submitFlow(trusted, {provider: request.provider, model: request.model, flags: {...flags, 'dry-run': true}});
|
|
294
|
+
if (deps.signal?.aborted) throw fail('GENERATION_ABORTED', 'Generation interrupted before dispatch');
|
|
295
|
+
const now = new Date().toISOString();
|
|
296
|
+
const initial = {
|
|
297
|
+
provider: request.provider, model: request.model, transport: 'rest', status: 'unknown',
|
|
298
|
+
images: images.map((image,index) => ({path: files[index], view: image.view, sha256: image.sha256, bytes: image.bytes.length})),
|
|
299
|
+
submitAt: now, updatedAt: now,
|
|
300
|
+
hosted: {protocol: HOSTED_GENERATION_PROTOCOL, requestId: paths.requestId, inputDigest: paths.inputDigest,
|
|
301
|
+
requestDigest, profileDigest: model.profileDigest, ...(qualification ? {qualification} : {})},
|
|
302
|
+
};
|
|
303
|
+
claimed = claimExclusive(paths.claim, initial);
|
|
304
|
+
if (!claimed) {
|
|
305
|
+
const claim = readClaim(paths);
|
|
306
|
+
if (claim.hosted.requestDigest !== requestDigest) throw fail('GENERATION_IDENTITY_CONFLICT', 'Concurrent request content changed');
|
|
307
|
+
return publicResult(paths, readBody(trusted, paths, claim));
|
|
308
|
+
}
|
|
309
|
+
const store = createStore(trusted.workdir);
|
|
310
|
+
store.put('gen3d', paths.localId, initial);
|
|
311
|
+
try {
|
|
312
|
+
await submitFlow({...trusted,
|
|
313
|
+
profileSnapshot: submitProfileSnapshot,
|
|
314
|
+
beforeSubmit: async () => {
|
|
315
|
+
if (deps.signal?.aborted) throw fail('GENERATION_ABORTED', 'Generation interrupted before dispatch');
|
|
316
|
+
// Uploads can be slow. The trusted F4 host must recheck the original
|
|
317
|
+
// lease/fence, baseline and cancellation immediately before paid send.
|
|
318
|
+
try { await deps.beforePaidSubmit?.(); }
|
|
319
|
+
catch (error) {
|
|
320
|
+
diagnostic(deps, 'authority', paths, error);
|
|
321
|
+
throw fail('GENERATION_AUTHORITY_STALE', 'Original paid authorization is no longer current');
|
|
322
|
+
}
|
|
323
|
+
if (deps.signal?.aborted) throw fail('GENERATION_ABORTED', 'Generation interrupted before dispatch');
|
|
324
|
+
if (qualification) qualificationAdmission({...deps, qualification: {
|
|
325
|
+
manifest: qualification.manifest, releaseDigest: qualification.manifest.releaseDigest,
|
|
326
|
+
}}, request, model, images);
|
|
327
|
+
sent = true;
|
|
328
|
+
store.put('gen3d', paths.localId, {...initial, sendStartedAt: new Date().toISOString()});
|
|
329
|
+
},
|
|
330
|
+
store: {
|
|
331
|
+
put(kind, _localId, body) {
|
|
332
|
+
if (kind !== 'gen3d') throw fail('GENERATION_IDENTITY_CONFLICT', 'Unexpected resource kind');
|
|
333
|
+
if (typeof body.providerTaskId !== 'string' || !/^[A-Za-z0-9_.:-]{1,256}$/.test(body.providerTaskId)) {
|
|
334
|
+
throw fail('TASK_FAILED', 'Provider returned an invalid task identity');
|
|
335
|
+
}
|
|
336
|
+
return store.put(kind, paths.localId, {...body, hosted: initial.hosted});
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}, {provider: request.provider, model: request.model, flags});
|
|
340
|
+
} catch (error) {
|
|
341
|
+
diagnostic(deps, sent ? 'submit' : 'prepare', paths, error);
|
|
342
|
+
// Errors after dispatch (including malformed success) cannot prove no charge.
|
|
343
|
+
// A known persisted provider ID must never be discarded by an error path.
|
|
344
|
+
const current = store.get(paths.id)?.body;
|
|
345
|
+
if (!current?.providerTaskId) store.put('gen3d', paths.localId, {
|
|
346
|
+
...initial, status: sent ? 'unknown' : 'failed', errorCode: cleanError(error).code,
|
|
347
|
+
updatedAt: new Date().toISOString(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return publicResult(paths, readBody(trusted, paths, initial));
|
|
351
|
+
} finally {
|
|
352
|
+
if (!claimed) fs.rmSync(inputDir, {recursive: true, force: true});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function storedContext(deps, identity) {
|
|
357
|
+
const trusted = hostDeps(deps), paths = identityPaths(trusted, identity);
|
|
358
|
+
const body = readBody(trusted, paths, readClaim(paths));
|
|
359
|
+
if (!body) throw fail('RESOURCE_NOT_FOUND', 'Hosted generation was not submitted');
|
|
360
|
+
trusted.profileSnapshot = freezeSnapshot(loadProfiles(trusted));
|
|
361
|
+
const model = modelInfo(trusted.profileSnapshot, body);
|
|
362
|
+
if (model.profileDigest !== body.hosted.profileDigest) throw fail('GENERATION_PROFILE_DRIFT', 'Provider configuration changed; reconcile original request');
|
|
363
|
+
return {trusted, paths, body};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** At most one status operation. Unknown requests with no task ID stay offline. */
|
|
367
|
+
export async function statusHostedGeneration(deps, identity) {
|
|
368
|
+
const {trusted, paths, body} = storedContext(deps, identity);
|
|
369
|
+
if (!body.providerTaskId || body.status === 'succeeded' || body.status === 'failed') return publicResult(paths, body);
|
|
370
|
+
try { await statusFlow(trusted, {id: paths.id}); } catch (error) {
|
|
371
|
+
diagnostic(deps, 'status', paths, error); throw cleanError(error);
|
|
372
|
+
}
|
|
373
|
+
return lookupHostedGeneration(trusted, identity);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Only fetch a known successful task; never a waiting loop or a new submit. */
|
|
377
|
+
export async function fetchHostedGeneration(deps, identity) {
|
|
378
|
+
const {trusted, paths, body} = storedContext(deps, identity);
|
|
379
|
+
if (!body.providerTaskId || body.status !== 'succeeded') throw fail('TASK_PENDING', 'Artifact is not ready; use status with the original request');
|
|
380
|
+
if (body.artifact) {
|
|
381
|
+
let bytes;
|
|
382
|
+
try { bytes = fs.readFileSync(body.artifact.path); } catch { throw fail('ARTIFACT_HASH_MISMATCH', 'Persisted artifact is missing'); }
|
|
383
|
+
if (bytes.length !== body.artifact.bytes || sha256Hex(bytes) !== body.artifact.sha256) {
|
|
384
|
+
throw fail('ARTIFACT_HASH_MISMATCH', 'Persisted artifact changed');
|
|
385
|
+
}
|
|
386
|
+
validateGlb(bytes);
|
|
387
|
+
return publicResult(paths, body);
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const {url, download} = await artifactLocation(trusted, {id: paths.id});
|
|
391
|
+
const artifact = await downloadArtifact(trusted, {download}, url,
|
|
392
|
+
path.join(trusted.workdir, '.abelien', 'artifacts', 'gen3d', paths.localId), paths.localId);
|
|
393
|
+
const store = createStore(trusted.workdir);
|
|
394
|
+
const current = store.get(paths.id).body;
|
|
395
|
+
store.put('gen3d', paths.localId, {...current, artifact, status: 'succeeded', updatedAt: new Date().toISOString()});
|
|
396
|
+
return lookupHostedGeneration(trusted, identity);
|
|
397
|
+
} catch (error) { diagnostic(deps, 'fetch', paths, error); throw cleanError(error); }
|
|
398
|
+
}
|