abelien-harness 0.9.0 → 0.10.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/package.json +1 -1
- package/skills/abelien-algorithm/SKILL.md +14 -6
- package/skills/abelien-algorithm/domain.json +59 -5
- package/skills/abelien-algorithm/references/research-policy.md +11 -3
- package/skills/abelien-algorithm/references/selection-service-contract.md +50 -0
- package/skills/abelien-algorithm/schemas/algorithm-bindings.v1.json +414 -0
- package/skills/abelien-algorithm/schemas/algorithm-candidates.v1.json +192 -0
- package/skills/abelien-algorithm/scripts/candidates.mjs +119 -0
- package/skills/abelien-algorithm/scripts/evidence.mjs +69 -0
- package/skills/abelien-algorithm/scripts/finalize-selection.mjs +64 -7
- package/skills/abelien-algorithm/scripts/lane-state.mjs +27 -0
- package/skills/abelien-algorithm/scripts/research.mjs +203 -0
- package/skills/abelien-harness/scripts/ab.mjs +38 -3
- package/skills/abelien-workspace/assets/lane-plan.json +36 -0
- package/skills/abelien-workspace/domain.json +22 -1
- package/skills/abelien-workspace/scripts/lanes-next.mjs +38 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// algorithm.research(P2-03 算法并行车道):按 Agent 的检索计划执行确定性的收集——Volc 检索与公开页面安全直读——
|
|
2
|
+
// 全部落为证据,不做选择。两种用途:implementation 只凭需求检索实现资料,可与硬件选型同时开始;hardware 在硬件
|
|
3
|
+
// 登记后检索已选型号的厂商资料,查询必须指向选型资源里的 component_id。
|
|
4
|
+
// 付费检索逐条「先存 sent 再发送」,与 reference.research 同一规则:重跑复用已完成的检索,停在 sent 而没有结果的
|
|
5
|
+
// 检索要求对账,绝不自动重发;同一请求完成后重复运行直接返回已有结果。
|
|
6
|
+
import { Buffer } from "node:buffer";
|
|
7
|
+
import { text, record, digest, sha256, failure } from "../../abelien-harness/scripts/web/common.mjs";
|
|
8
|
+
import { decodeBody } from "../../abelien-harness/scripts/web/public-read.mjs";
|
|
9
|
+
import { SCHEMA, PURPOSES, RESEARCH_SHAPE, LIMITS, reader, volc, invalid, words, readRequirements, validateResearch } from "./evidence.mjs";
|
|
10
|
+
|
|
11
|
+
const reconcile = message => failure("ALGORITHM_WEB_RECONCILIATION_REQUIRED", message);
|
|
12
|
+
|
|
13
|
+
function readLibrary(library) {
|
|
14
|
+
if (library === undefined || library === null) return [];
|
|
15
|
+
if (!Array.isArray(library) || library.length > LIMITS.library) invalid(`library must list up to ${LIMITS.library} in-library algorithms`);
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
return library.map((entry, index) => {
|
|
18
|
+
if (!record(entry) || !text(entry.entry_id) || ids.has(entry.entry_id)) invalid(`library[${index}] needs a unique entry_id`);
|
|
19
|
+
ids.add(entry.entry_id);
|
|
20
|
+
return { entry_id: entry.entry_id, name: words(entry.name, `library[${index}].name`, 200), category: text(entry.category) ? entry.category.trim().slice(0, 200) : null };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The hardware the vendor queries may name: exact models of a hardware-selection resource, verified or not. */
|
|
25
|
+
async function readHardware(input, readResource) {
|
|
26
|
+
if (!text(input.hardware_selection_id) || !text(input.hardware_selection_revision)) invalid("hardware research needs hardware_selection_id and hardware_selection_revision");
|
|
27
|
+
const resource = await readResource(input.hardware_selection_id);
|
|
28
|
+
if (!resource || resource.kind !== "hardware-selection" || resource.body?.revision !== input.hardware_selection_revision) invalid("hardware selection is missing or its revision is stale");
|
|
29
|
+
const models = Array.isArray(resource.body.selected_models) ? resource.body.selected_models : [];
|
|
30
|
+
if (!models.length || models.some(item => !text(item?.component_id) || !text(item?.manufacturer) || !text(item?.model))) invalid("hardware selection needs exact component IDs, manufacturers and models");
|
|
31
|
+
return { selection_id: input.hardware_selection_id, revision: input.hardware_selection_revision, status: resource.body.status ?? null,
|
|
32
|
+
models: models.map(item => ({ component_id: item.component_id, manufacturer: item.manufacturer, model: item.model })) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateResearchInput(input) {
|
|
36
|
+
if (!record(input)) invalid("input must be an object");
|
|
37
|
+
const allowed = ["purpose", "requirements", "library", "hardware_selection_id", "hardware_selection_revision", "plan"];
|
|
38
|
+
if (Object.keys(input).some(key => !allowed.includes(key))) invalid("input has unsupported fields");
|
|
39
|
+
if (!PURPOSES.includes(input.purpose)) invalid(`purpose must be one of ${PURPOSES.join(", ")}`);
|
|
40
|
+
const plan = input.plan ?? {};
|
|
41
|
+
if (!record(plan) || Object.keys(plan).some(key => !["queries", "max_pages"].includes(key))) invalid("plan has unsupported fields");
|
|
42
|
+
const queries = plan.queries ?? [];
|
|
43
|
+
if (!Array.isArray(queries) || !queries.length || queries.length > LIMITS.queries) invalid(`plan.queries must list 1–${LIMITS.queries} queries`);
|
|
44
|
+
const maxPages = plan.max_pages === undefined ? 8 : plan.max_pages;
|
|
45
|
+
if (!Number.isSafeInteger(maxPages) || maxPages < 0 || maxPages > LIMITS.pages) invalid(`plan.max_pages must be 0–${LIMITS.pages}`);
|
|
46
|
+
return { purpose: input.purpose, requirements: readRequirements(input.requirements), library: input.purpose === "implementation" ? readLibrary(input.library) : [],
|
|
47
|
+
queries, maxPages };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Interleave search results so one query cannot use the whole page budget; counts what the cap left unread. */
|
|
51
|
+
function pageQueue(searches, max) {
|
|
52
|
+
const queue = [], seen = new Set();
|
|
53
|
+
let skipped = 0;
|
|
54
|
+
for (let rank = 0; ; rank++) {
|
|
55
|
+
let added = false;
|
|
56
|
+
for (const search of searches) {
|
|
57
|
+
const result = search.results[rank];
|
|
58
|
+
if (!result) continue;
|
|
59
|
+
added = true;
|
|
60
|
+
let url;
|
|
61
|
+
try { url = reader.normalizePublicSourceUrl(result.url).href; } catch { continue; }
|
|
62
|
+
if (seen.has(url)) continue;
|
|
63
|
+
seen.add(url);
|
|
64
|
+
if (queue.length < max) queue.push({ url, search, title: result.title, snippet: result.snippet ?? "" }); else skipped++;
|
|
65
|
+
}
|
|
66
|
+
if (!added) break;
|
|
67
|
+
}
|
|
68
|
+
return Object.assign(queue, { skipped });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function pool(list, task, limit = LIMITS.concurrency) {
|
|
72
|
+
const out = new Array(list.length); let next = 0, failed = false;
|
|
73
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
74
|
+
while (!failed && next < list.length) {
|
|
75
|
+
const index = next++;
|
|
76
|
+
try { out[index] = await task(list[index], index); } catch (error) { failed = true; throw error; }
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function researchAlgorithms({ input, env = {}, fetchImpl, readResource, writeBlob, ports = {}, checkpoint }) {
|
|
83
|
+
const request = validateResearchInput(input);
|
|
84
|
+
if (typeof writeBlob !== "function" || typeof readResource !== "function") throw failure("ALGORITHM_HOST", "trusted content writer and resource reader are required");
|
|
85
|
+
const hardware = request.purpose === "hardware" ? await readHardware(input, readResource) : null;
|
|
86
|
+
const reactionIds = new Set(request.requirements.reactions.map(item => item.id));
|
|
87
|
+
const componentIds = new Set(hardware?.models.map(item => item.component_id) ?? []);
|
|
88
|
+
const queries = request.queries.map((q, index) => {
|
|
89
|
+
const at = `plan.queries[${index}]`;
|
|
90
|
+
if (!record(q) || Object.keys(q).some(key => !["reaction_id", "component_id", "query", "count"].includes(key))) invalid(`${at} has unsupported fields`);
|
|
91
|
+
const query = words(q.query, `${at}.query`, LIMITS.queryChars);
|
|
92
|
+
if (q.count !== undefined && (!Number.isSafeInteger(q.count) || q.count < 1 || q.count > LIMITS.count)) invalid(`${at}.count must be 1–${LIMITS.count}`);
|
|
93
|
+
// Implementation queries belong to one reaction; vendor queries to one selected model.
|
|
94
|
+
if (request.purpose === "implementation" && (!reactionIds.has(q.reaction_id) || q.component_id !== undefined)) invalid(`${at} must name one requirements reaction_id and no component_id`);
|
|
95
|
+
if (request.purpose === "hardware" && (!componentIds.has(q.component_id) || (q.reaction_id !== undefined && !reactionIds.has(q.reaction_id)))) invalid(`${at} must name a selected component_id`);
|
|
96
|
+
return { reaction_id: q.reaction_id ?? null, component_id: q.component_id ?? null, query, count: q.count ?? LIMITS.count };
|
|
97
|
+
});
|
|
98
|
+
const requestDigest = digest({ purpose: request.purpose, requirements: request.requirements, library: request.library, hardware, queries, max_pages: request.maxPages, shape: RESEARCH_SHAPE });
|
|
99
|
+
const id = `alg-res-${requestDigest.slice(0, 16)}`;
|
|
100
|
+
const existing = await readResource(`algorithm-research:${id}`);
|
|
101
|
+
if (existing?.body?.status === "collected" && existing.body.request_digest === requestDigest) {
|
|
102
|
+
try { return validateResearch(existing.body); } catch { throw reconcile("saved research fails the current contract; reconcile it instead of re-running the paid searches"); }
|
|
103
|
+
}
|
|
104
|
+
const sameQuery = (entry, q, index) => record(entry) && entry.id === `s${index + 1}` && entry.query === q.query && entry.count === q.count
|
|
105
|
+
&& entry.reaction_id === q.reaction_id && entry.component_id === q.component_id;
|
|
106
|
+
if (existing && (existing.body?.status !== "in_progress" || existing.body.request_digest !== requestDigest || !Array.isArray(existing.body.searches)
|
|
107
|
+
|| existing.body.searches.length > queries.length || existing.body.searches.some((entry, index) => !sameQuery(entry, queries[index], index))))
|
|
108
|
+
throw reconcile("saved research does not match this request; reconcile the original invocation instead of re-running it");
|
|
109
|
+
// Fail before anything is marked sent: a missing or malformed key cannot have produced a charge.
|
|
110
|
+
if (!text(env.WEB_SEARCH_API_KEY) || /[\r\n\0]/.test(env.WEB_SEARCH_API_KEY)) throw failure("ALGORITHM_WEB_CONFIG", "WEB_SEARCH_API_KEY is required from the trusted host");
|
|
111
|
+
if (typeof checkpoint !== "function") throw failure("ALGORITHM_WEB_CHECKPOINT_REQUIRED", "paid search IO requires the original resource store checkpoint");
|
|
112
|
+
let expectedDigest = existing?.meta?.digest ?? null;
|
|
113
|
+
const save = async body => { expectedDigest = (await checkpoint(body, { expectedDigest })).meta.digest; };
|
|
114
|
+
|
|
115
|
+
const unknowns = [];
|
|
116
|
+
const base = { id, schema: SCHEMA, request_digest: requestDigest, purpose: request.purpose, context: request.requirements.context,
|
|
117
|
+
reactions: request.requirements.reactions, limits: request.requirements.limits, library: request.library, hardware };
|
|
118
|
+
const progress = { ...base, status: "in_progress", searches: structuredClone(existing?.body.searches ?? []), pages: [], unknowns: [] };
|
|
119
|
+
const searches = progress.searches;
|
|
120
|
+
const keepReceipt = (entry, saved) => {
|
|
121
|
+
if (!saved || typeof saved.raw !== "string") return;
|
|
122
|
+
if (saved.raw.includes(env.WEB_SEARCH_API_KEY)) throw failure("ALGORITHM_WEB_RESPONSE", "search receipt still contains the key after redaction");
|
|
123
|
+
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 });
|
|
124
|
+
};
|
|
125
|
+
for (const [index, q] of queries.entries()) {
|
|
126
|
+
let entry = searches[index];
|
|
127
|
+
if (entry && ["completed", "failed"].includes(entry.status)) {
|
|
128
|
+
if (entry.status === "completed" && !entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
129
|
+
if (entry.status === "failed") unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
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`);
|
|
133
|
+
const body = volc.volcQueryRequest({ query: q.query, count: q.count });
|
|
134
|
+
volc.validateVolcCapability(body);
|
|
135
|
+
entry = { id: `s${index + 1}`, reaction_id: q.reaction_id, component_id: q.component_id, query: q.query, count: q.count, request: body, dispatch_state: "sent",
|
|
136
|
+
sent_at: new Date().toISOString(), status: "in_progress", error_code: null, outcome: null, request_id: null, receipt_sha256: null, receipt_blob_ref: null, results: [] };
|
|
137
|
+
searches[index] = entry;
|
|
138
|
+
await save(progress);
|
|
139
|
+
try {
|
|
140
|
+
const saved = await volc.volcWebSearch(body, { env, fetchImpl, timeoutMs: LIMITS.searchTimeoutMs });
|
|
141
|
+
keepReceipt(entry, saved);
|
|
142
|
+
entry.status = "completed"; entry.outcome = "received";
|
|
143
|
+
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) }));
|
|
144
|
+
if (!entry.results.length) unknowns.push(`检索「${q.query}」没有结果`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!String(error?.code ?? "").startsWith("ALGORITHM_WEB_")) throw error;
|
|
147
|
+
keepReceipt(entry, error.providerEvidence);
|
|
148
|
+
entry.status = "failed"; entry.error_code = error.code; entry.outcome = error.ambiguous || !error.providerEvidence ? "unknown" : "rejected";
|
|
149
|
+
unknowns.push(`检索「${q.query}」失败(${entry.error_code})`);
|
|
150
|
+
}
|
|
151
|
+
await save(progress);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const readOptions = { lookup: ports.lookup, pageRequest: ports.pageRequest, dnsRequest: ports.dnsRequest };
|
|
155
|
+
const queue = pageQueue(searches, request.maxPages);
|
|
156
|
+
const now = ports.now ?? Date.now, roundEnds = now() + LIMITS.pageRoundMs;
|
|
157
|
+
const readPage = async url => {
|
|
158
|
+
for (let attempt = 0; ; attempt++) {
|
|
159
|
+
const left = roundEnds - now();
|
|
160
|
+
if (left < LIMITS.pageConnectTimeoutMs) throw Object.assign(new Error("page round budget spent"), { code: "round_budget" });
|
|
161
|
+
try {
|
|
162
|
+
return await reader.fetchPublicPage(url, { ...readOptions, timeoutMs: Math.min(LIMITS.pageTimeoutMs, left), connectTimeoutMs: LIMITS.pageConnectTimeoutMs, maxBytes: LIMITS.pageBytes });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const stage = reader.publicReadFailure(error)?.stage;
|
|
165
|
+
if (attempt || !["timeout", "transport"].includes(stage)) throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const pages = await pool(queue, async (entry, index) => {
|
|
170
|
+
const page = { id: `p${index + 1}`, url: entry.url, final_url: null, search_id: entry.search.id, reaction_id: entry.search.reaction_id, component_id: entry.search.component_id,
|
|
171
|
+
status: "unavailable", reason: null, title: entry.title ?? "", fetched_at: null, text_blob_ref: null, text_sha256: null, text_source: null, excerpt: "" };
|
|
172
|
+
try {
|
|
173
|
+
const read = await readPage(entry.url);
|
|
174
|
+
Object.assign(page, { status: read.status, reason: read.reason, final_url: read.final_url, fetched_at: read.fetched_at ?? null });
|
|
175
|
+
if (read.status === "read") {
|
|
176
|
+
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(decodeBody(Buffer.from(read.raw_base64, "base64"), read.content_type))?.[1];
|
|
177
|
+
if (text(title)) page.title = title.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
178
|
+
Object.assign(page, { text_blob_ref: writeBlob({ text: read.text }), text_sha256: read.text_sha256, text_source: "page", excerpt: read.text.slice(0, LIMITS.excerptChars) });
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
page.reason = reader.publicReadFailure(error)?.stage ?? error?.code ?? "read_failed";
|
|
182
|
+
if (!reader.publicReadFailure(error) && error?.code !== "round_budget" && !String(error?.code ?? "").startsWith("ALGORITHM_WEB_")) throw error;
|
|
183
|
+
}
|
|
184
|
+
// An unreadable page still has the search engine's summary for discovery, marked as such; finalize never accepts it as evidence.
|
|
185
|
+
const snippet = String(entry.snippet ?? "").trim();
|
|
186
|
+
if (page.status !== "read" && snippet) {
|
|
187
|
+
Object.assign(page, { status: "read", text_blob_ref: writeBlob({ text: snippet }), text_sha256: sha256(snippet), text_source: "search_snippet", excerpt: snippet.slice(0, LIMITS.excerptChars) });
|
|
188
|
+
}
|
|
189
|
+
return page;
|
|
190
|
+
});
|
|
191
|
+
for (const reaction of request.requirements.reactions) {
|
|
192
|
+
if (request.purpose === "implementation" && !pages.some(page => page.reaction_id === reaction.id && page.text_source === "page"))
|
|
193
|
+
unknowns.push(`reaction ${reaction.id} 没有读到实现资料正文`);
|
|
194
|
+
}
|
|
195
|
+
for (const model of hardware?.models ?? []) {
|
|
196
|
+
if (queries.some(q => q.component_id === model.component_id) && !pages.some(page => page.component_id === model.component_id && page.text_source === "page"))
|
|
197
|
+
unknowns.push(`${model.manufacturer} ${model.model} 没有读到厂商资料正文`);
|
|
198
|
+
}
|
|
199
|
+
const collected = validateResearch({ ...base, status: "collected", searches, pages, unknowns,
|
|
200
|
+
budget: { queries: searches.length, pages: pages.length, skipped_pages: queue.skipped } });
|
|
201
|
+
await save(collected);
|
|
202
|
+
return collected;
|
|
203
|
+
}
|
|
@@ -64,6 +64,32 @@ export function createStore(workdir) {
|
|
|
64
64
|
const logFile = path.join(dirs.log, "operations.jsonl");
|
|
65
65
|
|
|
66
66
|
const readIndex = () => readJson(indexFile);
|
|
67
|
+
// Index writes are read-modify-write. Two ab processes on one workdir (a host running a parallel lane next to the
|
|
68
|
+
// main line, P2-03) would otherwise lose each other's entries, so every index write holds a short cross-process lock.
|
|
69
|
+
const lockFile = path.join(dirs.store, ".index.lock");
|
|
70
|
+
const pause = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
71
|
+
const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } };
|
|
72
|
+
function withIndexLock(task) {
|
|
73
|
+
const deadline = Date.now() + 10000;
|
|
74
|
+
for (let attempt = 0; ; attempt++) {
|
|
75
|
+
let fd;
|
|
76
|
+
try { fd = fs.openSync(lockFile, "wx", 0o600); } catch (error) {
|
|
77
|
+
if (error.code !== "EEXIST") throw error;
|
|
78
|
+
// Index writes take milliseconds: a lock whose holder is gone, or older than 30 s, was left by a crash.
|
|
79
|
+
let stale = false;
|
|
80
|
+
try {
|
|
81
|
+
const owner = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
82
|
+
stale = !Number.isSafeInteger(owner.pid) || !alive(owner.pid) || Date.now() - owner.at > 30000;
|
|
83
|
+
} catch { try { stale = Date.now() - fs.statSync(lockFile).mtimeMs > 30000; } catch { /* already released */ } }
|
|
84
|
+
if (stale) { fs.rmSync(lockFile, { force: true }); continue; }
|
|
85
|
+
if (Date.now() > deadline) throw fail("STORE_LOCKED", "resource index is held by another process; retry the command");
|
|
86
|
+
pause(Math.min(50, 5 * (attempt + 1)));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try { fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); } finally { fs.closeSync(fd); }
|
|
90
|
+
try { return task(); } finally { fs.rmSync(lockFile, { force: true }); }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
67
93
|
const appendLog = entry =>
|
|
68
94
|
fs.appendFileSync(logFile, canonicalJson({ ts: new Date().toISOString(), ...entry }) + "\n");
|
|
69
95
|
const blobFile = digest => path.join(dirs.blobs, `${digest}.json`);
|
|
@@ -78,6 +104,9 @@ export function createStore(workdir) {
|
|
|
78
104
|
function put(kind, localId, body) {
|
|
79
105
|
if (!KIND_RE.test(kind)) throw fail("INVALID_KIND", `bad kind: ${kind}`);
|
|
80
106
|
if (typeof localId !== "string" || !localId) throw fail("INVALID_ID", "localId must be a non-empty string");
|
|
107
|
+
return withIndexLock(() => putLocked(kind, localId, body));
|
|
108
|
+
}
|
|
109
|
+
function putLocked(kind, localId, body) {
|
|
81
110
|
const id = `${kind}:${localId}`;
|
|
82
111
|
const digest = sha256Hex(canonicalJson(body));
|
|
83
112
|
const now = new Date().toISOString();
|
|
@@ -116,6 +145,9 @@ export function createStore(workdir) {
|
|
|
116
145
|
return ref;
|
|
117
146
|
}
|
|
118
147
|
function setMachineState(id, machineName, state) {
|
|
148
|
+
return withIndexLock(() => setMachineStateLocked(id, machineName, state));
|
|
149
|
+
}
|
|
150
|
+
function setMachineStateLocked(id, machineName, state) {
|
|
119
151
|
const index = readIndex();
|
|
120
152
|
const entry = index.resources[id];
|
|
121
153
|
if (!entry) throw fail("INSTANCE_NOT_FOUND", `machine instance not found: ${id}`, { id });
|
|
@@ -135,6 +167,9 @@ export function createStore(workdir) {
|
|
|
135
167
|
.map(([id, e]) => assemble(id, e, readJson(blobFile(e.digest)).body));
|
|
136
168
|
}
|
|
137
169
|
function remove_(id) {
|
|
170
|
+
return withIndexLock(() => removeLocked(id));
|
|
171
|
+
}
|
|
172
|
+
function removeLocked(id) {
|
|
138
173
|
const index = readIndex();
|
|
139
174
|
if (!index.resources[id]) return false;
|
|
140
175
|
delete index.resources[id];
|
|
@@ -170,7 +205,7 @@ export function createStore(workdir) {
|
|
|
170
205
|
// doctor must report the shipped package version, never a frozen constant (0.2.3 drifted across
|
|
171
206
|
// six releases). The npm tree always ships package.json at the skill root; standalone skill copies
|
|
172
207
|
// fall back to FALLBACK_VERSION, which scripts/release-check.mjs pins to package.json.
|
|
173
|
-
const FALLBACK_VERSION = "0.
|
|
208
|
+
const FALLBACK_VERSION = "0.10.0";
|
|
174
209
|
export let VERSION = FALLBACK_VERSION;
|
|
175
210
|
try {
|
|
176
211
|
VERSION = String(JSON.parse(fs.readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version || FALLBACK_VERSION);
|
|
@@ -539,7 +574,7 @@ async function moduleAdapter(a, ctx) {
|
|
|
539
574
|
throw fail("ADAPTER_MODULE", `module adapter export not found: ${exportName}`, { path: a.path, export: exportName });
|
|
540
575
|
}
|
|
541
576
|
try {
|
|
542
|
-
return await handler({ input: ctx.input ?? {}, env: ctx.env ?? {}, fetchImpl: ctx.fetchImpl ?? fetch, readResource: ctx.readResource, readEvidence: ctx.readEvidence, readBlob: ctx.readBlob, writeBlob: ctx.writeBlob, ports: ctx.ports, checkpoint: ctx.checkpoint });
|
|
577
|
+
return await handler({ input: ctx.input ?? {}, env: ctx.env ?? {}, fetchImpl: ctx.fetchImpl ?? fetch, readResource: ctx.readResource, readEvidence: ctx.readEvidence, readBlob: ctx.readBlob, writeBlob: ctx.writeBlob, listResources: ctx.listResources, ports: ctx.ports, checkpoint: ctx.checkpoint });
|
|
543
578
|
} catch (e) {
|
|
544
579
|
// Node 系统错误(EACCES、ENOENT 等)的 message 带绝对路径。stdout 只留 code 与 syscall;
|
|
545
580
|
// 原文仅在显式 AB_DEBUG=1 时写到 stderr。错误码不变,结构仍是 {code,message}。
|
|
@@ -623,7 +658,7 @@ export async function runCapability(deps, capability, args = {}) {
|
|
|
623
658
|
if (typeof deps.onCheckpoint === "function") deps.onCheckpoint(checkpointResource);
|
|
624
659
|
return checkpointResource;
|
|
625
660
|
} : undefined;
|
|
626
|
-
const baseCtx = { readBlob: ref => deps.store.getContent(ref), writeBlob: hit.cap.checkpoint === true ? value => deps.store.putContent(value) : undefined, input: args.input ?? {}, resource, env: deps.env ?? {}, fetchImpl: deps.fetchImpl, readResource, readEvidence, ports: deps.modulePorts?.[capability], checkpoint };
|
|
661
|
+
const baseCtx = { readBlob: ref => deps.store.getContent(ref), listResources: kind => deps.store.list({ kind }), writeBlob: hit.cap.checkpoint === true ? value => deps.store.putContent(value) : undefined, input: args.input ?? {}, resource, env: deps.env ?? {}, fetchImpl: deps.fetchImpl, readResource, readEvidence, ports: deps.modulePorts?.[capability], checkpoint };
|
|
627
662
|
const adapterInput = hit.cap.input?.from ? dotGet(baseCtx, hit.cap.input.from) : baseCtx.input;
|
|
628
663
|
const decl = resolveAdapter(hit.d, hit.cap.adapter);
|
|
629
664
|
const result = await runAdapter(decl, { ...baseCtx, input: adapterInput });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"plan_version": "2026-10-14.2",
|
|
3
|
+
"description": "与主线阶段并行的车道。宿主在每个自动阶段开跑时调用 workspace.lanes-next,按返回的步骤在同一 run 里并发执行,不阻塞主线;车道结果不反向约束主线(2026-10-12 碰头会:算法依赖本阶段冻结,不约束硬件选型)。",
|
|
4
|
+
"artifacts": ["algorithm_proposed", "hardware", "algorithm_selection"],
|
|
5
|
+
"lanes": {
|
|
6
|
+
"algorithm": {
|
|
7
|
+
"starts_with": ["hardware", "real_image"],
|
|
8
|
+
"steps": {
|
|
9
|
+
"research": {
|
|
10
|
+
"goal": "需求明确后立即检索每个 reaction 可用的实现,与硬件选型同时进行",
|
|
11
|
+
"needs": [],
|
|
12
|
+
"produces": "algorithm_proposed",
|
|
13
|
+
"directives": [
|
|
14
|
+
"只凭本次需求的 reaction、limits 和落点检索,不等硬件,也不猜硬件型号;离线、隐私、延迟、费用等硬约束从一开始就用来筛选。",
|
|
15
|
+
"先看宿主给出的库内已适配算法,能满足 reaction 的优先;库外再联网,顺序是实现官方资料(模型卡、SDK、发布记录)→ Hugging Face → 成熟云服务官方文档;博客、论坛与聚合文章只用来发现线索。",
|
|
16
|
+
"每个 reaction 用 1–2 条英文查询调用 algorithm.research(purpose=implementation,查询带 reaction_id),读正文后再判断,不凭检索摘要下结论。",
|
|
17
|
+
"用 algorithm.candidates 为每个 reaction 提交 1–3 个候选,格式按 abelien-algorithm 的 algorithm-candidates.v1 schema:implementation_ref 是简短名称(不带网址和说明,说明写 summary),implementation_version 只写来源写明的确定版本,没写就填 null;needs 按原文记录,没写的填 null;quotes 逐字取自本次研究读到的正文,同一份资料可以同时支撑几个 reaction。违反硬约束的不提交,确实找不到的写进 unresolved 并说明原因。",
|
|
18
|
+
"算法结果不反向约束硬件选型,也不阻塞首版 CAD;只记录资源需求,供硬件登记后绑定。"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"bind": {
|
|
22
|
+
"goal": "硬件登记后,把每个 reaction 绑定到已选型号上的一个实现并复核兼容",
|
|
23
|
+
"needs": ["algorithm_proposed", "hardware"],
|
|
24
|
+
"produces": "algorithm_selection",
|
|
25
|
+
"directives": [
|
|
26
|
+
"读取已登记的 hardware-selection(id 与 revision)和已提议的候选;硬件尚未 verified 也照常绑定,结果会保持待确认。",
|
|
27
|
+
"先为主控等相关型号检索厂商官方资料:algorithm.research(purpose=hardware,查询带 component_id),看 SDK、runtime、算子支持、内存与外设接口。",
|
|
28
|
+
"每个 reaction 恰好选一个已提议的候选(implementation_ref 照抄),绑定到具体 component_id;逐项核对 io、runtime、dependencies、network、license、resources 和每条硬约束,各写 pass/conflict/unknown、理由与来源。格式按 algorithm-bindings.v1 schema:sources 只写 research_id 与 page_id 指向的正文页(search_snippet 不算),url、访问时间与哈希由 Harness 回填;source_type 按发布方选(所选型号厂商 hardware_vendor、实现官方 implementation_official、模型卡 huggingface、云服务官方 cloud_vendor),分销商、博客不是一手来源。",
|
|
29
|
+
"用 algorithm.finalize(operation=bind,proposed_id 为已提议候选)提交;证据不足就如实写 unknowns/blocking_unknowns,结果保持 needs_confirmation,不编造通过。",
|
|
30
|
+
"只有换硬件(算力或接口变化)才重新绑定;需求的 reaction 或 limits 变化才重新研究。"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abelien-workspace",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "作品旅程、PRD 与业务契约;user-journey 由 Agent 驱动,draft 供真实硬件阶段使用;不声明假 PRD、确认、报价或支付能力",
|
|
5
5
|
"resources": [
|
|
6
6
|
"draft"
|
|
@@ -28,6 +28,27 @@
|
|
|
28
28
|
"completed"
|
|
29
29
|
]
|
|
30
30
|
}
|
|
31
|
+
},
|
|
32
|
+
"workspace.lanes-next": {
|
|
33
|
+
"description": "给出当前自动阶段旁边要并发执行的车道步骤(目前是算法车道:research 与硬件选型同时开始,硬件登记后 bind)及该步骤的领域规则;纯计算,不联网、不写资源。规则来自 assets/lane-plan.json",
|
|
34
|
+
"adapter": {
|
|
35
|
+
"type": "module",
|
|
36
|
+
"path": "scripts/lanes-next.mjs",
|
|
37
|
+
"export": "lanesNext"
|
|
38
|
+
},
|
|
39
|
+
"input": {
|
|
40
|
+
"from": "input"
|
|
41
|
+
},
|
|
42
|
+
"resultContract": {
|
|
43
|
+
"nonEmptyStrings": [
|
|
44
|
+
"plan_version",
|
|
45
|
+
"intent",
|
|
46
|
+
"stage"
|
|
47
|
+
],
|
|
48
|
+
"arrays": [
|
|
49
|
+
"lanes"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
31
52
|
}
|
|
32
53
|
},
|
|
33
54
|
"machines": {},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// workspace.lanes-next(P2-03 算法并行车道):给出当前自动阶段旁边要并发执行的车道步骤及其领域规则。
|
|
2
|
+
// 纯函数:不联网、不写资源、不读宿主状态。车道从哪个主线阶段开始、每步需要什么、带什么规则都只来自
|
|
3
|
+
// assets/lane-plan.json;宿主把已有产物映射为 artifacts 后调用,按返回的步骤在同一 run 里并发执行。
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { PLAN as JOURNEY } from "./journey-next.mjs";
|
|
6
|
+
|
|
7
|
+
const deepFreeze = value => {
|
|
8
|
+
if (value !== null && typeof value === "object") { for (const child of Object.values(value)) deepFreeze(child); Object.freeze(value); }
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
export const LANES = deepFreeze(JSON.parse(readFileSync(new URL("../assets/lane-plan.json", import.meta.url), "utf8")));
|
|
12
|
+
const invalid = message => { throw Object.assign(new Error(message), { code: "VALIDATION" }); };
|
|
13
|
+
const record = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
|
|
15
|
+
/** A lane runs from the first of its starts_with stages present in this intent's main line; side stages all come after it. */
|
|
16
|
+
function laneActive(lane, intent, stage) {
|
|
17
|
+
if (JOURNEY.side.includes(stage)) return true;
|
|
18
|
+
const order = JOURNEY.orders[intent];
|
|
19
|
+
const start = lane.starts_with.map(name => order.indexOf(name)).find(index => index >= 0);
|
|
20
|
+
return start !== undefined && order.indexOf(stage) >= start;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function lanesNext({ input }) {
|
|
24
|
+
if (!record(input) || Object.keys(input).some(key => !["intent", "stage", "artifacts"].includes(key))) invalid("input accepts intent, stage and artifacts only");
|
|
25
|
+
if (!Object.hasOwn(JOURNEY.orders, input.intent)) invalid(`intent must be one of ${Object.keys(JOURNEY.orders).join(", ")}`);
|
|
26
|
+
const allowed = [...JOURNEY.orders[input.intent], ...JOURNEY.side];
|
|
27
|
+
if (!allowed.includes(input.stage)) invalid(`stage for ${input.intent} must be one of ${allowed.join(", ")}`);
|
|
28
|
+
const artifacts = input.artifacts ?? {};
|
|
29
|
+
if (!record(artifacts) || Object.entries(artifacts).some(([key, value]) => !LANES.artifacts.includes(key) || typeof value !== "boolean")) invalid(`artifacts must map ${LANES.artifacts.join(", ")} to booleans`);
|
|
30
|
+
const lanes = [];
|
|
31
|
+
for (const [name, lane] of Object.entries(LANES.lanes)) {
|
|
32
|
+
if (!laneActive(lane, input.intent, input.stage)) continue;
|
|
33
|
+
// The first step whose output is missing and whose inputs are all present; a lane does one step per stage.
|
|
34
|
+
const step = Object.entries(lane.steps).find(([, entry]) => artifacts[entry.produces] !== true && entry.needs.every(need => artifacts[need] === true));
|
|
35
|
+
if (step) lanes.push({ lane: name, step: step[0], goal: step[1].goal, directives: [...step[1].directives] });
|
|
36
|
+
}
|
|
37
|
+
return { plan_version: LANES.plan_version, intent: input.intent, stage: input.stage, lanes };
|
|
38
|
+
}
|