@openshain/core 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,78 @@
1
+ import type { ErrorObject } from "ajv";
2
+ import Ajv2020 from "ajv/dist/2020.js";
3
+ import safeRegex from "safe-regex2";
4
+ import { OpenshainError } from "../errors.ts";
5
+ import type { JsonSchema } from "./types.ts";
6
+
7
+ export type InputValidation = { ok: true } | { ok: false; reason: string };
8
+
9
+ /**
10
+ * Compiles a tool's input schema once. Unknown keywords are tolerated because
11
+ * third-party schemas may carry vendor extensions; the schema itself must be
12
+ * valid, must describe an object, and may not contain a regular expression
13
+ * that can be made to backtrack catastrophically (the model controls the input).
14
+ */
15
+ export function compileInputValidator(schema: JsonSchema): (input: unknown) => InputValidation {
16
+ if (schema.type !== "object") {
17
+ throw new OpenshainError("invalid_tool", 'input schema must have "type": "object"');
18
+ }
19
+ const unsafe = findUnsafePattern(schema);
20
+ if (unsafe !== undefined) {
21
+ throw new OpenshainError(
22
+ "invalid_tool",
23
+ `input schema contains a regular expression that can backtrack catastrophically: ${unsafe}`,
24
+ );
25
+ }
26
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
27
+ let validate: ReturnType<typeof ajv.compile>;
28
+ try {
29
+ validate = ajv.compile(schema);
30
+ } catch (cause) {
31
+ throw new OpenshainError(
32
+ "invalid_tool",
33
+ `input schema does not compile: ${(cause as Error).message}`,
34
+ { cause },
35
+ );
36
+ }
37
+ return (input) =>
38
+ validate(input) ? { ok: true } : { ok: false, reason: describe(validate.errors ?? []) };
39
+ }
40
+
41
+ /** Walks the schema and returns the first `pattern` or `patternProperties` key that is unsafe. */
42
+ function findUnsafePattern(node: unknown): string | undefined {
43
+ if (Array.isArray(node)) {
44
+ for (const item of node) {
45
+ const found = findUnsafePattern(item);
46
+ if (found !== undefined) return found;
47
+ }
48
+ return undefined;
49
+ }
50
+ if (node === null || typeof node !== "object") return undefined;
51
+ for (const [key, value] of Object.entries(node)) {
52
+ if (key === "pattern" && typeof value === "string" && !safeRegex(value)) return value;
53
+ if (key === "patternProperties" && value !== null && typeof value === "object") {
54
+ for (const pattern of Object.keys(value)) {
55
+ if (!safeRegex(pattern)) return pattern;
56
+ }
57
+ }
58
+ const found = findUnsafePattern(value);
59
+ if (found !== undefined) return found;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function describe(errors: ErrorObject[]): string {
65
+ return errors
66
+ .map((error) => {
67
+ const where = error.instancePath || "/";
68
+ const params = error.params as Record<string, unknown>;
69
+ const detail =
70
+ error.keyword === "additionalProperties"
71
+ ? ` (${String(params.additionalProperty)})`
72
+ : error.keyword === "required"
73
+ ? ` (${String(params.missingProperty)})`
74
+ : "";
75
+ return `${where} ${error.message ?? error.keyword}${detail}`;
76
+ })
77
+ .join("; ");
78
+ }
@@ -0,0 +1,25 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolveWorkspacePath } from "../tool/paths.ts";
4
+ import type { Artifact } from "./events.ts";
5
+
6
+ /**
7
+ * The artifact as it is now. The runtime computes the hash rather than taking anyone's word.
8
+ * When the file cannot be read, because a later call moved or deleted it or because nobody
9
+ * wrote it, the artifact keeps the hash that was reported and is marked missing.
10
+ */
11
+ export async function verifyArtifact(
12
+ root: string,
13
+ path: string,
14
+ reported: string,
15
+ ): Promise<Artifact> {
16
+ try {
17
+ const resolved = await resolveWorkspacePath(root, path);
18
+ const sha256 = createHash("sha256")
19
+ .update(await readFile(resolved))
20
+ .digest("hex");
21
+ return { path, sha256 };
22
+ } catch {
23
+ return { path, sha256: reported, missing: true };
24
+ }
25
+ }
@@ -0,0 +1,165 @@
1
+ import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { OpenshainError } from "../errors.ts";
4
+ import { newEventId, type WorkId } from "../ids.ts";
5
+ import {
6
+ type AnyEvent,
7
+ type Event,
8
+ type EventPayloads,
9
+ type EventType,
10
+ eventFromFile,
11
+ eventToFile,
12
+ } from "./events.ts";
13
+
14
+ export const EVENTS_FILE_NAME = "events.jsonl";
15
+
16
+ /** Appends to one file are serialized within the process, so two instances cannot interleave the size check and the write. */
17
+ const appendQueues = new Map<string, Promise<unknown>>();
18
+
19
+ function serialized<T>(path: string, task: () => Promise<T>): Promise<T> {
20
+ const previous = appendQueues.get(path) ?? Promise.resolve();
21
+ const run = previous.then(task, task);
22
+ const settled = run
23
+ .catch(() => undefined)
24
+ .then(() => {
25
+ if (appendQueues.get(path) === settled) appendQueues.delete(path);
26
+ });
27
+ appendQueues.set(path, settled);
28
+ return run;
29
+ }
30
+
31
+ export interface NewEvent<T extends EventType = EventType> {
32
+ type: T;
33
+ payload: EventPayloads[T];
34
+ /** When the thing happened. Defaults to the time of recording. */
35
+ occurredAt?: string;
36
+ }
37
+
38
+ /**
39
+ * Append-only log of one work's events. The file is the source of truth.
40
+ *
41
+ * Every line is checked on open and on read; a line that cannot be read stops
42
+ * the reader. Every event is checked on append by reading its own line back
43
+ * before it is written, so what is written can always be read. A change to the
44
+ * file by someone else between two operations of this instance is refused.
45
+ */
46
+ export class EventLog {
47
+ private constructor(
48
+ private readonly path: string,
49
+ private readonly workId: WorkId,
50
+ private nextSeq: number,
51
+ private size: number,
52
+ ) {}
53
+
54
+ /** Opens (creating the directory if needed) and checks the existing log end to end. */
55
+ static async open(dir: string, workId: WorkId): Promise<EventLog> {
56
+ await mkdir(dir, { recursive: true });
57
+ const path = join(dir, EVENTS_FILE_NAME);
58
+ const { events, size } = await readAll(path, workId);
59
+ const last = events.at(-1);
60
+ return new EventLog(path, workId, (last?.seq ?? 0) + 1, size);
61
+ }
62
+
63
+ async append<T extends EventType>(input: NewEvent<T>): Promise<Event<T>> {
64
+ const now = new Date().toISOString();
65
+ const event = {
66
+ v: 1 as const,
67
+ id: newEventId(),
68
+ workId: this.workId,
69
+ seq: this.nextSeq,
70
+ type: input.type,
71
+ occurredAt: input.occurredAt ?? now,
72
+ recordedAt: now,
73
+ payload: input.payload,
74
+ } as Event<T>;
75
+
76
+ let line: string;
77
+ try {
78
+ line = `${JSON.stringify(eventToFile(event))}\n`;
79
+ eventFromFile(JSON.parse(line));
80
+ } catch (cause) {
81
+ throw new OpenshainError(
82
+ "invalid_event",
83
+ `${input.type} event cannot be read back once written: ${(cause as Error).message}`,
84
+ { cause },
85
+ );
86
+ }
87
+
88
+ await serialized(this.path, async () => {
89
+ const current = await fileSize(this.path);
90
+ if (current !== this.size) {
91
+ throw new OpenshainError(
92
+ "concurrent_write",
93
+ `${this.path} changed since it was opened (expected ${this.size} bytes, found ${current}); another writer is active`,
94
+ );
95
+ }
96
+ await appendFile(this.path, line, "utf8");
97
+ this.size += Buffer.byteLength(line, "utf8");
98
+ this.nextSeq += 1;
99
+ });
100
+ return event;
101
+ }
102
+
103
+ async read(): Promise<AnyEvent[]> {
104
+ return (await readAll(this.path, this.workId)).events;
105
+ }
106
+ }
107
+
108
+ async function fileSize(path: string): Promise<number> {
109
+ try {
110
+ return (await stat(path)).size;
111
+ } catch (err) {
112
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0;
113
+ throw err;
114
+ }
115
+ }
116
+
117
+ async function readAll(
118
+ path: string,
119
+ workId: WorkId,
120
+ ): Promise<{ events: AnyEvent[]; size: number }> {
121
+ let text: string;
122
+ try {
123
+ text = await readFile(path, "utf8");
124
+ } catch (err) {
125
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { events: [], size: 0 };
126
+ throw err;
127
+ }
128
+ const corrupt = (detail: string, cause?: unknown) =>
129
+ new OpenshainError("corrupt_log", `${path}: ${detail}`, { cause });
130
+
131
+ if (text.length > 0 && !text.endsWith("\n")) {
132
+ throw corrupt("does not end with a newline; the last write did not complete");
133
+ }
134
+
135
+ const events: AnyEvent[] = [];
136
+ const lines = text.split("\n");
137
+ lines.forEach((line, index) => {
138
+ const lineNo = index + 1;
139
+ if (line === "") {
140
+ if (index === lines.length - 1) return; // trailing newline
141
+ throw corrupt(`line ${lineNo} is empty`);
142
+ }
143
+ let parsed: unknown;
144
+ try {
145
+ parsed = JSON.parse(line);
146
+ } catch (cause) {
147
+ throw corrupt(`line ${lineNo} is not valid JSON`, cause);
148
+ }
149
+ let event: AnyEvent;
150
+ try {
151
+ event = eventFromFile(parsed);
152
+ } catch (cause) {
153
+ throw corrupt(`line ${lineNo} is not a valid event: ${(cause as Error).message}`, cause);
154
+ }
155
+ if (event.workId !== workId) {
156
+ throw corrupt(`line ${lineNo} belongs to ${event.workId}, expected ${workId}`);
157
+ }
158
+ const expectedSeq = events.length + 1;
159
+ if (event.seq !== expectedSeq) {
160
+ throw corrupt(`line ${lineNo} has seq ${event.seq}, expected ${expectedSeq}`);
161
+ }
162
+ events.push(event);
163
+ });
164
+ return { events, size: Buffer.byteLength(text, "utf8") };
165
+ }