Skip to content

Commit a86eb1f

Browse files
author
liushiao
committed
feat(LGT-020): implement worker execution protocol
1 parent 672ba92 commit a86eb1f

5 files changed

Lines changed: 2125 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { createHash } from "node:crypto";
2+
3+
export const WORKER_EXECUTION_SCHEMA_VERSION = 1;
4+
export const WORKER_EXECUTION_INTERFACE_PROOF_VERSION = 1;
5+
6+
export const WORKER_EXECUTION_LIMITS = Object.freeze({
7+
workSpecificationBytes: 128 * 1024,
8+
contextExpansionRequestBytes: 128 * 1024,
9+
workerReportBytes: 256 * 1024,
10+
workerObservationBytes: 128 * 1024,
11+
executionRecordBytes: 1024 * 1024,
12+
textBytes: 8 * 1024,
13+
pathBytes: 1024,
14+
identifierBytes: 128,
15+
planRefs: 64,
16+
claimRefs: 256,
17+
paths: 512,
18+
expansionRequests: 64,
19+
artifactRefs: 256,
20+
reportItems: 256,
21+
relatedRefs: 64,
22+
events: 256
23+
});
24+
25+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
26+
const IDENTIFIER_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9._:-]*[a-zA-Z0-9])?$/u;
27+
function assertSchemaAndKind(value, kind, location) {
28+
if (value.schemaVersion !== WORKER_EXECUTION_SCHEMA_VERSION || value.kind !== kind) {
29+
throw protocolError(
30+
"WORKER_EXECUTION_SCHEMA_INVALID",
31+
`${location} must use schemaVersion 1 and kind ${kind}.`
32+
);
33+
}
34+
}
35+
36+
function normalizeIdentifier(value, location) {
37+
const text = normalizeText(value, location, {
38+
minimumBytes: 1,
39+
maximumBytes: WORKER_EXECUTION_LIMITS.identifierBytes
40+
});
41+
if (!IDENTIFIER_PATTERN.test(text)) {
42+
throw protocolError("WORKER_EXECUTION_IDENTIFIER_INVALID", `${location} is not canonical.`);
43+
}
44+
return text;
45+
}
46+
47+
function normalizeDigest(value, location) {
48+
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
49+
throw protocolError("WORKER_EXECUTION_DIGEST_INVALID", `${location} must be a sha256 digest.`);
50+
}
51+
return value;
52+
}
53+
54+
function normalizePath(value, location) {
55+
const path = normalizeText(value, location, {
56+
minimumBytes: 1,
57+
maximumBytes: WORKER_EXECUTION_LIMITS.pathBytes
58+
});
59+
if (path.startsWith("/")
60+
|| path.includes("\\")
61+
|| path.includes("\0")
62+
|| path.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
63+
throw protocolError("WORKER_EXECUTION_PATH_INVALID", `${location} is not repository-relative.`);
64+
}
65+
return path;
66+
}
67+
68+
function normalizeText(value, location, {
69+
minimumBytes = 0,
70+
maximumBytes = WORKER_EXECUTION_LIMITS.textBytes
71+
} = {}) {
72+
if (typeof value !== "string" || value !== value.trim() || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) {
73+
throw protocolError("WORKER_EXECUTION_TEXT_INVALID", `${location} must be canonical text.`);
74+
}
75+
const bytes = Buffer.byteLength(value, "utf8");
76+
if (bytes < minimumBytes || bytes > maximumBytes) {
77+
throw protocolError(
78+
"WORKER_EXECUTION_LIMIT_EXCEEDED",
79+
`${location} must contain between ${minimumBytes} and ${maximumBytes} UTF-8 bytes.`
80+
);
81+
}
82+
return value;
83+
}
84+
85+
function normalizeInteger(value, location, { minimum, maximum }) {
86+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
87+
throw protocolError(
88+
"WORKER_EXECUTION_NUMBER_INVALID",
89+
`${location} must be an integer between ${minimum} and ${maximum}.`
90+
);
91+
}
92+
return value;
93+
}
94+
95+
function normalizeStringSet(value, location, {
96+
minimum = 0,
97+
maximum,
98+
item
99+
}) {
100+
const entries = normalizeDenseArray(value, location, { minimum, maximum })
101+
.map((entry, index) => item(entry, `${location}[${index}]`))
102+
.sort(compareUtf8);
103+
assertUnique(entries, location);
104+
return entries;
105+
}
106+
107+
function normalizeDenseArray(value, location, { minimum = 0, maximum }) {
108+
if (!Array.isArray(value)) {
109+
throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be an array.`);
110+
}
111+
const ownKeys = Reflect.ownKeys(value);
112+
if (ownKeys.some((key) => typeof key === "symbol")
113+
|| ownKeys.some((key) => key !== "length" && !/^\d+$/u.test(key))
114+
|| value.length < minimum
115+
|| value.length > maximum
116+
|| !Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean)) {
117+
throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be dense and bounded.`);
118+
}
119+
return value;
120+
}
121+
122+
function assertPlainObject(value, location) {
123+
if (!value || typeof value !== "object" || Array.isArray(value)) {
124+
throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be an object.`);
125+
}
126+
let prototype;
127+
let descriptors;
128+
try {
129+
prototype = Object.getPrototypeOf(value);
130+
descriptors = Object.getOwnPropertyDescriptors(value);
131+
} catch {
132+
throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} cannot be inspected safely.`);
133+
}
134+
if (prototype !== Object.prototype && prototype !== null) {
135+
throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} must be a plain object.`);
136+
}
137+
for (const [key, descriptor] of Object.entries(descriptors)) {
138+
if (!("value" in descriptor) || descriptor.enumerable !== true) {
139+
throw protocolError(
140+
"WORKER_EXECUTION_INPUT_INVALID",
141+
`${location}.${key} must be an enumerable data property.`
142+
);
143+
}
144+
}
145+
if (Reflect.ownKeys(value).some((key) => typeof key === "symbol")) {
146+
throw protocolError("WORKER_EXECUTION_INPUT_INVALID", `${location} cannot contain symbol keys.`);
147+
}
148+
}
149+
150+
function assertExactKeys(value, expected, location) {
151+
const actual = Object.keys(value).sort(compareUtf8);
152+
const canonicalExpected = [...expected].sort(compareUtf8);
153+
if (canonicalStringify(actual) !== canonicalStringify(canonicalExpected)) {
154+
const expectedSet = new Set(canonicalExpected);
155+
const actualSet = new Set(actual);
156+
throw protocolError(
157+
"WORKER_EXECUTION_FIELDS_INVALID",
158+
`${location} has missing or unknown fields.`,
159+
{
160+
missing: canonicalExpected.filter((key) => !actualSet.has(key)),
161+
unknown: actual.filter((key) => !expectedSet.has(key))
162+
}
163+
);
164+
}
165+
}
166+
167+
function assertUnique(values, location) {
168+
const seen = new Set();
169+
for (const value of values) {
170+
if (seen.has(value)) {
171+
throw protocolError("WORKER_EXECUTION_DUPLICATE", `${location} contains duplicate values.`);
172+
}
173+
seen.add(value);
174+
}
175+
}
176+
177+
function withoutKeys(value, keys) {
178+
assertPlainObject(value, "object");
179+
const omitted = new Set(keys);
180+
return Object.fromEntries(Object.entries(value).filter(([candidate]) => !omitted.has(candidate)));
181+
}
182+
183+
function sealDocument(content, digestField) {
184+
return { ...content, [digestField]: canonicalDigest(content) };
185+
}
186+
187+
function assertDocumentSeal(value, content, digestField) {
188+
const expected = sealDocument(content, digestField);
189+
if (value[digestField] !== expected[digestField]
190+
|| canonicalStringify(value) !== canonicalStringify(expected)) {
191+
throw protocolError(
192+
"WORKER_EXECUTION_DIGEST_INVALID",
193+
`${digestField} does not bind the canonical document content.`
194+
);
195+
}
196+
return expected;
197+
}
198+
199+
function canonicalDigest(value) {
200+
return `sha256:${createHash("sha256").update(canonicalStringify(value), "utf8").digest("hex")}`;
201+
}
202+
203+
function canonicalStringify(value) {
204+
if (value === null || typeof value === "boolean" || typeof value === "string") {
205+
return JSON.stringify(value);
206+
}
207+
if (typeof value === "number") {
208+
if (!Number.isSafeInteger(value)) {
209+
throw protocolError("WORKER_EXECUTION_NUMBER_INVALID", "Canonical values require safe integers.");
210+
}
211+
return String(value);
212+
}
213+
if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(",")}]`;
214+
assertPlainObject(value, "canonicalValue");
215+
return `{${Object.keys(value).sort(compareUtf8).map((key) => (
216+
`${JSON.stringify(key)}:${canonicalStringify(value[key])}`
217+
)).join(",")}}`;
218+
}
219+
220+
function assertDocumentBytes(value, maximum, label) {
221+
const observed = Buffer.byteLength(canonicalStringify(value), "utf8");
222+
if (observed > maximum) {
223+
throw protocolError(
224+
"WORKER_EXECUTION_LIMIT_EXCEEDED",
225+
`${label} exceeds its ${maximum}-byte canonical limit.`,
226+
{ maximum, observed }
227+
);
228+
}
229+
}
230+
231+
function compareUtf8(left, right) {
232+
return Buffer.compare(Buffer.from(String(left), "utf8"), Buffer.from(String(right), "utf8"));
233+
}
234+
235+
function deepFreeze(value) {
236+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
237+
for (const item of Object.values(value)) deepFreeze(item);
238+
return Object.freeze(value);
239+
}
240+
241+
function protocolError(code, message, details = undefined) {
242+
const error = new Error(message);
243+
error.code = code;
244+
if (details !== undefined) error.details = details;
245+
return error;
246+
}
247+
248+
export {
249+
assertDocumentBytes,
250+
assertDocumentSeal,
251+
assertExactKeys,
252+
assertPlainObject,
253+
assertSchemaAndKind,
254+
assertUnique,
255+
canonicalDigest,
256+
canonicalStringify,
257+
compareUtf8,
258+
deepFreeze,
259+
normalizeDenseArray,
260+
normalizeDigest,
261+
normalizeIdentifier,
262+
normalizeInteger,
263+
normalizePath,
264+
normalizeStringSet,
265+
normalizeText,
266+
protocolError,
267+
sealDocument,
268+
withoutKeys
269+
};

0 commit comments

Comments
 (0)