abelien-harness 0.6.3 → 0.7.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 +1 -1
- package/package.json +1 -1
- package/skills/abelien-appearance/SKILL.md +1 -0
- package/skills/abelien-clarify/SKILL.md +11 -9
- package/skills/abelien-clarify/references/examples.md +3 -3
- package/skills/abelien-clarify/scripts/check_result.py +3 -2
- package/skills/abelien-clarify/scripts/test_check_result.py +13 -0
- package/skills/abelien-guide/SKILL.md +1 -1
- package/skills/abelien-hardware/SKILL.md +2 -2
- package/skills/abelien-hardware/domain.json +1 -1
- package/skills/abelien-hardware/references/hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware/references/hardware-selection/references/query-contract.md +6 -4
- package/skills/abelien-hardware/references/planning-modes.md +1 -1
- package/skills/abelien-hardware/references/search-index-format.md +2 -2
- package/skills/abelien-hardware/scripts/dimensions.mjs +13 -8
- package/skills/abelien-hardware-selection/references/hardware-selection-v2/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/references/hardware-selection-v2/references/query-contract.md +6 -4
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +2 -2
- package/skills/abelien-harness/references/adapter-contract.md +1 -0
- package/skills/abelien-harness/scripts/ab.mjs +6 -0
- package/skills/abelien-harness/scripts/web/public-read.mjs +32 -11
- package/skills/abelien-reference/SKILL.md +7 -4
- package/skills/abelien-reference/domain.json +4 -3
- package/skills/abelien-reference/fixtures/create.final.json +20 -0
- package/skills/abelien-reference/fixtures/restore.collected.json +20 -0
- package/skills/abelien-reference/fixtures/restore.final.json +24 -2
- package/skills/abelien-reference/references/research-contract.md +20 -11
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +18 -4
- package/skills/abelien-reference/schemas/reference-judgement.v1.json +6 -6
- package/skills/abelien-reference/scripts/evidence.mjs +6 -3
- package/skills/abelien-reference/scripts/finalize.mjs +60 -14
- package/skills/abelien-reference/scripts/measure.mjs +4 -2
- package/skills/abelien-reference/scripts/png.mjs +20 -4
- package/skills/abelien-reference/scripts/research.mjs +93 -22
- package/skills/abelien-structure/SKILL.md +2 -2
- package/skills/abelien-structure/domain.json +1 -1
- package/skills/abelien-structure/references/build123d-execution.md +2 -2
- package/skills/abelien-structure/references/handoff.md +1 -1
- package/skills/abelien-structure/worker/cli-adapter.mjs +56 -13
- package/skills/abelien-structure/worker/main.mjs +15 -4
- package/skills/abelien-structure/worker/supervisor.mjs +6 -2
- package/skills/abelien-workspace/SKILL.md +1 -1
- package/skills/abelien-workspace/assets/journey-stage-plan.json +21 -15
- package/skills/abelien-workspace/domain.json +1 -1
- package/skills/abelien-workspace/references/harness-binding.md +4 -1
- package/skills/abelien-workspace/references/user-journey.md +13 -10
- package/skills/abelien-workspace/scripts/journey-next.mjs +17 -5
- package/skills/abelien-workspace/workflows/definitions/748a70482fb2f775f6e5fd942c7873534cf3e59614c77381f812a9710d479743.json +1 -0
- package/skills/abelien-workspace/workflows/definitions/a1021c217f2894da423f8591842ea9f349bc526ac3119352b4bea02e8dbd42f9.json +1 -0
- package/skills/abelien-workspace/workflows/definitions/df147960a3f7c4d631f66a6b91a7891f8a37d25a50322cf09bdc9304195963b0.json +1 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +1 -1
- package/skills/abelien-workspace/workflows/user-journey.json +3 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// reference.research(P2-13.04):按 Agent 给出的检索计划执行确定性的收集——Volc 检索、公开页面安全直读、
|
|
2
2
|
// 页面主图抓取、用户原图测量——全部落为证据,不做身份判断。判断由 Agent 读证据后经 reference.finalize 提交并核验。
|
|
3
|
-
//
|
|
3
|
+
// 同一请求完成后重复运行直接返回已有结果。页面图片的格式按字节判断,有宿主转换器时统一存成 PNG,
|
|
4
4
|
// 存下的字节的 sha256 就是这张图的 id;宿主只展示有 id 的图。
|
|
5
|
+
// 付费检索逐条「先存 sent 再发送」:发送前把该次尝试写入检查点,收到回执后再写结果与脱敏原文;重跑时已完成的检索
|
|
6
|
+
// 直接复用,停在 sent 而没有结果(结果未知)的检索一律要求对账,绝不自动重发。
|
|
5
7
|
import { Buffer } from "node:buffer";
|
|
6
|
-
import { text, record, digest, sha256 } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
import { text, record, digest, sha256, failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
7
9
|
import { decodeBody, decodeHtmlEntities } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
8
10
|
import { isPng } from "./png.mjs";
|
|
9
11
|
import { measurePng } from "./measure.mjs";
|
|
@@ -109,15 +111,21 @@ export function imageCandidates(html, base) {
|
|
|
109
111
|
return out;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
/** Run tasks with bounded concurrency; results keep input order. */
|
|
114
|
+
/** Run tasks with bounded concurrency; results keep input order. Once any task fails no worker starts another. */
|
|
113
115
|
async function pool(list, task, limit = LIMITS.concurrency) {
|
|
114
|
-
const out = new Array(list.length); let next = 0;
|
|
116
|
+
const out = new Array(list.length); let next = 0, failed = false;
|
|
115
117
|
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
116
|
-
while (next < list.length) {
|
|
118
|
+
while (!failed && next < list.length) {
|
|
119
|
+
const index = next++;
|
|
120
|
+
try { out[index] = await task(list[index], index); } catch (error) { failed = true; throw error; }
|
|
121
|
+
}
|
|
117
122
|
}));
|
|
118
123
|
return out;
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
const reconcile = message => failure("REFERENCE_WEB_RECONCILIATION_REQUIRED", message);
|
|
127
|
+
const sameQuery = (entry, q, index) => record(entry) && entry.id === `s${index + 1}` && entry.purpose === q.purpose && entry.query === q.query && entry.count === q.count;
|
|
128
|
+
|
|
121
129
|
/**
|
|
122
130
|
* Interleave search results so one query cannot use the whole page budget. Also counts the distinct public pages the
|
|
123
131
|
* cap left unread, so the host can say the research stopped at its limit instead of implying nothing else was found.
|
|
@@ -130,21 +138,21 @@ function pageQueue(links, intent, searches, max) {
|
|
|
130
138
|
seen.add(entry.url);
|
|
131
139
|
if (queue.length < max) queue.push(entry); else skipped++;
|
|
132
140
|
};
|
|
133
|
-
for (const url of links) push({ url, purpose: intent === "create" ? "similar" : "identify", search_id: null, query: null, search_title: null });
|
|
141
|
+
for (const url of links) push({ url, purpose: intent === "create" ? "similar" : "identify", search_id: null, query: null, search_title: null, search_snippet: "" });
|
|
134
142
|
for (let rank = 0; ; rank++) {
|
|
135
143
|
let added = false;
|
|
136
144
|
for (const search of searches) {
|
|
137
145
|
const result = search.results[rank];
|
|
138
146
|
if (!result) continue;
|
|
139
147
|
added = true;
|
|
140
|
-
try { push({ url: reader.normalizePublicSourceUrl(result.url).href, purpose: search.purpose, search_id: search.id, query: search.query, search_title: result.title }); } catch { /* not a public HTTPS result */ }
|
|
148
|
+
try { push({ url: reader.normalizePublicSourceUrl(result.url).href, purpose: search.purpose, search_id: search.id, query: search.query, search_title: result.title, search_snippet: result.snippet ?? "" }); } catch { /* not a public HTTPS result */ }
|
|
141
149
|
}
|
|
142
150
|
if (!added) break;
|
|
143
151
|
}
|
|
144
152
|
return Object.assign(queue, { skipped });
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
export async function researchReference({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {} }) {
|
|
155
|
+
export async function researchReference({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {}, checkpoint }) {
|
|
148
156
|
const request = validateResearchInput(input);
|
|
149
157
|
if (typeof writeBlob !== "function" || typeof readResource !== "function") throw Object.assign(new Error("trusted content writer and resource reader are required"), { code: "REFERENCE_HOST" });
|
|
150
158
|
// A misconfigured converter stops the run before any request, like a missing search key.
|
|
@@ -153,9 +161,19 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
153
161
|
const id = `ref-${requestDigest.slice(0, 16)}`;
|
|
154
162
|
const existing = await readResource(`reference-evidence:${id}`);
|
|
155
163
|
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest) {
|
|
156
|
-
// Never reuse a body that fails today's contract
|
|
157
|
-
try { return validateEvidence(existing.body); } catch {
|
|
164
|
+
// Never reuse a body that fails today's contract, and never pay for its searches again: it is left for reconciliation.
|
|
165
|
+
try { return validateEvidence(existing.body); } catch { throw reconcile("saved collected evidence fails the current contract; reconcile it instead of re-running the paid searches"); }
|
|
158
166
|
}
|
|
167
|
+
// Only an in-progress checkpoint of this very request may be resumed; anything else is left for reconciliation.
|
|
168
|
+
if (existing && (existing.body?.status !== "in_progress" || existing.body.request_digest !== requestDigest || !Array.isArray(existing.body.searches)
|
|
169
|
+
|| existing.body.searches.length > request.plan.queries.length || existing.body.searches.some((entry, index) => !sameQuery(entry, request.plan.queries[index], index))))
|
|
170
|
+
throw reconcile("saved reference evidence does not match this request; reconcile the original invocation instead of re-running it");
|
|
171
|
+
const paid = request.plan.queries.length > 0;
|
|
172
|
+
// Fail before anything is marked sent: a missing or malformed key cannot have produced a charge.
|
|
173
|
+
if (paid && (!text(env.WEB_SEARCH_API_KEY) || /[\r\n\0]/.test(env.WEB_SEARCH_API_KEY))) throw failure("REFERENCE_WEB_CONFIG", "WEB_SEARCH_API_KEY is required from the trusted host");
|
|
174
|
+
if (paid && typeof checkpoint !== "function") throw failure("REFERENCE_WEB_CHECKPOINT_REQUIRED", "paid search IO requires the original resource store checkpoint");
|
|
175
|
+
let expectedDigest = existing?.meta?.digest ?? null;
|
|
176
|
+
const save = async body => { if (checkpoint) expectedDigest = (await checkpoint(body, { expectedDigest })).meta.digest; };
|
|
159
177
|
|
|
160
178
|
const unknowns = [], items = [];
|
|
161
179
|
let userImage = null;
|
|
@@ -168,39 +186,89 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
168
186
|
items.push({ id: "u1", kind: "user_input", title: "你的输入", role: request.text, source: null,
|
|
169
187
|
image: userImage ? { blob_ref: userImage.blob_ref, sha256: userImage.sha256, media_type: "image/png" } : null, verdict: null, status: "ready", query: null });
|
|
170
188
|
|
|
171
|
-
|
|
189
|
+
// The in-progress checkpoint carries every field a collected body has, so readers never see a partial shape.
|
|
190
|
+
const progress = { id, schema: SCHEMA, status: "in_progress", request_digest: requestDigest, intent: request.intent, keep: request.keep, changeable: request.changeable,
|
|
191
|
+
depth: request.depth, text: request.text, user_image: userImage, searches: structuredClone(existing?.body.searches ?? []), pages: [], images: [], items: [], unknowns: [] };
|
|
192
|
+
const searches = progress.searches;
|
|
193
|
+
// The saved receipt is the redacted original text (as a content blob) plus its hash; it must not carry the key.
|
|
194
|
+
const keepReceipt = (entry, saved) => {
|
|
195
|
+
if (!saved || typeof saved.raw !== "string") return;
|
|
196
|
+
if (text(env.WEB_SEARCH_API_KEY) && saved.raw.includes(env.WEB_SEARCH_API_KEY)) throw failure("REFERENCE_WEB_RESPONSE", "search receipt still contains the key after redaction");
|
|
197
|
+
Object.assign(entry, { request_id: saved.request_id ?? null, receipt_sha256: saved.raw_sha256, receipt_blob_ref: writeBlob({ text: saved.raw }), response_sha256: saved.response_sha256 ?? null, http_status: saved.http_status ?? null, received_at: saved.received_at ?? null });
|
|
198
|
+
};
|
|
172
199
|
for (const [index, q] of request.plan.queries.entries()) {
|
|
173
|
-
|
|
200
|
+
let entry = searches[index];
|
|
201
|
+
if (entry && ["completed", "failed"].includes(entry.status)) {
|
|
202
|
+
if (entry.status === "completed" && !entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
203
|
+
if (entry.status === "failed") unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (entry?.dispatch_state === "sent") throw reconcile(`search ${entry.id} was sent but its outcome was never saved; reconcile it with the provider, it is not resent`);
|
|
207
|
+
const body = volc.volcQueryRequest({ query: q.query, count: q.count });
|
|
208
|
+
volc.validateVolcCapability(body);
|
|
209
|
+
entry = { id: `s${index + 1}`, purpose: q.purpose, query: q.query, count: q.count, request: body, dispatch_state: "sent", sent_at: new Date().toISOString(),
|
|
210
|
+
status: "in_progress", error_code: null, outcome: null, request_id: null, receipt_sha256: null, receipt_blob_ref: null, results: [] };
|
|
211
|
+
searches[index] = entry;
|
|
212
|
+
await save(progress);
|
|
174
213
|
try {
|
|
175
|
-
const saved = await volc.volcWebSearch(
|
|
176
|
-
entry
|
|
214
|
+
const saved = await volc.volcWebSearch(body, { env, fetchImpl, timeoutMs: LIMITS.searchTimeoutMs });
|
|
215
|
+
keepReceipt(entry, saved);
|
|
216
|
+
entry.status = "completed"; entry.outcome = "received";
|
|
177
217
|
entry.results = (saved.response.Result.WebResults ?? []).filter(r => text(r?.Url)).map((r, rank) => ({ rank: rank + 1, url: r.Url, title: String(r.Title ?? ""), snippet: String(r.Snippet ?? r.Summary ?? "").slice(0, 300) }));
|
|
178
218
|
if (!entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
179
219
|
} catch (error) {
|
|
180
|
-
if (error?.code
|
|
181
|
-
entry
|
|
220
|
+
if (!String(error?.code ?? "").startsWith("REFERENCE_WEB_")) throw error;
|
|
221
|
+
keepReceipt(entry, error.providerEvidence);
|
|
222
|
+
// A known rejection and an unknown outcome are both final for this request: neither is resent.
|
|
223
|
+
entry.status = "failed"; entry.error_code = error.code; entry.outcome = error.ambiguous || !error.providerEvidence ? "unknown" : "rejected";
|
|
182
224
|
unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
183
225
|
}
|
|
184
|
-
|
|
226
|
+
await save(progress);
|
|
185
227
|
}
|
|
186
228
|
|
|
187
229
|
const readOptions = { lookup: ports.lookup, pageRequest: ports.pageRequest, dnsRequest: ports.dnsRequest, timeoutMs: LIMITS.pageTimeoutMs };
|
|
188
230
|
const queue = pageQueue(request.links, request.intent, searches, request.plan.max_pages);
|
|
231
|
+
// The whole page phase has one budget: a page not started in time is not read, and a read never outlives the round.
|
|
232
|
+
const now = ports.now ?? Date.now, roundEnds = now() + LIMITS.pageRoundMs;
|
|
233
|
+
/** A slow connection is told apart from a slow body; a timeout or a dropped connection is tried once more while the round has time. */
|
|
234
|
+
const readPage = async url => {
|
|
235
|
+
for (let attempt = 0; ; attempt++) {
|
|
236
|
+
const left = roundEnds - now();
|
|
237
|
+
if (left < LIMITS.pageConnectTimeoutMs) throw Object.assign(new Error("page round budget spent"), { code: "round_budget" });
|
|
238
|
+
try {
|
|
239
|
+
return await reader.fetchPublicPage(url, { ...readOptions, timeoutMs: Math.min(LIMITS.pageTimeoutMs, left),
|
|
240
|
+
connectTimeoutMs: LIMITS.pageConnectTimeoutMs, maxBytes: LIMITS.pageBytes });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const stage = reader.publicReadFailure(error)?.stage;
|
|
243
|
+
if (attempt || !["timeout", "transport"].includes(stage)) throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
189
247
|
// Phase 1: read pages concurrently; results keep queue order.
|
|
190
248
|
const pages = await pool(queue, async (entry, index) => {
|
|
191
249
|
const page = { id: `p${index + 1}`, url: entry.url, purpose: entry.purpose, search_id: entry.search_id, status: "unavailable", reason: null, final_url: null, title: entry.search_title ?? "",
|
|
192
250
|
raw_sha256: null, text_blob_ref: null, text_sha256: null, excerpt: "", image_candidates: [] };
|
|
193
251
|
try {
|
|
194
|
-
const read = await
|
|
252
|
+
const read = await readPage(entry.url);
|
|
195
253
|
const html = decodeBody(Buffer.from(read.raw_base64, "base64"), read.content_type);
|
|
196
|
-
|
|
254
|
+
// A too_large body was cut off mid-stream: its hash is not reproducible, so raw_sha256 stays null and the
|
|
255
|
+
// prefix hash is kept under the explicit partial_sha256 name.
|
|
256
|
+
const partial = read.reason === "too_large";
|
|
257
|
+
Object.assign(page, { status: read.status, reason: read.reason, final_url: read.final_url, raw_sha256: partial ? null : read.raw_sha256, ...(partial ? { partial_sha256: read.raw_sha256 } : {}), title: titleOf(html) || page.title });
|
|
197
258
|
if (read.status === "read") {
|
|
198
259
|
page.text_blob_ref = writeBlob({ text: read.text }); page.text_sha256 = read.text_sha256; page.excerpt = read.text.slice(0, LIMITS.excerptChars);
|
|
199
260
|
page.image_candidates = read.content_type.includes("html") ? imageCandidates(html, read.final_url) : [];
|
|
200
261
|
}
|
|
201
262
|
} catch (error) {
|
|
202
263
|
page.reason = reader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
203
|
-
if (!reader.publicReadFailure(error) && !String(error?.code ?? "").startsWith("REFERENCE_WEB_")) throw error;
|
|
264
|
+
if (!reader.publicReadFailure(error) && error?.code !== "round_budget" && !String(error?.code ?? "").startsWith("REFERENCE_WEB_")) throw error;
|
|
265
|
+
}
|
|
266
|
+
// A page whose body could not be read still has the search engine's own summary: it becomes the page text, marked as such,
|
|
267
|
+
// so identification can quote it; the failure that made it necessary stays in reason.
|
|
268
|
+
const snippet = String(entry.search_snippet ?? "").trim();
|
|
269
|
+
if (page.status !== "read" && snippet) {
|
|
270
|
+
page.text_blob_ref = writeBlob({ text: snippet }); page.text_sha256 = sha256(snippet); page.excerpt = snippet.slice(0, LIMITS.excerptChars);
|
|
271
|
+
Object.assign(page, { status: "read", text_source: "search_snippet" });
|
|
204
272
|
}
|
|
205
273
|
return page;
|
|
206
274
|
});
|
|
@@ -221,7 +289,7 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
221
289
|
if (!ready.bytes) { entryImage.reason = ready.reason; continue; }
|
|
222
290
|
if (Math.min(ready.size.width, ready.size.height) < MIN_IMAGE_SIDE) { entryImage.reason = "too_small"; continue; }
|
|
223
291
|
return Object.assign(entryImage, { status: "read", reason: null, blob_ref: writeBlob({ media_type: ready.media_type, base64: ready.bytes.toString("base64") }),
|
|
224
|
-
sha256: sha256(ready.bytes), media_type: ready.media_type, measurement: ready.media_type === "image/png" ? measurePng(ready.bytes) : null });
|
|
292
|
+
sha256: sha256(ready.bytes), media_type: ready.media_type, measurement: ready.media_type === "image/png" ? measurePng(ready.bytes, { maxPixels: LIMITS.measurePixels }) : null });
|
|
225
293
|
} catch (error) {
|
|
226
294
|
entryImage.reason = imageReader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
227
295
|
if (!imageReader.publicReadFailure(error) && !String(error?.code ?? "").startsWith("REFERENCE_WEB_")) throw error;
|
|
@@ -238,10 +306,13 @@ export async function researchReference({ input, env = {}, fetchImpl, readResour
|
|
|
238
306
|
if (request.intent !== "create" && !pages.some(p => p.purpose === "identify" && p.status === "read")) unknowns.push("没有读到可用于识别产品的页面,型号保持未知");
|
|
239
307
|
if (request.intent === "create" && !pages.some(p => p.purpose === "similar" && p.status === "read")) unknowns.push("没有读到相似产品页面,可改按声学、供电、交互等子系统参考");
|
|
240
308
|
|
|
241
|
-
|
|
309
|
+
const collected = validateEvidence({
|
|
242
310
|
id, schema: SCHEMA, status: "collected", request_digest: requestDigest, intent: request.intent, keep: request.keep, changeable: request.changeable,
|
|
243
311
|
depth: request.depth, text: request.text, user_image: userImage, searches, pages, images, items, unknowns,
|
|
244
312
|
budget: { queries: searches.length, pages: pages.length, images: images.length,
|
|
245
313
|
skipped_pages: queue.skipped, skipped_images: withImage.length - wanted.length },
|
|
246
314
|
});
|
|
315
|
+
// Close the checkpoint against the digest this run last saw, so a concurrent writer is detected.
|
|
316
|
+
if (existing || paid) await save(collected);
|
|
317
|
+
return collected;
|
|
247
318
|
}
|
|
@@ -14,7 +14,7 @@ compatibility:
|
|
|
14
14
|
|
|
15
15
|
## 参与时机与交接
|
|
16
16
|
|
|
17
|
-
- 硬件未选定:先建立整机结构骨架,即按功能划分的分区与基准面(如声学腔与挡板、电子托板层与支柱、按键板、电池仓、运动关节),给出每个分区的可用空间(直径或盒体、高度预算、开口与朝向),连同用户需求及 appearance 外观目标中的尺寸、形态、开口、固定区域和运动空间约束一起供 hardware 检索;分区空间以对应槽位的 `fit_envelope` 交给 hardware:分区由锁定的外形或用户硬要求确定时 `basis` 记 `locked
|
|
17
|
+
- 硬件未选定:先建立整机结构骨架,即按功能划分的分区与基准面(如声学腔与挡板、电子托板层与支柱、按键板、电池仓、运动关节),给出每个分区的可用空间(直径或盒体、高度预算、开口与朝向),连同用户需求及 appearance 外观目标中的尺寸、形态、开口、固定区域和运动空间约束一起供 hardware 检索;分区空间以对应槽位的 `fit_envelope` 交给 hardware:分区由锁定的外形或用户硬要求确定时 `basis` 记 `locked`(可信尺寸放不下的件会交用户裁决换件或放宽分区,不会被直接拒绝;用户自己的硬尺寸上限仍写在需求 limits 里,由它负责硬拒绝),由结构推断时记 `inferred`(只影响排序),`zone` 写分区名;此时只建骨架与包络,不做完整建模,也不阻塞首张外观图。还原或修改现成产品且有拆解或官方结构资料时,骨架按来源架构建立;新创时可参考相似产品的架构;两者都标为推断。首张外观图之后,再结合真实器件尺寸确定托板、支柱、开孔和安装方式,与 hardware 往返迭代。
|
|
18
18
|
- 硬件候选出现:核对尺寸、CAD 和可复用整机结构,做初步布局;候选几何不能被称为已采用产品。
|
|
19
19
|
- 已有硬件起点:读取实际组件 ID、数量、来源及版本,复用资产并建立装配、轻量预览。
|
|
20
20
|
- 用户修改或工程结果返回:只处理受影响的参数、资产和检查。纯颜色变化通常不重建几何;轮廓、分件、开口、组件或运动改变则重判相关检查。
|
|
@@ -45,7 +45,7 @@ compatibility:
|
|
|
45
45
|
|
|
46
46
|
进程组清理保持 TERM → wait(2s) → KILL → wait。仅 Darwin 的 PermissionError 且原子进程已退出时,固定 `/bin/ps` 完整校验全表,证明原组无成员或全为同有效 UID 的 zombie 后才按已终止处理(该表接受 Darwin 的 `?` 未定运行态,它不使整表判废;原组自身成员为 `?` 仍不是 zombie,照旧失败);活后代、未知探针与其他错误仍失败。边界见[执行参考](references/build123d-execution.md)。
|
|
47
47
|
|
|
48
|
-
本包随附 Structure Worker(`worker/`)及其宿主 CLI adapter:`domain.json` 注册的 `structure.*` 能力只供宿主调用,依赖宿主设置的文件根目录与 Worker
|
|
48
|
+
本包随附 Structure Worker(`worker/`)及其宿主 CLI adapter:`domain.json` 注册的 `structure.*` 能力只供宿主调用,依赖宿主设置的文件根目录与 Worker 凭据,缺少即拒绝。`ab capability list` 会列出它们,内核不区分宿主与 Agent,能否调用只看运行环境里有没有这些变量;即使你的环境里恰好有(例如用户把 `AGENT_STRUCTURE_TOKEN` 写进了 `.env`),也不要直接调用,尤其不要调用 `structure.job-submit` / `structure.job-cancel`,那会绕开宿主的预算、账本与幂等。CAD 一律经宿主的受管工具提交;发现环境里有这些变量时告诉用户,由宿主把它们移出 Agent 可见的环境。上面三个执行辅助工具是有界前台进程,不是跨请求的后台 Worker;中断后可核验并复用已完成产物,不能恢复一个已经消失的进程。
|
|
49
49
|
|
|
50
50
|
## 还原与工程边界
|
|
51
51
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abelien-structure",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "结构域的宿主 CLI adapter:组件拟合、工程产物校验、目标件渲染与 Structure Worker 任务。只供宿主经 ab capability run 调用:文件只能在宿主设置的 ABELIEN_STRUCTURE_IO_ROOT 内读写,连 Worker 需宿主设置的 AGENT_STRUCTURE_ORIGIN / AGENT_STRUCTURE_TOKEN;缺少即拒绝。",
|
|
5
5
|
"resources": [],
|
|
6
6
|
"capabilities": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Build123d 与有界执行
|
|
2
2
|
|
|
3
|
-
> 2026-10-05 执行优先级:先核对 OSS 已有组件/子装配 CAD 并按原生比例复用,仅补建缺失定制件;确认无可用资产才考虑自建组件。宿主不支持导入应报告能力缺口,不等于允许跳过查找。当前
|
|
3
|
+
> 2026-10-05 执行优先级:先核对 OSS 已有组件/子装配 CAD 并按原生比例复用,仅补建缺失定制件;确认无可用资产才考虑自建组件。宿主不支持导入应报告能力缺口,不等于允许跳过查找。当前 Structure Worker(随本包发布于 `worker/`,宿主经 `structure.*` 能力调用)仅接受 custom_brep/envelope,须如实标记;本文执行示例不是“所有部件从零生成”的指令。目标是最快真实 CAD 预览,采用与交付另行验证。
|
|
4
4
|
|
|
5
5
|
## 先复用,再决定是否建模
|
|
6
6
|
|
|
@@ -66,4 +66,4 @@ POSIX 清理保持 TERM → wait(2s) → KILL → wait,不提高任务预算
|
|
|
66
66
|
|
|
67
67
|
## Web 接入边界
|
|
68
68
|
|
|
69
|
-
浏览器宿主通常没有任意 shell/Python。接入前需要受控任务 API
|
|
69
|
+
浏览器宿主通常没有任意 shell/Python。接入前需要受控任务 API、独立执行进程、输入版本绑定、超时取消和结果回调,再暴露结构能力。不能把分钟级建模塞进模型的一次工具调用,或将本脚本描述成已部署后台队列。产品侧的受控任务服务是随本包发布的 Structure Worker(`worker/`),由宿主部署并经 `structure.*` 能力调用,见本仓 `docs/architecture/structure-worker.md`;`scripts/` 下的工具仍只是本地有界执行辅助。
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
## 领域分工
|
|
20
20
|
|
|
21
21
|
- hardware 判定型号、电气/接口兼容及覆盖;structure 验证具体空间、安装点、操作口和几何关系。hardware 的机械规格证据不能代替实际装配检查。
|
|
22
|
-
- 无法安装时,返回受影响 slot/component、可用空间、干涉证据、替代尺寸约束及可选结构修改。没有用户授权改变硬要求时,不通过放宽要求解决。调整顺序取决于哪些约束被锁定:外形被还原目标或用户硬要求锁定时,先由 hardware 在原要求下换件,再调整骨架(分区、层高、朝向),改变外形必须交 guide 由用户取舍并记为外观偏差,不得自行放大外壳;外形未锁定时,在 appearance
|
|
22
|
+
- 无法安装时,返回受影响 slot/component、可用空间、干涉证据、替代尺寸约束及可选结构修改。没有用户授权改变硬要求时,不通过放宽要求解决。调整顺序取决于哪些约束被锁定:外形被还原目标或用户硬要求锁定时,先由 hardware 在原要求下换件,再调整骨架(分区、层高、朝向),改变外形必须交 guide 由用户取舍并记为外观偏差,不得自行放大外壳;外形未锁定时,在 appearance 认可的外观容差内调整外形与换件同等可选,把选择依据写入结果;超出容差只能换件,或交 guide 由用户取舍,不得自行放大外壳(2026-10-12 裁定)。
|
|
23
23
|
- 换型交回 hardware;算力/行为影响交回 algorithm。structure 不把局部设计建议写成新的已选 BOM。
|
|
24
24
|
- 复用整机时保持组件数量和身份,核对继承范围。自定义机械件另列,避免把整机 BOM 与其子组件重复采购。
|
|
25
25
|
- source BOM 是原产品证据,candidate BOM 是本次选型与装配;同功能替代不能写成原件。相关研究消费 hardware 的证据,不再启动第二套候选系统。
|
|
@@ -7,6 +7,9 @@ import {ENGINEERING_VERSION,ENGINEERING_CHECKS,ENGINEERING_NOT_CHECKED,ENGINEERI
|
|
|
7
7
|
import {fitAppearanceComponent,verifyAppearanceComponentFit,deriveTargetLocalPlacement} from './component-fit.mjs';
|
|
8
8
|
import {MAX_GLB_BYTES} from './component-fit-glb.mjs';
|
|
9
9
|
|
|
10
|
+
// Artifact ceilings mirror what the Worker accepts before storing a job artifact (jobs.mjs and engineering-fit.mjs:
|
|
11
|
+
// .png 4 MB, .json 128 KB, everything else - .step, .glb, .py - 24 MB). The 32 MB native RLIMIT is a process cap, not a
|
|
12
|
+
// stored-artifact size, so nothing larger can be read back.
|
|
10
13
|
const MAX_STEP_BYTES=24*1024*1024,MAX_JSON_BYTES=128*1024,MAX_PNG_BYTES=4*1024*1024,ERROR_BODY_BYTES=4096,TARGET_ERROR_BODY_BYTES=128*1024;
|
|
11
14
|
const hex=/^[a-f0-9]{64}$/,reasonPattern=/^[a-z][a-z0-9_]{0,63}$/;
|
|
12
15
|
const sha256=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
@@ -102,16 +105,33 @@ export function targetPlacement({input,env}){
|
|
|
102
105
|
return guarded(()=>({candidateToTarget:deriveTargetLocalPlacement({baseline:value.baseline,baselineGlb:readInput(root,value.baselineGlbPath,MAX_GLB_BYTES,'baselineGlbPath')})}));
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
|
|
108
|
+
/** Default client budgets. /target-image renders for up to 60 s inside the Worker (component-target-image.mjs) after the
|
|
109
|
+
* baseline upload and extraction, so its default leaves 30 s of margin; a shorter wait would report worker_result_unknown
|
|
110
|
+
* while the render is still running and invite a retry on top of it. An explicit input.timeoutMs still wins. */
|
|
111
|
+
export const WORKER_TIMEOUT_MS=Object.freeze({default:30000,targetImage:90000});
|
|
112
|
+
|
|
113
|
+
function workerConfig(env,input,defaultTimeoutMs=WORKER_TIMEOUT_MS.default){
|
|
106
114
|
const origin=env.AGENT_STRUCTURE_ORIGIN,token=env.AGENT_STRUCTURE_TOKEN;
|
|
107
115
|
let url=null;try{url=new URL(origin);}catch{/* reported below */}
|
|
108
116
|
const loopback=['127.0.0.1','localhost','[::1]'].includes(url?.hostname);
|
|
109
117
|
if(!url||url.origin!==origin||!(url.protocol==='https:'||(url.protocol==='http:'&&loopback)))
|
|
110
118
|
throw fail('STRUCTURE_HOST_ENV_MISSING','AGENT_STRUCTURE_ORIGIN must be an https origin (http only on loopback) set by the host');
|
|
111
119
|
if(typeof token!=='string'||token.length<32)throw fail('STRUCTURE_HOST_ENV_MISSING','AGENT_STRUCTURE_TOKEN must be set by the host');
|
|
112
|
-
const timeoutMs=input.timeoutMs??
|
|
120
|
+
const timeoutMs=input.timeoutMs??defaultTimeoutMs;
|
|
113
121
|
if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1000||timeoutMs>300000)throw fail('STRUCTURE_INPUT_INVALID','timeoutMs must be an integer from 1000 to 300000');
|
|
114
|
-
return {origin,token,timeoutMs};
|
|
122
|
+
return {origin,token,timeoutMs,env};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const TARGET_DIAGNOSTIC_SCHEMA='abelien.appearance-target-render-diagnostic.v1';
|
|
126
|
+
/** stdout is read by whoever ran `ab`, so the render diagnostic keeps only fields without free text: the Python stderr tail,
|
|
127
|
+
* error message and stack (which carry runtime paths) go to stderr, and only under ABELIEN_STRUCTURE_DEBUG=1. Other Worker
|
|
128
|
+
* diagnostics (source_invalid) are already whitelisted by the Worker and pass through unchanged. */
|
|
129
|
+
function publicDiagnostic(diagnostic,env){
|
|
130
|
+
if(diagnostic?.schema!==TARGET_DIAGNOSTIC_SCHEMA)return diagnostic;
|
|
131
|
+
if(env?.ABELIEN_STRUCTURE_DEBUG==='1')process.stderr.write(`structure_debug ${JSON.stringify({diagnostic})}\n`);
|
|
132
|
+
const {schema,baseline,rendererSha256,durationMs,status,exitCode,signal,stderrBytes,stderrTruncated,error}=diagnostic;
|
|
133
|
+
return {schema,baseline,rendererSha256,durationMs,status,exitCode,signal,stderrBytes,stderrTruncated,
|
|
134
|
+
error:error&&typeof error==='object'?{code:error.code,name:error.name}:null};
|
|
115
135
|
}
|
|
116
136
|
|
|
117
137
|
/** One mapping from Worker HTTP outcomes to host reasons; noEffect is true only where the Worker provably did nothing. */
|
|
@@ -127,22 +147,45 @@ async function bounded(response,maximum){
|
|
|
127
147
|
return Buffer.concat(chunks,total);
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
/** Connection-phase failures: no HTTP request reached the Worker. Anything else (reset, timeout, lost response) stays unknown. */
|
|
151
|
+
const NOT_SENT=new Set(['ECONNREFUSED','ENOTFOUND','EAI_AGAIN','EAI_NONAME','EAI_FAIL','ENETUNREACH','EHOSTUNREACH','UND_ERR_CONNECT_TIMEOUT']);
|
|
152
|
+
const neverSent=error=>{
|
|
153
|
+
const cause=error?.cause,all=Array.isArray(cause?.errors)&&cause.errors.length?cause.errors:[cause];
|
|
154
|
+
return all.every(item=>NOT_SENT.has(item?.code));
|
|
155
|
+
};
|
|
156
|
+
/** Statuses where the Worker answered before doing anything: credentials, or a request it refused to read. */
|
|
157
|
+
const AUTH_REASONS={401:'unauthorized',403:'forbidden'},REJECTED_REASONS={405:'method_not_allowed',413:'body_too_large',415:'media_type'};
|
|
158
|
+
async function errorReason(response,fallback,errorBytes){
|
|
159
|
+
let why=fallback,diagnostic;
|
|
160
|
+
try{
|
|
161
|
+
const value=JSON.parse((await bounded(response,errorBytes)).toString('utf8'));
|
|
162
|
+
if(typeof value.error==='string'&&/^[a-z][a-z0-9_]{0,47}$/.test(value.error))why=value.error;
|
|
163
|
+
if(value.diagnostic!==undefined)diagnostic=value.diagnostic;
|
|
164
|
+
}catch{/* status remains authoritative */}
|
|
165
|
+
return {why,diagnostic};
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
async function call(worker,fetchImpl,{route,scope='0'.repeat(64),body,errorBytes=ERROR_BODY_BYTES}){
|
|
131
169
|
let response;
|
|
132
170
|
try{
|
|
133
171
|
response=await fetchImpl(`${worker.origin}${route}`,{method:body===undefined?'GET':'POST',redirect:'error',cache:'no-store',
|
|
134
172
|
headers:{authorization:`Bearer ${worker.token}`,'x-structure-scope':scope,...(body===undefined?{}:{'content-type':'application/json'})},
|
|
135
173
|
...(body===undefined?{}:{body:JSON.stringify(body)}),signal:AbortSignal.timeout(worker.timeoutMs)});
|
|
136
|
-
}catch{throw workerFailure('worker_result_unknown',false);}
|
|
174
|
+
}catch(error){throw neverSent(error)?workerFailure('worker_unreachable',true):workerFailure('worker_result_unknown',false);}
|
|
175
|
+
if(AUTH_REASONS[response.status]){
|
|
176
|
+
void response.body?.cancel().catch(()=>undefined);
|
|
177
|
+
throw fail('STRUCTURE_WORKER_AUTH','worker rejected the host credentials',{reason:AUTH_REASONS[response.status],status:response.status,noEffect:true});
|
|
178
|
+
}
|
|
179
|
+
if(REJECTED_REASONS[response.status]){
|
|
180
|
+
const {why}=await errorReason(response,REJECTED_REASONS[response.status],errorBytes);
|
|
181
|
+
throw fail('STRUCTURE_WORKER_INPUT_REJECTED','worker refused to read the request',{reason:why,status:response.status,noEffect:true});
|
|
182
|
+
}
|
|
183
|
+
// A v3 Worker answers every /target-image request itself; only a Worker without the route (v2) returns 404 here.
|
|
184
|
+
if(response.status===404&&route==='/target-image')throw workerFailure('worker_protocol_mismatch',true);
|
|
137
185
|
if(response.status===404)throw workerFailure('not_found',true);
|
|
138
186
|
if([400,429,503].includes(response.status)){
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const value=JSON.parse((await bounded(response,errorBytes)).toString('utf8'));
|
|
142
|
-
if(typeof value.error==='string'&&/^[a-z][a-z0-9_]{0,47}$/.test(value.error))why=value.error;
|
|
143
|
-
if(value.diagnostic!==undefined)diagnostic=value.diagnostic;
|
|
144
|
-
}catch{/* status remains authoritative */}
|
|
145
|
-
throw workerFailure(why,true,diagnostic);
|
|
187
|
+
const {why,diagnostic}=await errorReason(response,response.status===429?'queue_full':response.status===503?'worker_unavailable':'invalid_input',errorBytes);
|
|
188
|
+
throw workerFailure(why,true,diagnostic===undefined?undefined:publicDiagnostic(diagnostic,worker.env));
|
|
146
189
|
}
|
|
147
190
|
if(!response.ok)throw workerFailure(response.status===409?'upstream_key_conflict':'worker_unavailable',false);
|
|
148
191
|
return response;
|
|
@@ -191,7 +234,7 @@ export async function artifactRead({input,env,fetchImpl}){
|
|
|
191
234
|
|
|
192
235
|
/** Rendering runs inside the Worker's runtime (v3 /target-image); the host only moves bytes and keeps the private diagnostic. */
|
|
193
236
|
export async function targetImage({input,env,fetchImpl}){
|
|
194
|
-
const value=object(input,'input'),worker=workerConfig(env,value),root=ioRoot(env);
|
|
237
|
+
const value=object(input,'input'),worker=workerConfig(env,value,WORKER_TIMEOUT_MS.targetImage),root=ioRoot(env);
|
|
195
238
|
const baselineGlb=readInput(root,value.baselineGlbPath,MAX_GLB_BYTES,'baselineGlbPath');
|
|
196
239
|
const response=await call(worker,fetchImpl,{route:'/target-image',errorBytes:TARGET_ERROR_BODY_BYTES,
|
|
197
240
|
body:{baseline:object(value.baseline,'baseline'),baselineGlb:{sha256:sha256(baselineGlb),base64:baselineGlb.toString('base64')}}});
|
|
@@ -202,6 +245,6 @@ export async function targetImage({input,env,fetchImpl}){
|
|
|
202
245
|
return bytes;
|
|
203
246
|
};
|
|
204
247
|
const png=decode('png',MAX_PNG_BYTES),targetGlb=decode('targetGlb',MAX_GLB_BYTES);
|
|
205
|
-
return {provenance:result.provenance,diagnostic:result.diagnostic??null,
|
|
248
|
+
return {provenance:result.provenance,diagnostic:publicDiagnostic(result.diagnostic??null,env),
|
|
206
249
|
png:writeOutput(root,value.outDir,'target.png',png),targetGlb:writeOutput(root,value.outDir,'target.glb',targetGlb)};
|
|
207
250
|
}
|
|
@@ -15,11 +15,22 @@ import {MAX_GLB_BYTES} from './component-fit-glb.mjs';
|
|
|
15
15
|
const hex=/^[a-f0-9]{64}$/;
|
|
16
16
|
const TARGET_IMAGE_MAX_BODY_BYTES=Math.ceil(MAX_GLB_BYTES/3)*4+65536;
|
|
17
17
|
const sha256=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
/** Renders are CPU-bound Python processes; more callers than this get 429 target_renderer_busy instead of queueing. */
|
|
19
|
+
export const TARGET_IMAGE_MAX_CONCURRENT=2;
|
|
18
20
|
/** Data-only target render inside the Worker runtime; the private diagnostic goes back to the trusted host only. */
|
|
19
|
-
async function targetImage(req,reply,renderer){
|
|
21
|
+
async function targetImage(req,res,reply,renderer,slots){
|
|
20
22
|
if(req.method!=='POST')return reply(405,{error:'method_not_allowed'});
|
|
21
23
|
if(req.headers['content-type']?.split(';')[0]!=='application/json')return reply(415,{error:'media_type'});
|
|
22
24
|
if(!renderer)return reply(503,{error:'target_renderer_unavailable'});
|
|
25
|
+
if(slots.active>=TARGET_IMAGE_MAX_CONCURRENT)return reply(429,{error:'target_renderer_busy'});
|
|
26
|
+
slots.active++;
|
|
27
|
+
// The response closing before it was written means the client went away: stop the render instead of finishing unseen work.
|
|
28
|
+
const controller=new AbortController(),gone=()=>{if(!res.writableFinished)controller.abort();};
|
|
29
|
+
res.once('close',gone);
|
|
30
|
+
try{return await renderTarget(req,reply,renderer,controller.signal);}
|
|
31
|
+
finally{slots.active--;res.off('close',gone);}
|
|
32
|
+
}
|
|
33
|
+
async function renderTarget(req,reply,renderer,signal){
|
|
23
34
|
const chunks=[];let size=0;
|
|
24
35
|
for await(const chunk of req){size+=chunk.length;if(size>TARGET_IMAGE_MAX_BODY_BYTES)return reply(413,{error:'body_too_large'});chunks.push(chunk);}
|
|
25
36
|
let body;try{body=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{return reply(400,{error:'invalid_input'});}
|
|
@@ -28,7 +39,7 @@ async function targetImage(req,reply,renderer){
|
|
|
28
39
|
||!bytes.length||!hex.test(glb.sha256??'')||sha256(bytes)!==glb.sha256)return reply(400,{error:'invalid_input'});
|
|
29
40
|
let diagnostic=null;
|
|
30
41
|
try{
|
|
31
|
-
const rendered=await renderer({baseline:body.baseline,baselineGlb:bytes},{onDiagnostic:event=>{diagnostic=event;}});
|
|
42
|
+
const rendered=await renderer({baseline:body.baseline,baselineGlb:bytes},{signal,onDiagnostic:event=>{diagnostic=event;}});
|
|
32
43
|
return reply(200,{provenance:rendered.provenance,diagnostic,png:{sha256:sha256(rendered.png),base64:rendered.png.toString('base64')},
|
|
33
44
|
targetGlb:{sha256:sha256(rendered.targetGlb),base64:rendered.targetGlb.toString('base64')}});
|
|
34
45
|
}catch(error){
|
|
@@ -43,7 +54,7 @@ export function createHandler(queue, token, health={ok:true}, sourceContract=nul
|
|
|
43
54
|
if(!legacy&&!current)throw new Error('structure_health_identity_invalid');
|
|
44
55
|
if(sourceContract!==null&&(!current||sourceContract.schema!==STRUCTURE_SOURCE_CONTRACT_SCHEMA))throw new Error('structure_source_contract_invalid');
|
|
45
56
|
const contract=sourceContract===null?null:JSON.parse(JSON.stringify(sourceContract));
|
|
46
|
-
const expected = Buffer.from(`Bearer ${token}`);
|
|
57
|
+
const expected = Buffer.from(`Bearer ${token}`),renderSlots={active:0};
|
|
47
58
|
return async (req,res) => {
|
|
48
59
|
const reply = (status,value) => { res.writeHead(status, {'content-type':'application/json','cache-control':'no-store'}); res.end(JSON.stringify(value)); };
|
|
49
60
|
try {
|
|
@@ -53,7 +64,7 @@ export function createHandler(queue, token, health={ok:true}, sourceContract=nul
|
|
|
53
64
|
if (url.pathname === '/health' && req.method === 'GET') return reply(200,health);
|
|
54
65
|
if (url.pathname === '/source-contract' && req.method === 'GET')
|
|
55
66
|
return contract?reply(200,{health,contract}):reply(503,{error:'source_contract_unavailable'});
|
|
56
|
-
if (url.pathname === '/target-image') return targetImage(req,reply,targetRenderer);
|
|
67
|
+
if (url.pathname === '/target-image') return targetImage(req,res,reply,targetRenderer,renderSlots);
|
|
57
68
|
const upstream=/^\/jobs\/by-upstream-key\/([a-f0-9]{64})$/.exec(url.pathname),scope=req.headers['x-structure-scope'];
|
|
58
69
|
if(upstream){if(req.method!=='GET')return reply(405,{error:'method_not_allowed'});return reply(200,queue.byUpstreamKey(scope,upstream[1]));}
|
|
59
70
|
const match = /^\/jobs(?:\/([a-f0-9-]+)(?:\/(cancel|artifacts\/([a-z.]+)))?)?$/.exec(url.pathname);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
5
6
|
import {DEFAULT_STRUCTURE_PORT} from './config.mjs';
|
|
6
7
|
|
|
7
8
|
export function readWorkerConfig(env) {
|
|
@@ -9,12 +10,15 @@ export function readWorkerConfig(env) {
|
|
|
9
10
|
const root = env.STRUCTURE_JOB_ROOT ?? (backend === 'docker' ? '/data' : '');
|
|
10
11
|
const port = Number(env.STRUCTURE_PORT ?? DEFAULT_STRUCTURE_PORT), timeoutMs = Number(env.STRUCTURE_TIMEOUT_MS ?? 180000);
|
|
11
12
|
const symbolPrecheck=env.STRUCTURE_SOURCE_SYMBOL_PRECHECK_ENABLED??'false';
|
|
13
|
+
// Plain HTTP carries the Bearer token, so every backend listens on loopback by default. Another address is an explicit
|
|
14
|
+
// operator opt-in (an IP literal, e.g. 0.0.0.0 inside a supervisor container whose port is published only to 127.0.0.1).
|
|
15
|
+
const host=env.STRUCTURE_LISTEN_HOST??'127.0.0.1';
|
|
12
16
|
if (!['docker', 'native-macos'].includes(backend) || typeof token !== 'string' || token.length < 32 ||
|
|
13
17
|
!path.isAbsolute(root) || !Number.isSafeInteger(port) || port < 1 || port > 65535 ||
|
|
14
18
|
!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 300000 ||
|
|
15
|
-
!['true','false'].includes(symbolPrecheck)||(backend === 'native-macos' && !path.isAbsolute(env.STRUCTURE_NATIVE_RUNTIME ?? ''))) throw new Error('structure_configuration_invalid');
|
|
19
|
+
!['true','false'].includes(symbolPrecheck)||!isIP(host)||(backend==='native-macos'&&!['127.0.0.1','::1'].includes(host))||(backend === 'native-macos' && !path.isAbsolute(env.STRUCTURE_NATIVE_RUNTIME ?? ''))) throw new Error('structure_configuration_invalid');
|
|
16
20
|
return { backend, token, root, port, timeoutMs, runtime: env.STRUCTURE_NATIVE_RUNTIME,sourceSymbolPrecheck:symbolPrecheck==='true',
|
|
17
|
-
image: env.STRUCTURE_RUNTIME_IMAGE ?? 'abelien-structure-runtime:local', host
|
|
21
|
+
image: env.STRUCTURE_RUNTIME_IMAGE ?? 'abelien-structure-runtime:local', host };
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function acquireSupervisorLock(root) {
|
|
@@ -49,7 +49,7 @@ node <skills目录>/abelien-harness/scripts/ab.mjs --workdir <作品工作目录
|
|
|
49
49
|
--input '{"intent":"restore","artifacts":{"reference_final":true}}'
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
它是纯计算,返回下一阶段、目标与 `directives`。宿主先跑完自己的守卫,再把 `directives` 原样附在自己的执行说明(工具名、字段)之后。restore 默认锁定外形;锁定时,CAD
|
|
52
|
+
它是纯计算,返回下一阶段、目标与 `directives`。宿主先跑完自己的守卫,再把 `directives` 原样附在自己的执行说明(工具名、字段)之后。restore 默认锁定外形;锁定时,CAD 阶段的规则会写明不得放大外壳;未锁定时只允许在 appearance 认可的外观容差内调整外壳,超出容差换件或交用户取舍。意图的划分见 [意图路由](../../docs/architecture/intent-routing.md)。阶段计划是[完整用户旅程](references/user-journey.md)中 A1 通过之后到交付首版这一段(及其旁路)的机读派生,规则正文以 user-journey.md 为准;本能力在[能力映射](references/harness-binding.md#当前能力映射)中登记。
|
|
53
53
|
|
|
54
54
|
本包定义作品事实之间的关系,不表示已实现完整的产品后台。现有可用能力和未接入项集中维护在[能力映射](references/harness-binding.md),不使用旧 `workspace.*` mock 顶替。
|
|
55
55
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "abelien.journey-stage-plan.v1",
|
|
3
|
-
"plan_version": "2026-10-12.
|
|
4
|
-
"description": "按意图的作品旅程阶段计划。orders 是自动推进的主线;side 阶段由宿主守卫按真实结果触发。每个阶段的 directives
|
|
3
|
+
"plan_version": "2026-10-12.5",
|
|
4
|
+
"description": "按意图的作品旅程阶段计划。orders 是自动推进的主线;side 阶段由宿主守卫按真实结果触发。每个阶段的 directives 是领域规则(宿主中立),宿主把它们附在自己的执行说明(工具名、字段)之后,不改写、不删减。旅程规则的唯一正文是 references/user-journey.md,本计划是其机读派生:orders 对应 A1 通过之后到交付首版(§2.4、§5),side 对应首版前后由真实结果触发的旁路(§5.3、§6、§7);两处不一致时以正文为准,改正文后同步本计划并升 plan_version。",
|
|
5
5
|
"artifacts": ["reference_final", "concept_image", "hardware", "appearance_baseline", "cad_candidate", "review"],
|
|
6
6
|
"orders": {
|
|
7
7
|
"create": ["reference", "concept", "hardware", "real_image", "cad", "review"],
|
|
@@ -24,23 +24,26 @@
|
|
|
24
24
|
"goal": "识别用户要的现成产品,并取得规格与逆向资料",
|
|
25
25
|
"directives": [
|
|
26
26
|
"调用 reference.research 执行识别与逆向资料检索(用途 identify / teardown;用户给了链接时先读链接),再读取证据,用 reference.finalize 提交识别判断。",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
27
|
+
"识别不确定时按最可能的候选继续,保留其他候选供用户切换;首版之前默认不向用户提问。",
|
|
28
|
+
"例外:用户的硬要求缺少取值、无法合理默认,且按默认继续会做出错误结果(例如要求放进某个卡槽却没给尺寸)时,可以在首版之前发一张问题卡;整个首版之前至多一张,最多三题、每题最多三个选项;其余未定项一律按默认处理。",
|
|
29
|
+
"完成后展示识别出的原产品图并注明来源(页面与站点),再用一两句话说明识别为哪款产品、依据是什么,以及比例核对结果。"
|
|
29
30
|
]
|
|
30
31
|
},
|
|
31
32
|
"reference": {
|
|
32
33
|
"goal": "检索相似产品,作为尺寸、结构、器件与实现方式的参考",
|
|
33
34
|
"directives": [
|
|
34
35
|
"调用 reference.research 检索相似产品(用途 similar),再读取证据,用 reference.finalize 提交借用什么、为什么合适、哪些不照搬。",
|
|
35
|
-
"
|
|
36
|
-
"相似产品只作参考,不成为可编辑模板,也不自动采用其 BOM。"
|
|
36
|
+
"找不到完整的相似产品时,按声学、供电、交互等子系统分别参考,不硬凑数量;首版之前默认不向用户提问。",
|
|
37
|
+
"相似产品只作参考,不成为可编辑模板,也不自动采用其 BOM。",
|
|
38
|
+
"例外:用户的硬要求缺少取值、无法合理默认,且按默认继续会做出错误结果(例如要求放进某个卡槽却没给尺寸)时,可以在首版之前发一张问题卡;整个首版之前至多一张,最多三题、每题最多三个选项;其余未定项一律按默认处理。"
|
|
37
39
|
]
|
|
38
40
|
},
|
|
39
41
|
"concept": {
|
|
40
42
|
"goal": "生成第一张概念图",
|
|
41
43
|
"directives": [
|
|
42
|
-
"
|
|
43
|
-
"出图后用一两句话告诉用户并结束本轮,硬件选型在下一阶段进行。"
|
|
44
|
+
"按外观目标生成第一张概念图;需求没说到的外观细节按常见做法补全,首版之前默认不向用户提问。",
|
|
45
|
+
"出图后用一两句话告诉用户并结束本轮,硬件选型在下一阶段进行。",
|
|
46
|
+
"例外:用户的硬要求缺少取值、无法合理默认,且按默认继续会做出错误结果(例如要求放进某个卡槽却没给尺寸)时,可以在首版之前发一张问题卡;整个首版之前至多一张,最多三题、每题最多三个选项;其余未定项一律按默认处理。"
|
|
44
47
|
],
|
|
45
48
|
"variants": {
|
|
46
49
|
"modify": [
|
|
@@ -52,15 +55,17 @@
|
|
|
52
55
|
"hardware": {
|
|
53
56
|
"goal": "为当前需求选型并登记",
|
|
54
57
|
"directives": [
|
|
55
|
-
"
|
|
58
|
+
"主要元器件选定即可;供电、电池、联网方式等用户没说的未定项按推荐默认值选型,并作为待确认项登记,首版之前默认不向用户提问。",
|
|
56
59
|
"不要修改需求,也不要出图。",
|
|
57
|
-
"登记后用一两句话说明选了什么、哪些按默认值处理,然后结束本轮;待确认项在首版之后合并成一张问题卡统一询问。"
|
|
60
|
+
"登记后用一两句话说明选了什么、哪些按默认值处理,然后结束本轮;待确认项在首版之后合并成一张问题卡统一询问。",
|
|
61
|
+
"例外:用户的硬要求缺少取值、无法合理默认,且按默认继续会做出错误结果(例如要求放进某个卡槽却没给尺寸)时,可以在首版之前发一张问题卡;整个首版之前至多一张,最多三题、每题最多三个选项;其余未定项一律按默认处理。"
|
|
58
62
|
],
|
|
59
63
|
"variants": {
|
|
60
64
|
"restore": [
|
|
61
|
-
"
|
|
65
|
+
"以参考证据中的来源产品规格与拆解资料为对照逐项选择替代件,区分原件、替代件与未知;主要元器件选定即可,未定项按推荐默认值登记为待确认项,首版之前默认不向用户提问。",
|
|
62
66
|
"不要修改需求,也不要出图。",
|
|
63
|
-
"登记后用一两句话说明替代关系与默认值,然后结束本轮;待确认项在首版之后合并成一张问题卡统一询问。"
|
|
67
|
+
"登记后用一两句话说明替代关系与默认值,然后结束本轮;待确认项在首版之后合并成一张问题卡统一询问。",
|
|
68
|
+
"例外:用户的硬要求缺少取值、无法合理默认,且按默认继续会做出错误结果(例如要求放进某个卡槽却没给尺寸)时,可以在首版之前发一张问题卡;整个首版之前至多一张,最多三题、每题最多三个选项;其余未定项一律按默认处理。"
|
|
64
69
|
]
|
|
65
70
|
}
|
|
66
71
|
},
|
|
@@ -110,7 +115,7 @@
|
|
|
110
115
|
],
|
|
111
116
|
"locks": {
|
|
112
117
|
"shape": {
|
|
113
|
-
"false": "
|
|
118
|
+
"false": "外形未锁定:硬件放得下优先,但外壳只能在 appearance 认可的外观容差内调整尺寸与比例,并记录调整量及依据;超出容差时换件(交回硬件)或把取舍交给用户,不得自行放大外壳。",
|
|
114
119
|
"true": "外形尺寸与比例已锁定:不得放大或缩小外壳;装不下时停止建模,先交回硬件换件,仍不行再调整结构骨架,改外形必须由用户决定。"
|
|
115
120
|
}
|
|
116
121
|
}
|
|
@@ -143,9 +148,10 @@
|
|
|
143
148
|
]
|
|
144
149
|
},
|
|
145
150
|
"questions": {
|
|
146
|
-
"goal": "
|
|
151
|
+
"goal": "首版之后合并询问按默认处理的事项",
|
|
147
152
|
"directives": [
|
|
148
|
-
"
|
|
153
|
+
"首版之后,读取已登记的硬件选型与系统假设,把首版之前按默认处理、至今仍按默认处理的事项合并成一张问题卡:最多三题,每题最多三个可直接选的方案,第一个选项写首版当前采用的默认值并注明“(首版已按此)”。",
|
|
154
|
+
"这张卡每次按当前状态重新生成,不是保存的问题队列:用户已改掉或已不再适用的默认不再出现,首版之前按例外问过并已答复的事项不重复询问。",
|
|
149
155
|
"意图、目标深度或型号识别仍有疑问时,可以作为其中一题,第一个选项写首版采用的判断。",
|
|
150
156
|
"没有待确认项就不发卡,一两句话说明后结束本轮。",
|
|
151
157
|
"不要修改需求、重新选型、建模或出图;用户的答复作为对首版的修订处理。"
|
|
@@ -29,7 +29,9 @@ skills/
|
|
|
29
29
|
abelien-harness/scripts/ab.mjs 通用 CLI
|
|
30
30
|
abelien-workspace/SKILL.md 作品业务契约入口
|
|
31
31
|
abelien-workspace/references/ 完整旅程、PRD 契约、领域模型与本调用说明
|
|
32
|
-
abelien-workspace/domain.json
|
|
32
|
+
abelien-workspace/domain.json 作品域、真实硬件阶段 draft 资源与 workspace.journey-next 声明
|
|
33
|
+
abelien-workspace/assets/ 按意图的阶段计划 journey-stage-plan.json(机读,派生自 user-journey.md)
|
|
34
|
+
abelien-workspace/scripts/ journey-next.mjs(阶段计划解释器,纯计算)
|
|
33
35
|
abelien-workspace/workflows/ 已可执行的线性工作流
|
|
34
36
|
abelien-hardware-selection/SKILL.md 真实硬件阶段的分类与服务指引
|
|
35
37
|
```
|
|
@@ -53,6 +55,7 @@ stdout 的 `ok:true` 表示命令取得了回执。workflow 调用还必须检
|
|
|
53
55
|
|
|
54
56
|
| 用户旅程所需能力 | 当前 Harness 对应与限制 |
|
|
55
57
|
| --- | --- |
|
|
58
|
+
| 按意图的下一阶段与阶段规则 | `workspace.journey-next`(本包 `domain.json` 唯一的 capability,`ab capability run workspace.journey-next --input '{"intent":"restore","artifacts":{"reference_final":true}}'`):读取[阶段计划](../assets/journey-stage-plan.json),返回下一阶段、`goal`、`directives`、`order`、`completed`、`locks` 与 `plan_version`。纯计算,不联网、不写资源、不读宿主状态;不判断 A1–C,也不代替宿主的结算、计费、预算、保底与幂等守卫。`stage` 只接受本意图的主线阶段与 `side` 旁路阶段,`artifacts`/`locks` 不用时省略、不传 null。阶段计划派生自[完整旅程](user-journey.md),规则以旅程正文为准 |
|
|
56
59
|
| clarify 需求整理 | 同级 `abelien-clarify`:以当前操作和同版上下文换取 `result / questions / note`,不是 CLI capability,不写资源;结构可用其 `scripts/check_result.py` 校验。共用表达规则在同级 guide 的 references,由 guide 读一次注入。已移除旧 workspace.brief,不提供假澄清 |
|
|
57
60
|
| 硬件候选检索与提案 | `user-journey-hardware-candidates` → `abelien-hardware`:`hardware.search`(TOS 检索)→ agent 比较整机与零部件候选 → `hardware.finalize`(保存 `hardware-proposal`)。提案不创建正式 `hardware-selection` 门,也不能直接开始算法阶段 |
|
|
58
61
|
| 正式硬件选型 | `user-journey-hardware` / `user-journey-hardware-algorithm` → `abelien-hardware-selection` → `hardware.select`;本地模块回读真实召回并校验 Agent 判断,返回 hardware-selection;证据不足不可用候选提案代替 |
|