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
|
@@ -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
|
+
}
|
|
@@ -126,6 +126,10 @@ export function validateProfile(p) {
|
|
|
126
126
|
// (mock baseUrl 等),CLI 不暴露;校验先于合并(override 可放松 https 钉子指向 http mock),
|
|
127
127
|
// 指向未知名显式 PROVIDER_NOT_FOUND(拒绝静默吞掉测试笔误)。
|
|
128
128
|
export function loadProfiles(deps) {
|
|
129
|
+
// Trusted host-only port: the exact verified configuration snapshot is used for
|
|
130
|
+
// preflight, upload, paid submit and subsequent reads within this operation.
|
|
131
|
+
// No CLI flag or environment setting exposes this port.
|
|
132
|
+
if (deps?.profileSnapshot) return deps.profileSnapshot;
|
|
129
133
|
const domain = discoverDomains({ workdir: deps.workdir }).find(d => d.name === DOMAIN_NAME);
|
|
130
134
|
if (!domain) throw fail("PROVIDER_NOT_FOUND", `domain pack ${DOMAIN_NAME} not discovered`, { domain: DOMAIN_NAME });
|
|
131
135
|
const dir = path.join(domain.dir, "providers");
|
|
@@ -514,27 +518,62 @@ export function unzipPick(zipBuf, pickRe, destPath, options = {}) {
|
|
|
514
518
|
}
|
|
515
519
|
|
|
516
520
|
// downloadArtifact——产物下载落盘(Task 8):GET 签名 URL(fetchImpl 可注入,测试零网络),
|
|
517
|
-
// 60s 超时(AbortController);非 2xx / 网络失败 /
|
|
518
|
-
// status
|
|
521
|
+
// 60s 超时(AbortController);非 2xx / 网络失败 / 超时 / 3xx 一律 DOWNLOAD_FAILED(非 2xx 附
|
|
522
|
+
// status)。**签名 URL 一律不跟随重定向**(`redirect:'error'`,与 restJson/uploadImages 同款):
|
|
523
|
+
// 产物地址是 provider 给的带签名链接,跟随 302 就等于把凭据送到未受信主机;这条对 CLI 的
|
|
524
|
+
// `ab generate 3d fetch` 与宿主 API 一并生效(宿主面本就在 hostDeps 强制,此处补齐内核面)。
|
|
525
|
+
// 落盘恒 destDir/model.glb:binary 直接原子写;zip/auto 先嗅探(Content-Type 含
|
|
519
526
|
// zip 或字节头 PK\x03\x04)走 unzipPick 挑 glb 条目。返回 {file, path, sha256, bytes}——
|
|
520
527
|
// spec 红线:签名 URL 永不进入返回值或落盘内容,只存在于调用栈;错误消息同样 redact
|
|
521
528
|
// (T8 遗留收口:URL 不进 message/details,诊断靠 details.status / details.reason)。
|
|
529
|
+
// Keep the host cancellation signal attached until the entire response body is read.
|
|
530
|
+
function requestAbort(deps, timeoutMs) {
|
|
531
|
+
const ac = new AbortController();
|
|
532
|
+
const abort = () => ac.abort();
|
|
533
|
+
if (deps.signal?.aborted) ac.abort();
|
|
534
|
+
deps.signal?.addEventListener('abort', abort, { once: true });
|
|
535
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
536
|
+
return {
|
|
537
|
+
signal: ac.signal,
|
|
538
|
+
close() { clearTimeout(timer); deps.signal?.removeEventListener('abort', abort); },
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function artifactBytes(deps, res) {
|
|
543
|
+
if (deps.credentialPolicy !== 'env-only') return Buffer.from(await res.arrayBuffer());
|
|
544
|
+
const limit = deps.maxArtifactBytes;
|
|
545
|
+
const advertised = Number(res.headers?.get?.('content-length'));
|
|
546
|
+
if (Number.isFinite(advertised) && advertised > limit) throw fail('DOWNLOAD_FAILED', 'Artifact exceeds host budget');
|
|
547
|
+
const reader = res.body?.getReader();
|
|
548
|
+
if (!reader) throw fail('DOWNLOAD_FAILED', 'Hosted artifact response must be bounded');
|
|
549
|
+
const chunks = []; let size = 0;
|
|
550
|
+
try {
|
|
551
|
+
for (;;) {
|
|
552
|
+
const { done, value } = await reader.read();
|
|
553
|
+
if (done) break;
|
|
554
|
+
size += value.length;
|
|
555
|
+
if (size > limit) throw fail('DOWNLOAD_FAILED', 'Artifact exceeds host budget');
|
|
556
|
+
chunks.push(value);
|
|
557
|
+
}
|
|
558
|
+
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
559
|
+
return Buffer.concat(chunks);
|
|
560
|
+
}
|
|
561
|
+
|
|
522
562
|
export async function downloadArtifact(deps, profile, url, destDir, localId) {
|
|
523
563
|
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
524
|
-
const
|
|
525
|
-
const timer = setTimeout(() => ac.abort(), 60000);
|
|
564
|
+
const request = requestAbort(deps, 60000);
|
|
526
565
|
let res;
|
|
527
566
|
let body;
|
|
528
567
|
try {
|
|
529
|
-
res = await fetchImpl(url, { method: "GET", signal:
|
|
568
|
+
res = await fetchImpl(url, { method: "GET", signal: request.signal, redirect: 'error' });
|
|
530
569
|
if (!res.ok) throw fail("DOWNLOAD_FAILED", `下载失败: HTTP ${res.status}`, { status: res.status });
|
|
531
|
-
body =
|
|
570
|
+
body = await artifactBytes(deps, res);
|
|
532
571
|
if (deps.maxArtifactBytes !== undefined && body.length > deps.maxArtifactBytes) throw fail('DOWNLOAD_FAILED', 'Artifact exceeds host budget');
|
|
533
572
|
} catch (e) {
|
|
534
573
|
if (e?.code === "DOWNLOAD_FAILED") throw e; // 非 2xx 已带 status,不二次包裹
|
|
535
574
|
throw fail("DOWNLOAD_FAILED", "下载失败(签名 URL 已隐去)", { reason: String(e?.cause?.message ?? e?.message ?? e).slice(0, 200) });
|
|
536
575
|
} finally {
|
|
537
|
-
|
|
576
|
+
request.close();
|
|
538
577
|
}
|
|
539
578
|
// mkdir -p 语义自裁(Task 12 离线段 A):目录不可创建(父路径被文件占据等)也是
|
|
540
579
|
// 下载失败的一部分——收口 DOWNLOAD_FAILED 家族,消息只含本地落点、绝无签名 URL。
|
|
@@ -661,11 +700,10 @@ export async function restJson(deps, profile, { method = "GET", url, body, key,
|
|
|
661
700
|
const headers = { "content-type": "application/json", authorization: `Bearer ${key}` };
|
|
662
701
|
let lastErr;
|
|
663
702
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
664
|
-
const
|
|
665
|
-
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
703
|
+
const request = requestAbort(deps, timeoutMs);
|
|
666
704
|
try {
|
|
667
705
|
const res = await fetchImpl(url, {
|
|
668
|
-
method, headers, signal:
|
|
706
|
+
method, headers, signal: request.signal, redirect: 'error',
|
|
669
707
|
...(body !== undefined ? { body } : {}), // GET 零 body(undici 拒绝 GET/HEAD body)
|
|
670
708
|
});
|
|
671
709
|
if (!res.ok) {
|
|
@@ -683,8 +721,9 @@ export async function restJson(deps, profile, { method = "GET", url, body, key,
|
|
|
683
721
|
if (e?.code === "HTTP_STATUS") throw e;
|
|
684
722
|
lastErr = e; // 网络错/超时/响应体非 JSON → 重试
|
|
685
723
|
} finally {
|
|
686
|
-
|
|
724
|
+
request.close();
|
|
687
725
|
}
|
|
726
|
+
if (deps.signal?.aborted) break;
|
|
688
727
|
}
|
|
689
728
|
if (lastErr?.code === "HTTP_STATUS") { // 5xx 耗尽:保 HTTP_FAILED 码,status 仍可诊断
|
|
690
729
|
throw fail("HTTP_FAILED", `rest 请求失败: ${lastErr.message}`, { status: lastErr.details.status });
|
|
@@ -718,19 +757,18 @@ async function uploadImages(deps, profile, key, files) {
|
|
|
718
757
|
}
|
|
719
758
|
const form = new FormData();
|
|
720
759
|
form.append("file", new Blob([raw], { type: mime }), path.basename(file));
|
|
721
|
-
const
|
|
722
|
-
const timer = setTimeout(() => ac.abort(), 30000);
|
|
760
|
+
const request = requestAbort(deps, 30000);
|
|
723
761
|
let res,text;
|
|
724
762
|
try {
|
|
725
763
|
res = await fetchImpl(profile.transport.baseUrl + upath, {
|
|
726
|
-
method, headers: { authorization: `Bearer ${key}` }, body: form, signal:
|
|
764
|
+
method, headers: { authorization: `Bearer ${key}` }, body: form, signal: request.signal, redirect: 'error',
|
|
727
765
|
});
|
|
728
766
|
text=await providerText(deps,res);
|
|
729
767
|
} catch (e) {
|
|
730
768
|
throw fail("UPLOAD_FAILED", `图片上传失败(网络错): ${file}`,
|
|
731
769
|
{ index: i, reason: String(e?.cause?.message ?? e?.message ?? e).slice(0, 200) });
|
|
732
770
|
} finally {
|
|
733
|
-
|
|
771
|
+
request.close();
|
|
734
772
|
}
|
|
735
773
|
if (!res.ok) {
|
|
736
774
|
throw fail("UPLOAD_FAILED", `图片上传失败: HTTP ${res.status}`,
|
|
@@ -856,6 +894,7 @@ export async function submitFlow(deps, { provider, model: modelId, flags = {} })
|
|
|
856
894
|
// upload 节(T12 在线段 1c):双闸通过后、submit 请求前逐图上传换 token——
|
|
857
895
|
// Authorization 同源(上方同一凭证解析的 key);失败即 UPLOAD_FAILED 中断(零花费)。
|
|
858
896
|
if (profile.upload) tokenByFile = await uploadImages(deps, profile, key, imgCtx.views.map(([, f]) => f));
|
|
897
|
+
await deps.beforeSubmit?.(); // Trusted host persists the one-send claim before the paid request.
|
|
859
898
|
const raw = await restJson(deps, profile, {
|
|
860
899
|
method: plan.request.method, url: plan.request.url, key,
|
|
861
900
|
body: canonicalJson(buildBody(bodyTemplate, realCtx)),
|
|
@@ -16,7 +16,9 @@ const SAFE_RUN_RE = /^[0-9A-Za-z][0-9A-Za-z._-]{0,255}$/;
|
|
|
16
16
|
const SYMBOL_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
17
17
|
const TERMINAL_WORKFLOW_STATES = new Set(["done", "failed", "cancelled"]);
|
|
18
18
|
const ALLOWED_CHANGES = new Set(["geometry", "transform", "interfaces", "appearance_mapping", "material"]);
|
|
19
|
-
const STEP_KEYS = new Set(["appearance.generate", "
|
|
19
|
+
const STEP_KEYS = new Set(["appearance.generate", "appearance.revision", "mesh.generate", "structure.adapt",
|
|
20
|
+
"cad.initial", "cad.revision", "cad.repair", "cad.final", "visual.review"]);
|
|
21
|
+
const APPEARANCE_PARENT_STEPS = new Set(["appearance.revision", "mesh.generate", "structure.adapt"]);
|
|
20
22
|
|
|
21
23
|
function invalid(message, details) {
|
|
22
24
|
throw fail("RECEIPT_INVALID", message, details);
|
|
@@ -237,6 +239,9 @@ export function parseStudioReceiptEnvelope(value) {
|
|
|
237
239
|
exactKeys(step, ["stepKey", "parentCandidateId", "repairOrdinal", "repairBasis"], "receipt.step");
|
|
238
240
|
if (typeof step.stepKey !== "string" || !STEP_KEYS.has(step.stepKey)) invalid("receipt.step.stepKey is invalid");
|
|
239
241
|
if (step.parentCandidateId !== null) uuid(step.parentCandidateId, "receipt.step.parentCandidateId");
|
|
242
|
+
if (APPEARANCE_PARENT_STEPS.has(step.stepKey) && step.parentCandidateId === null) {
|
|
243
|
+
invalid("appearance and engineering receipts require their immutable parent candidate");
|
|
244
|
+
}
|
|
240
245
|
if (step.stepKey === "cad.final" && (!creationLineage || step.parentCandidateId === null)) {
|
|
241
246
|
invalid("cad.final requires creation lineage and the reviewed preview parent");
|
|
242
247
|
}
|
|
@@ -499,7 +504,8 @@ function inspectWorkflow(workdir, envelope) {
|
|
|
499
504
|
instanceDigest: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
500
505
|
validatedFields: [...validated].sort(),
|
|
501
506
|
waitIdentityVerified: ["waitId", "workflowInstanceId", operationField, "localRunId", "remoteRunId", "invocationId",
|
|
502
|
-
...(envelope.step.stepKey === "cad.final"
|
|
507
|
+
...(envelope.step.stepKey === "cad.final" || APPEARANCE_PARENT_STEPS.has(envelope.step.stepKey)
|
|
508
|
+
? ["definitionDigest", "stepKey", "parentCandidateId"] : [])]
|
|
503
509
|
.every(field => validated.has(field)),
|
|
504
510
|
resumeDisposition,
|
|
505
511
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// model input can never select either value.
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
|
|
6
|
-
export const STUDIO_CONTEXT_FILE = "/
|
|
6
|
+
export const STUDIO_CONTEXT_FILE = "/home/agent/workspace/abelien-runtime-delegation/current.json";
|
|
7
7
|
|
|
8
8
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
9
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
@@ -188,6 +188,16 @@ function validateModificationRequest(value) {
|
|
|
188
188
|
jsonSize(input, "modification input");
|
|
189
189
|
return input;
|
|
190
190
|
}
|
|
191
|
+
// Transport identities only; immutable definitions and full tool schemas remain
|
|
192
|
+
// broker-owned. Do not grant v2 to every tool when adding CAD adaptation.
|
|
193
|
+
function validateToolIdentity(value) {
|
|
194
|
+
const tool = record(value, "tool input");
|
|
195
|
+
const v1 = ["model.complete", "hardware.edit", "image.generate", "cad.build", "visual.review", "mesh.generate"];
|
|
196
|
+
if (!(tool.version === 1 && v1.includes(tool.tool))
|
|
197
|
+
&& !(tool.version === 2 && tool.tool === "cad.build" && tool.purpose === "appearance_adaptation")) {
|
|
198
|
+
throw fail("STUDIO_INVALID", "tool input identity is invalid");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
191
201
|
function validateToolOperationRequest(value) {
|
|
192
202
|
const input = exact(value, ["schemaVersion", "commandId", "stepKey", "parentCandidateId",
|
|
193
203
|
"repairBasis", "effectIdentity", "effectRequestDigest", "waits", "input"],
|
|
@@ -214,10 +224,7 @@ function validateToolOperationRequest(value) {
|
|
|
214
224
|
text(repair.reportDigest, "reportDigest", DIGEST, 64);
|
|
215
225
|
sortedUniqueStrings(repair.issueCodes, "issueCodes", 64);
|
|
216
226
|
}
|
|
217
|
-
|
|
218
|
-
if (!["model.complete", "hardware.edit", "image.generate", "cad.build", "visual.review"].includes(tool.tool) || tool.version !== 1) {
|
|
219
|
-
throw fail("STUDIO_INVALID", "tool input identity is invalid");
|
|
220
|
-
}
|
|
227
|
+
validateToolIdentity(input.input);
|
|
221
228
|
noForbiddenKeys(input);
|
|
222
229
|
jsonSize(input, "tool operation input");
|
|
223
230
|
return input;
|
|
@@ -244,10 +251,7 @@ function validateInvocationRequest(value) {
|
|
|
244
251
|
if (input.schemaVersion !== 1) throw fail("STUDIO_INVALID", "invocation schemaVersion must be 1");
|
|
245
252
|
text(input.operationId, "operationId", UUID, 36);
|
|
246
253
|
text(input.callSlot, "callSlot", IDENTIFIER, 128);
|
|
247
|
-
|
|
248
|
-
if (!["model.complete", "hardware.edit", "image.generate", "cad.build", "visual.review"].includes(tool.tool) || tool.version !== 1) {
|
|
249
|
-
throw fail("STUDIO_INVALID", "tool input identity is invalid");
|
|
250
|
-
}
|
|
254
|
+
validateToolIdentity(input.input);
|
|
251
255
|
noForbiddenKeys(input);
|
|
252
256
|
jsonSize(input, "invocation input");
|
|
253
257
|
return input;
|
|
@@ -352,10 +356,14 @@ function safeSuccess(kind, value) {
|
|
|
352
356
|
const row = exact(wrapper.creation, ["creationOperationId", "requestId", "inputRevision", "manifestDigest",
|
|
353
357
|
"requirementsHash", "hardwareRef", "visualSpecRef", "algorithmRef", "frozenInputs", "plannedOccurrenceBindings",
|
|
354
358
|
"executionDefinitionVersion", "executionDefinitionDigest", "executionPath", "repairCount", "status"],
|
|
355
|
-
["executionSources", "stage", "latestInvocationId", "latestInvocationStatus", "accountingStatus", "candidateId", "reviewOutcome"
|
|
359
|
+
["executionSources", "stage", "latestInvocationId", "latestInvocationStatus", "accountingStatus", "candidateId", "reviewOutcome",
|
|
360
|
+
"latestTool", "cadBuildAttempts", "previewCandidateId"], "creation");
|
|
356
361
|
text(row.creationOperationId, "creationOperationId", UUID, 36);
|
|
357
362
|
text(row.requestId, "requestId", UUID, 36);
|
|
358
363
|
text(row.manifestDigest, "manifestDigest", DIGEST, 64);
|
|
364
|
+
if (row.latestTool != null) text(row.latestTool, "latestTool", IDENTIFIER, 128);
|
|
365
|
+
if (row.cadBuildAttempts !== undefined) integer(row.cadBuildAttempts, "cadBuildAttempts");
|
|
366
|
+
if (row.previewCandidateId != null) text(row.previewCandidateId, "previewCandidateId", UUID, 36);
|
|
359
367
|
noForbiddenKeys(row, "creation response");
|
|
360
368
|
return {creation: row};
|
|
361
369
|
}
|
|
@@ -35,13 +35,15 @@ compatibility:
|
|
|
35
35
|
|
|
36
36
|
产品目标是尽快展示可信真实 CAD;根据可用资产组合复用、布局和参数化补建,不强制所有部件从零 Build123d。已有可信模型可先只读预览并标注未审查,实际是否支持由宿主决定;采用/交付仍须适用检查。几何执行分 preview 与 final,预览可迭代,不是只能调用两次。需求、组件、几何、相关证据或工具链变化才失效对应缓存;纯报告措辞变化不要求重新导出 CAD。
|
|
37
37
|
|
|
38
|
+
局部自由造型修改的独立 structure.adapt(cad.build@2)步骤及 analytic_carrier_faceted_decoration 范围按[外观件交接](../abelien-appearance/references/local-component-edit.md)执行:保持原接口精确几何及未改实例,不要求 Build123d 重建整个装饰网格;缺可信保护区域/接口检查时只预览,不能升级为 CAD 通过。
|
|
39
|
+
|
|
38
40
|
## 本包可执行的辅助工具
|
|
39
41
|
|
|
40
42
|
- `python3 scripts/check_bundle.py --project <候选目录> --manifest bundle.json --baseline <宿主当前基线>`:现代候选包的同版文件、来源/候选 BOM、逐实例数量、原生比例、几何检查覆盖与视觉报告完整性门,格式见[候选包质量](references/bundle-quality.md)。明确只证明契约完整性,不能以 viewed=true 宣称真实审查;不依赖旧 workflow-state。
|
|
41
43
|
- `python3 scripts/run_stage.py --project <工程目录> --plan <任务计划.json>`:显式输入/输出清单、输入与输出哈希复用、互斥执行、可配置超时、POSIX 进程组终止及独立执行回执。计划格式见建模参考。
|
|
42
44
|
- `python3 scripts/check_visual_review.py --project <工程目录> --report <审查.json> --subject <本版模型文件> --require-pass`:为 appearance 保留的兼容完整性校验实现,只检查报告结构、文件及哈希、视图和目标覆盖;不执行图像比较或几何验收。视觉语义见同级 `abelien-appearance/references/visual-review.md`。
|
|
43
45
|
|
|
44
|
-
进程组清理保持 TERM → wait(2s) → KILL → wait。仅 Darwin 的 PermissionError 且原子进程已退出时,固定 `/bin/ps` 完整校验全表,证明原组无成员或全为同有效 UID 的 zombie
|
|
46
|
+
进程组清理保持 TERM → wait(2s) → KILL → wait。仅 Darwin 的 PermissionError 且原子进程已退出时,固定 `/bin/ps` 完整校验全表,证明原组无成员或全为同有效 UID 的 zombie 后才按已终止处理(该表接受 Darwin 的 `?` 未定运行态,它不使整表判废;原组自身成员为 `?` 仍不是 zombie,照旧失败);活后代、未知探针与其他错误仍失败。边界见[执行参考](references/build123d-execution.md)。
|
|
45
47
|
|
|
46
48
|
本包不注册空 `domain.json`、虚假的 `structure.*` capability 或占位 CAD 生成服务。需要有本地执行/渲染/看图能力的宿主,或另行接通对应后端。执行辅助工具是有界前台进程,不是跨请求的后台 Worker;中断后可核验并复用已完成产物,不能恢复一个已经消失的进程。
|
|
47
49
|
|