skill-family-harness-node 0.1.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.
@@ -0,0 +1,274 @@
1
+ import {
2
+ checkOperation,
3
+ findProtocol,
4
+ findSchemaByObject,
5
+ listProtocols,
6
+ stableError,
7
+ } from "skill-family-contracts";
8
+ import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
9
+ import { validateContractDocument } from "./validation.mjs";
10
+
11
+ /**
12
+ * Kernel protocol intake and execution: operation-request in,
13
+ * operation-result out.
14
+ *
15
+ * Everything structural is delegated to contracts: the envelope is validated
16
+ * against the registered operation-request schema (no re-implementation of
17
+ * envelope rules), protocol/operation intake uses the frozen registry and
18
+ * kernel vocabulary, and every produced result is self-checked against the
19
+ * registered operation-result schema before it is returned.
20
+ *
21
+ * Terminal-state semantics (from the frozen kernel protocol):
22
+ * - a schema-valid request with an unregistered protocol, unknown operation,
23
+ * or params contract violation is REJECTED at intake;
24
+ * - an accepted operation that fails during execution is FAILED;
25
+ * - SUCCESS is only reached with an empty error list.
26
+ *
27
+ * The result envelope must echo operationId/operation/protocol. When the
28
+ * request envelope is too malformed to echo, deterministic fallback values
29
+ * are used; both branches are mechanically verified against the registered
30
+ * result schema (two-pass build), so no pattern or envelope rule is
31
+ * duplicated here.
32
+ */
33
+
34
+ const REQUEST_ENVELOPE = Object.freeze({
35
+ schemaVersion: 1,
36
+ kind: "skill-family.operation-result",
37
+ });
38
+
39
+ const FALLBACK_OPERATION_ID = "rejected-intake";
40
+ const FALLBACK_OPERATION = "intake";
41
+
42
+ function schemaIdFor(objectName) {
43
+ const registration = findSchemaByObject(objectName);
44
+ if (!registration) {
45
+ // The frozen contracts registry always registers these objects; reaching
46
+ // this line means the contracts dependency itself is broken.
47
+ throw mechanismError(
48
+ HARNESS_ERROR_KINDS.EXECUTION_FAILED,
49
+ `contracts registry has no schema for object: ${objectName}`,
50
+ );
51
+ }
52
+ return registration.$id;
53
+ }
54
+
55
+ const REQUEST_SCHEMA_ID = schemaIdFor("operation-request");
56
+ const RESULT_SCHEMA_ID = schemaIdFor("operation-result");
57
+
58
+ function findingToEntry(code, finding) {
59
+ const entry = stableError(code, finding.message || "validation failed");
60
+ if (typeof finding.instancePath === "string" && finding.instancePath.length > 0) {
61
+ entry.path = finding.instancePath;
62
+ }
63
+ const details = {};
64
+ if (finding.keyword !== undefined) details.keyword = finding.keyword;
65
+ if (finding.params !== undefined) details.params = finding.params;
66
+ if (Object.keys(details).length > 0) entry.details = details;
67
+ return entry;
68
+ }
69
+
70
+ function genericEntry(code, message) {
71
+ return stableError(code, message);
72
+ }
73
+
74
+ function echoable(raw) {
75
+ const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
76
+ return {
77
+ protocol:
78
+ source.protocol && typeof source.protocol === "object" && !Array.isArray(source.protocol)
79
+ ? structuredClone(source.protocol)
80
+ : null,
81
+ operationId: typeof source.operationId === "string" ? source.operationId : null,
82
+ operation: typeof source.operation === "string" ? source.operation : null,
83
+ };
84
+ }
85
+
86
+ function fallbackProtocol() {
87
+ const protocols = listProtocols();
88
+ const first = protocols[0];
89
+ if (!first) {
90
+ throw mechanismError(
91
+ HARNESS_ERROR_KINDS.EXECUTION_FAILED,
92
+ "contracts registry lists no protocol",
93
+ );
94
+ }
95
+ return { name: first.name, version: first.version };
96
+ }
97
+
98
+ /**
99
+ * Parses and intake-checks one raw operation-request without executing it.
100
+ * Returns { ok: true, request } or
101
+ * { ok: false, state: "rejected", errors } where errors is a non-empty
102
+ * array of stable-coded entries. The input is deep-cloned and never mutated.
103
+ */
104
+ export function parseRequest(raw) {
105
+ let candidate;
106
+ try {
107
+ candidate = structuredClone(raw === undefined ? null : raw);
108
+ } catch {
109
+ return {
110
+ ok: false,
111
+ state: "rejected",
112
+ errors: [
113
+ genericEntry("SFC1001", "request envelope is not serializable structured data"),
114
+ ],
115
+ };
116
+ }
117
+ const envelope = validateContractDocument(candidate, { schemaId: REQUEST_SCHEMA_ID });
118
+ if (!envelope.valid) {
119
+ return {
120
+ ok: false,
121
+ state: "rejected",
122
+ errors: envelope.errors.map((finding) => findingToEntry(envelope.errorCode, finding)),
123
+ };
124
+ }
125
+ const request = envelope.data;
126
+ if (!findProtocol(request.protocol.name, request.protocol.version)) {
127
+ return {
128
+ ok: false,
129
+ state: "rejected",
130
+ errors: [
131
+ genericEntry(
132
+ "SFC1011",
133
+ `unknown protocol: ${request.protocol.name} version ${request.protocol.version}`,
134
+ ),
135
+ ],
136
+ };
137
+ }
138
+ const operationCheck = checkOperation(request.operation, request.params);
139
+ if (!operationCheck.ok) {
140
+ return {
141
+ ok: false,
142
+ state: "rejected",
143
+ errors: operationCheck.errors.map((finding) => findingToEntry(operationCheck.code, finding)),
144
+ };
145
+ }
146
+ return { ok: true, request };
147
+ }
148
+
149
+ function buildResult({ echo, state, outputs, errors, completedAt }) {
150
+ return {
151
+ ...REQUEST_ENVELOPE,
152
+ protocol: echo.protocol ?? fallbackProtocol(),
153
+ operationId: echo.operationId ?? FALLBACK_OPERATION_ID,
154
+ operation: echo.operation ?? FALLBACK_OPERATION,
155
+ state,
156
+ outputs,
157
+ errors,
158
+ completedAt,
159
+ };
160
+ }
161
+
162
+ function assertSchemaValidResult(result) {
163
+ const check = validateContractDocument(result, { schemaId: RESULT_SCHEMA_ID });
164
+ if (!check.valid) {
165
+ throw mechanismError(
166
+ HARNESS_ERROR_KINDS.INVALID_RESULT,
167
+ `harness produced a result that violates the registered operation-result schema: ${check.errors
168
+ .map((finding) => finding.message)
169
+ .join("; ")}`,
170
+ );
171
+ }
172
+ }
173
+
174
+ function finalizeResult(candidate) {
175
+ // Deterministic echo ladder: keep as much of the request echo as the
176
+ // registered result schema accepts, falling back field-group by field-group.
177
+ // Self-verification against the real schema replaces any hand-written
178
+ // envelope rule; the full-fallback rung is schema-valid by construction,
179
+ // so the ladder always converges.
180
+ const rungs = [
181
+ candidate,
182
+ buildResult({
183
+ echo: { protocol: null, operationId: candidate.operationId, operation: candidate.operation },
184
+ state: candidate.state,
185
+ outputs: candidate.outputs,
186
+ errors: candidate.errors,
187
+ completedAt: candidate.completedAt,
188
+ }),
189
+ buildResult({
190
+ echo: { protocol: null, operationId: null, operation: null },
191
+ state: candidate.state,
192
+ outputs: candidate.outputs,
193
+ errors: candidate.errors,
194
+ completedAt: candidate.completedAt,
195
+ }),
196
+ ];
197
+ for (const rung of rungs) {
198
+ const check = validateContractDocument(rung, { schemaId: RESULT_SCHEMA_ID });
199
+ if (check.valid) return rung;
200
+ }
201
+ assertSchemaValidResult(rungs[rungs.length - 1]); // throws INVALID_RESULT
202
+ return rungs[rungs.length - 1]; // unreachable
203
+ }
204
+
205
+ async function executeValidateOperation(request) {
206
+ const params = request.params;
207
+ const dialect = params.dialect; // undefined means: use the registration's dialect
208
+ const policy = params.policy ?? "strict";
209
+ const outcome = validateContractDocument(params.document, {
210
+ schemaId: params.schemaId,
211
+ dialect,
212
+ policy,
213
+ });
214
+ if (outcome.valid) {
215
+ return {
216
+ state: "succeeded",
217
+ outputs: { report: { valid: true, errors: [] } },
218
+ errors: [],
219
+ };
220
+ }
221
+ const entries =
222
+ outcome.errorCode === "SFC1001"
223
+ ? outcome.errors.map((finding) => findingToEntry("SFC1001", finding))
224
+ : [findingToEntry(outcome.errorCode, outcome.errors[0] ?? { message: "validation failed" })];
225
+ return { state: "failed", outputs: null, errors: entries };
226
+ }
227
+
228
+ /**
229
+ * Processes one raw operation-request and returns a terminal, schema-verified
230
+ * operation-result. Never throws for request-side problems; only a harness
231
+ * bug (an unproducible result) throws. Options: { now } clock injection for
232
+ * deterministic completedAt values (defaults to the system clock).
233
+ */
234
+ export async function processRequest(raw, { now = () => new Date() } = {}) {
235
+ const completedAt = now().toISOString();
236
+ const parsed = parseRequest(raw);
237
+ const echo = echoable(raw);
238
+ if (!parsed.ok) {
239
+ return finalizeResult(
240
+ buildResult({
241
+ echo,
242
+ state: parsed.state,
243
+ outputs: null,
244
+ errors: parsed.errors,
245
+ completedAt,
246
+ }),
247
+ );
248
+ }
249
+ let outcome;
250
+ try {
251
+ outcome = await executeValidateOperation(parsed.request);
252
+ } catch (cause) {
253
+ // Any unexpected execution failure becomes a coded terminal result;
254
+ // processRequest never leaks request-side problems as exceptions.
255
+ outcome = {
256
+ state: "failed",
257
+ outputs: null,
258
+ errors: [
259
+ stableError("SFC2004", `operation execution failed: ${cause && cause.message ? cause.message : "unknown"}`, {
260
+ kind: HARNESS_ERROR_KINDS.EXECUTION_FAILED,
261
+ }),
262
+ ],
263
+ };
264
+ }
265
+ return finalizeResult(
266
+ buildResult({
267
+ echo,
268
+ state: outcome.state,
269
+ outputs: outcome.outputs,
270
+ errors: outcome.errors,
271
+ completedAt,
272
+ }),
273
+ );
274
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ ContractsError,
3
+ SUPPORTED_DIALECTS,
4
+ VALIDATION_POLICIES,
5
+ compileSchema,
6
+ findSchemaRegistration,
7
+ validateDocument,
8
+ } from "skill-family-contracts";
9
+ import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
10
+
11
+ /**
12
+ * Dialect-aware schema validation with cached validators.
13
+ *
14
+ * The harness never re-implements schema interpretation: dialect routing
15
+ * (draft-07 vs 2020-12 Ajv classes), Ajv instance caching per
16
+ * dialect|policy, and the package-schema pre-registration all live in
17
+ * skill-family-contracts. This layer only (1) resolves the dialect for a
18
+ * registered schema when the caller does not override it, (2) caches the
19
+ * compiled validate function per schema|dialect|policy, and (3) normalizes
20
+ * every failure into the contracts result shape with registered codes.
21
+ */
22
+
23
+ const validatorCache = new Map(); // `${schemaId}|${dialect}|${policy}` -> validate fn
24
+
25
+ function unsupportedPolicyResult(policy) {
26
+ return {
27
+ valid: false,
28
+ errorCode: "SFC2004",
29
+ errors: [
30
+ {
31
+ message: `unsupported validation policy: ${policy}`,
32
+ params: { kind: HARNESS_ERROR_KINDS.UNSUPPORTED_POLICY },
33
+ },
34
+ ],
35
+ data: undefined,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Resolves { schemaId, dialect, policy } against the frozen registry.
41
+ * Returns { ok: true, schemaId, dialect, policy, registration } or
42
+ * { ok: false, errorCode, message } with a registered code:
43
+ * SFC1002 unknown $id, SFC1006 unsupported dialect, SFC2004 unknown policy.
44
+ */
45
+ export function resolveSchemaContext({ schemaId, dialect, policy = "strict" } = {}) {
46
+ if (typeof schemaId !== "string" || schemaId.length === 0) {
47
+ return { ok: false, errorCode: "SFC1002", message: "schemaId must be a non-empty string" };
48
+ }
49
+ const registration = findSchemaRegistration(schemaId);
50
+ if (!registration) {
51
+ return { ok: false, errorCode: "SFC1002", message: `unknown schema $id: ${schemaId}` };
52
+ }
53
+ const resolvedDialect = dialect ?? registration.dialect;
54
+ if (!Object.hasOwn(SUPPORTED_DIALECTS, resolvedDialect)) {
55
+ return { ok: false, errorCode: "SFC1006", message: `unsupported dialect: ${resolvedDialect}` };
56
+ }
57
+ if (!Object.hasOwn(VALIDATION_POLICIES, policy)) {
58
+ return { ok: false, errorCode: "SFC2004", message: `unsupported validation policy: ${policy}` };
59
+ }
60
+ return { ok: true, schemaId, dialect: resolvedDialect, policy, registration };
61
+ }
62
+
63
+ /**
64
+ * Returns the cached compiled validator for a registered schema.
65
+ * Dialect routing and Ajv instance caching are delegated to contracts; this
66
+ * cache keeps the compiled validate function per schema|dialect|policy so a
67
+ * repeated call never recompiles. Throws ContractsError/HarnessError with
68
+ * registered codes (SFC1002, SFC1006, SFC1012) on failure.
69
+ */
70
+ export function getValidator({ schemaId, dialect, policy = "strict" } = {}) {
71
+ const context = resolveSchemaContext({ schemaId, dialect, policy });
72
+ if (!context.ok) {
73
+ if (context.errorCode === "SFC2004") {
74
+ throw mechanismError(HARNESS_ERROR_KINDS.UNSUPPORTED_POLICY, context.message);
75
+ }
76
+ throw new ContractsError(context.errorCode, context.message, { schemaId });
77
+ }
78
+ const key = `${context.schemaId}|${context.dialect}|${context.policy}`;
79
+ const cached = validatorCache.get(key);
80
+ if (cached) return cached;
81
+ const validate = compileSchema({ schemaId: context.schemaId }, { dialect: context.dialect, policy: context.policy });
82
+ validatorCache.set(key, validate);
83
+ return validate;
84
+ }
85
+
86
+ /** Number of cached validators (observability for tests and diagnostics). */
87
+ export function validatorCacheSize() {
88
+ return validatorCache.size;
89
+ }
90
+
91
+ /**
92
+ * Validates one document against a registered schema.
93
+ * Never mutates caller input; returns the contracts result shape
94
+ * { valid, errorCode, errors, data } where data is the normalized copy.
95
+ * errorCode is one of null, SFC1001, SFC1002, SFC1006, SFC1012, or
96
+ * SFC2004 (unknown policy) — all registered codes.
97
+ */
98
+ export function validateContractDocument(document, { schemaId, dialect, policy = "strict" } = {}) {
99
+ const context = resolveSchemaContext({ schemaId, dialect, policy });
100
+ if (!context.ok) {
101
+ if (context.errorCode === "SFC2004") {
102
+ return unsupportedPolicyResult(policy);
103
+ }
104
+ return {
105
+ valid: false,
106
+ errorCode: context.errorCode,
107
+ errors: [{ message: context.message }],
108
+ data: undefined,
109
+ };
110
+ }
111
+ return validateDocument(document, {
112
+ schemaId: context.schemaId,
113
+ dialect: context.dialect,
114
+ policy: context.policy,
115
+ });
116
+ }
@@ -0,0 +1,119 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
5
+ import { readFileContained, resolveContained } from "./paths.mjs";
6
+ import { writeFileAtomic } from "./atomic.mjs";
7
+
8
+ /**
9
+ * Auto-cleaning temporary workspaces.
10
+ *
11
+ * A TemporaryWorkspace owns one mkdtemp directory under the operating
12
+ * system's temporary directory. Every access goes through the containment
13
+ * layer, and dispose() removes the whole tree. withTemporaryWorkspace
14
+ * guarantees cleanup on the exception path as well.
15
+ */
16
+ export class TemporaryWorkspace {
17
+ #root;
18
+ #disposed;
19
+
20
+ constructor(root) {
21
+ this.#root = root;
22
+ this.#disposed = false;
23
+ }
24
+
25
+ /** Creates a fresh workspace: { prefix } optional, default "sf-harness-". */
26
+ static async create({ prefix = "sf-harness-" } = {}) {
27
+ if (typeof prefix !== "string" || prefix.length === 0 || prefix.includes("/") || prefix.includes("\0")) {
28
+ throw new TypeError("TemporaryWorkspace.create: prefix must be a non-empty, separator-free string");
29
+ }
30
+ try {
31
+ const root = await mkdtemp(path.join(os.tmpdir(), prefix));
32
+ return new TemporaryWorkspace(root);
33
+ } catch (cause) {
34
+ throw mechanismError(
35
+ HARNESS_ERROR_KINDS.WORKSPACE_CREATE_FAILED,
36
+ `cannot create temporary workspace: ${cause && cause.code ? cause.code : "unknown"}`,
37
+ );
38
+ }
39
+ }
40
+
41
+ /** Absolute workspace root (a system temporary directory). */
42
+ get root() {
43
+ return this.#root;
44
+ }
45
+
46
+ get disposed() {
47
+ return this.#disposed;
48
+ }
49
+
50
+ #assertAlive() {
51
+ if (this.#disposed) {
52
+ throw mechanismError(
53
+ HARNESS_ERROR_KINDS.WORKSPACE_DISPOSED,
54
+ "temporary workspace is already disposed",
55
+ );
56
+ }
57
+ }
58
+
59
+ /** Containment-checked resolution of a workspace-relative path. */
60
+ async resolve(relPath) {
61
+ this.#assertAlive();
62
+ return resolveContained(this.#root, relPath);
63
+ }
64
+
65
+ /** Atomic contained write; returns the absolute target path. */
66
+ async writeFile(relPath, data, options) {
67
+ this.#assertAlive();
68
+ return writeFileAtomic(this.#root, relPath, data, options);
69
+ }
70
+
71
+ /** Contained read; returns Buffer, or string when encoding is "utf8". */
72
+ async readFile(relPath, options) {
73
+ this.#assertAlive();
74
+ return readFileContained(this.#root, relPath, options);
75
+ }
76
+
77
+ /**
78
+ * Removes the workspace tree. Idempotent; a disposal failure surfaces as
79
+ * SFC2004 (workspace-dispose-failed) but never resurrects the workspace.
80
+ */
81
+ async dispose() {
82
+ if (this.#disposed) return;
83
+ this.#disposed = true;
84
+ try {
85
+ await rm(this.#root, { recursive: true, force: true, maxRetries: 3 });
86
+ } catch (cause) {
87
+ throw mechanismError(
88
+ HARNESS_ERROR_KINDS.WORKSPACE_DISPOSE_FAILED,
89
+ `temporary workspace cleanup failed: ${cause && cause.code ? cause.code : "unknown"}`,
90
+ );
91
+ }
92
+ }
93
+
94
+ /** Resource-management hook (`await using workspace` where supported). */
95
+ async [Symbol.asyncDispose]() {
96
+ await this.dispose();
97
+ }
98
+ }
99
+
100
+ /** Creates a TemporaryWorkspace under the system temporary directory. */
101
+ export async function createTemporaryWorkspace(options) {
102
+ return TemporaryWorkspace.create(options);
103
+ }
104
+
105
+ /**
106
+ * Runs `fn(workspace)` on a fresh workspace and disposes it afterwards,
107
+ * whether fn returns or throws. Returns fn's result.
108
+ */
109
+ export async function withTemporaryWorkspace(fn, options) {
110
+ if (typeof fn !== "function") {
111
+ throw new TypeError("withTemporaryWorkspace: fn must be a function");
112
+ }
113
+ const workspace = await createTemporaryWorkspace(options);
114
+ try {
115
+ return await fn(workspace);
116
+ } finally {
117
+ await workspace.dispose();
118
+ }
119
+ }