@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,458 @@
1
+ import { z } from "zod";
2
+ import { OpenshainError } from "../errors.ts";
3
+ import type { EventId, WorkId } from "../ids.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Code-side types (camelCase)
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export type StopReason = "end_turn" | "tool_call" | "max_tokens" | "refusal" | "other";
10
+
11
+ export type AssistantPart =
12
+ | { type: "text"; text: string }
13
+ | { type: "tool_call"; id: string; name: string; input: unknown }
14
+ | { type: "opaque"; provider: string; data: unknown };
15
+
16
+ export type ToolContent = { type: "text"; text: string } | { type: "json"; value: unknown };
17
+
18
+ /**
19
+ * A file a work produced. `missing` means the runtime could not read it when the work ended; the
20
+ * hash is then the tool's report. `claimed` means an agent named it but no tool of this work wrote
21
+ * it; the hash is the runtime's, but nothing in the record ties the file to this work's calls.
22
+ */
23
+ export type Artifact = { path: string; sha256: string; missing?: true; claimed?: true };
24
+
25
+ export interface ModelUsage {
26
+ /** Every input token, including the ones read from or written to a prompt cache. */
27
+ inputTokens: number;
28
+ outputTokens: number;
29
+ /** The part of inputTokens served from a prompt cache. */
30
+ cachedInputTokens?: number;
31
+ /** The part of inputTokens written to a prompt cache. */
32
+ cacheWriteTokens?: number;
33
+ /** The part of outputTokens spent on reasoning. */
34
+ reasoningTokens?: number;
35
+ }
36
+
37
+ export const TOOL_REJECTION_CODES = [
38
+ "schema_mismatch",
39
+ "unknown_tool",
40
+ "not_allowed",
41
+ "reserved_path",
42
+ "outside_workspace",
43
+ "invalid_path",
44
+ ] as const;
45
+
46
+ export type ToolRejectionCode = (typeof TOOL_REJECTION_CODES)[number];
47
+
48
+ export interface EventPayloads {
49
+ "work.created": {
50
+ objective: string;
51
+ principal: string;
52
+ profession: string;
53
+ type: string;
54
+ /** The work this one was started from, such as the session that asked for it. */
55
+ parent?: string;
56
+ /** The name the model goes by in this work. A session picks it; the works it starts carry the same one. */
57
+ agentName?: string;
58
+ };
59
+ "work.status_changed": { from: string; to: string; reason: string };
60
+ "model.requested": { provider: string; model: string; messageCount: number; toolNames: string[] };
61
+ "model.completed": { stopReason: StopReason; content: AssistantPart[]; raw?: unknown };
62
+ "model.failed": { code: string; message: string };
63
+ "tool.called": { callId: string; provider: string; name: string; input: unknown };
64
+ "tool.completed": {
65
+ callId: string;
66
+ content: ToolContent[];
67
+ isError: boolean;
68
+ observation?: { source: string; retrievedAt: string };
69
+ after?: Artifact[];
70
+ };
71
+ "tool.rejected": { callId: string; name: string; code: ToolRejectionCode; reason: string };
72
+ "human.input_requested": { callId: string; question: string };
73
+ "human.input_provided": { callId: string; answer: string };
74
+ /** What the person said in a session. Becomes a user message in the projection. */
75
+ "human.message": { text: string };
76
+ "usage.recorded":
77
+ | { kind: "model_inference"; provider: string; model: string; usage: ModelUsage }
78
+ | { kind: "tool_execution"; provider: string; usage: { durationMs: number } };
79
+ "evidence.recorded": { claim: string; refs: string[]; artifacts: Artifact[] };
80
+ "work.completed": { summary: string };
81
+ "work.failed": { reason: string; detail: string };
82
+ }
83
+
84
+ export type EventType = keyof EventPayloads;
85
+
86
+ interface Envelope {
87
+ v: 1;
88
+ id: EventId;
89
+ workId: WorkId;
90
+ seq: number;
91
+ occurredAt: string;
92
+ recordedAt: string;
93
+ }
94
+
95
+ export type Event<T extends EventType = EventType> = T extends EventType
96
+ ? Envelope & { type: T; payload: EventPayloads[T] }
97
+ : never;
98
+
99
+ /** An event whose type this version of the runtime does not know. Kept, not validated. */
100
+ export type UnknownEvent = Envelope & { type: string; payload: unknown };
101
+
102
+ export type AnyEvent = Event | UnknownEvent;
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // File-side schemas (snake_case). These are the on-disk contract.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ // Payload schemas are loose: a field added by a newer runtime must not make an
109
+ // older runtime refuse the log. Only the envelope is strict; envelope changes bump `v`.
110
+ const textPart = z.looseObject({ type: z.literal("text"), text: z.string() });
111
+ const toolCallPart = z.looseObject({
112
+ type: z.literal("tool_call"),
113
+ id: z.string(),
114
+ name: z.string(),
115
+ input: z.unknown(),
116
+ });
117
+ const opaquePart = z.looseObject({
118
+ type: z.literal("opaque"),
119
+ provider: z.string(),
120
+ data: z.unknown(),
121
+ });
122
+ const jsonPart = z.looseObject({ type: z.literal("json"), value: z.unknown() });
123
+ const artifact = z.looseObject({
124
+ path: z.string(),
125
+ sha256: z.string(),
126
+ missing: z.literal(true).optional(),
127
+ claimed: z.literal(true).optional(),
128
+ });
129
+ const modelUsageFile = z.looseObject({
130
+ input_tokens: z.int().nonnegative(),
131
+ output_tokens: z.int().nonnegative(),
132
+ cached_input_tokens: z.int().nonnegative().optional(),
133
+ cache_write_tokens: z.int().nonnegative().optional(),
134
+ reasoning_tokens: z.int().nonnegative().optional(),
135
+ });
136
+ type ModelUsageFile = z.infer<typeof modelUsageFile>;
137
+
138
+ export const payloadFileSchemas = {
139
+ "work.created": z.looseObject({
140
+ objective: z.string(),
141
+ principal: z.string(),
142
+ profession: z.string(),
143
+ type: z.string(),
144
+ parent: z.string().optional(),
145
+ agent_name: z.string().optional(),
146
+ }),
147
+ "work.status_changed": z.looseObject({ from: z.string(), to: z.string(), reason: z.string() }),
148
+ "model.requested": z.looseObject({
149
+ provider: z.string(),
150
+ model: z.string(),
151
+ message_count: z.int().nonnegative(),
152
+ tool_names: z.array(z.string()),
153
+ }),
154
+ "model.completed": z.looseObject({
155
+ stop_reason: z.enum(["end_turn", "tool_call", "max_tokens", "refusal", "other"]),
156
+ content: z.array(z.discriminatedUnion("type", [textPart, toolCallPart, opaquePart])),
157
+ raw: z.unknown().optional(),
158
+ }),
159
+ "model.failed": z.looseObject({ code: z.string(), message: z.string() }),
160
+ "tool.called": z.looseObject({
161
+ call_id: z.string(),
162
+ provider: z.string(),
163
+ name: z.string(),
164
+ input: z.unknown(),
165
+ }),
166
+ "tool.completed": z.looseObject({
167
+ call_id: z.string(),
168
+ content: z.array(z.discriminatedUnion("type", [textPart, jsonPart])),
169
+ is_error: z.boolean(),
170
+ observation: z.looseObject({ source: z.string(), retrieved_at: z.iso.datetime() }).optional(),
171
+ after: z.array(artifact).optional(),
172
+ }),
173
+ "tool.rejected": z.looseObject({
174
+ call_id: z.string(),
175
+ name: z.string(),
176
+ code: z.enum(TOOL_REJECTION_CODES),
177
+ reason: z.string(),
178
+ }),
179
+ "human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
180
+ "human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
181
+ "human.message": z.looseObject({ text: z.string() }),
182
+ "usage.recorded": z.discriminatedUnion("kind", [
183
+ z.looseObject({
184
+ kind: z.literal("model_inference"),
185
+ provider: z.string(),
186
+ model: z.string(),
187
+ usage: modelUsageFile,
188
+ }),
189
+ z.looseObject({
190
+ kind: z.literal("tool_execution"),
191
+ provider: z.string(),
192
+ usage: z.looseObject({ duration_ms: z.int().nonnegative() }),
193
+ }),
194
+ ]),
195
+ "evidence.recorded": z.looseObject({
196
+ claim: z.string(),
197
+ refs: z.array(z.string()),
198
+ artifacts: z.array(artifact),
199
+ }),
200
+ "work.completed": z.looseObject({ summary: z.string() }),
201
+ "work.failed": z.looseObject({ reason: z.string(), detail: z.string() }),
202
+ } satisfies Record<EventType, z.ZodType>;
203
+
204
+ export const EventFileSchema = z.strictObject({
205
+ v: z.literal(1),
206
+ id: z.string(),
207
+ work_id: z.string(),
208
+ seq: z.int().positive(),
209
+ type: z.string(),
210
+ occurred_at: z.iso.datetime(),
211
+ recorded_at: z.iso.datetime(),
212
+ payload: z.unknown(),
213
+ });
214
+
215
+ export type EventFile = z.infer<typeof EventFileSchema>;
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Mapping. The only place where snake_case and camelCase meet.
219
+ // ---------------------------------------------------------------------------
220
+
221
+ export function eventToFile(event: AnyEvent): EventFile {
222
+ const payload = isKnownType(event.type)
223
+ ? payloadToFile(event.type, event.payload as EventPayloads[EventType])
224
+ : event.payload;
225
+ return {
226
+ v: event.v,
227
+ id: event.id,
228
+ work_id: event.workId,
229
+ seq: event.seq,
230
+ type: event.type,
231
+ occurred_at: event.occurredAt,
232
+ recorded_at: event.recordedAt,
233
+ payload: canonical(payload),
234
+ };
235
+ }
236
+
237
+ const DATA_KEYS = new Set(["input", "data", "value", "raw"]);
238
+
239
+ /**
240
+ * Canonical JSON form: object keys sorted recursively so that equal data is
241
+ * written as equal bytes. Inside the free-form fields (`input`, `data`, `value`,
242
+ * `raw`) `undefined` becomes `null`, because JSON has no `undefined` and a
243
+ * dropped key would make the line unreadable.
244
+ */
245
+ export function canonical(
246
+ value: unknown,
247
+ insideData = false,
248
+ seen: WeakSet<object> = new WeakSet(),
249
+ ): unknown {
250
+ if (value === undefined) return insideData ? null : undefined;
251
+ if (value === null || typeof value !== "object") return value;
252
+ if (seen.has(value)) {
253
+ throw new OpenshainError("invalid_event", "a value that refers to itself cannot be recorded");
254
+ }
255
+ seen.add(value);
256
+ try {
257
+ if (Array.isArray(value)) return value.map((item) => canonical(item, insideData, seen) ?? null);
258
+ const out: Record<string, unknown> = {};
259
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
260
+ const inner = insideData || DATA_KEYS.has(key);
261
+ const item = canonical((value as Record<string, unknown>)[key], inner, seen);
262
+ if (item !== undefined) define(out, key, item);
263
+ else if (inner) define(out, key, null);
264
+ }
265
+ return out;
266
+ } finally {
267
+ seen.delete(value);
268
+ }
269
+ }
270
+
271
+ /** Sets an own property even for a key such as `__proto__`, which plain assignment would treat as the prototype. */
272
+ function define(target: Record<string, unknown>, key: string, item: unknown): void {
273
+ Object.defineProperty(target, key, {
274
+ value: item,
275
+ enumerable: true,
276
+ writable: true,
277
+ configurable: true,
278
+ });
279
+ }
280
+
281
+ export function eventFromFile(input: unknown): AnyEvent {
282
+ const parsed = EventFileSchema.safeParse(input);
283
+ if (!parsed.success) {
284
+ throw new OpenshainError("corrupt_log", `event envelope: ${describeIssues(parsed.error)}`);
285
+ }
286
+ const file = parsed.data;
287
+ const envelope: Envelope = {
288
+ v: file.v,
289
+ id: file.id as EventId,
290
+ workId: file.work_id as WorkId,
291
+ seq: file.seq,
292
+ occurredAt: file.occurred_at,
293
+ recordedAt: file.recorded_at,
294
+ };
295
+ if (!isKnownType(file.type)) {
296
+ return { ...envelope, type: file.type, payload: file.payload };
297
+ }
298
+ const payload = payloadFileSchemas[file.type].safeParse(file.payload);
299
+ if (!payload.success) {
300
+ throw new OpenshainError(
301
+ "corrupt_log",
302
+ `${file.type} payload: ${describeIssues(payload.error)}`,
303
+ );
304
+ }
305
+ return {
306
+ ...envelope,
307
+ type: file.type,
308
+ payload: payloadFromFile(file.type, payload.data),
309
+ } as Event;
310
+ }
311
+
312
+ function isKnownType(type: string): type is EventType {
313
+ return Object.hasOwn(payloadFileSchemas, type);
314
+ }
315
+
316
+ function describeIssues(error: z.ZodError): string {
317
+ return error.issues
318
+ .map(
319
+ (issue) => (issue.path.length ? `${issue.path.map(String).join(".")}: ` : "") + issue.message,
320
+ )
321
+ .join("; ");
322
+ }
323
+
324
+ type FilePayload<T extends EventType> = z.infer<(typeof payloadFileSchemas)[T]>;
325
+
326
+ interface Codec<T extends EventType> {
327
+ toFile(payload: EventPayloads[T]): FilePayload<T>;
328
+ fromFile(payload: FilePayload<T>): EventPayloads[T];
329
+ }
330
+
331
+ function usageToFile(usage: ModelUsage): ModelUsageFile {
332
+ const out: ModelUsageFile = {
333
+ input_tokens: usage.inputTokens,
334
+ output_tokens: usage.outputTokens,
335
+ };
336
+ if (usage.cachedInputTokens !== undefined) out.cached_input_tokens = usage.cachedInputTokens;
337
+ if (usage.cacheWriteTokens !== undefined) out.cache_write_tokens = usage.cacheWriteTokens;
338
+ if (usage.reasoningTokens !== undefined) out.reasoning_tokens = usage.reasoningTokens;
339
+ return out;
340
+ }
341
+
342
+ function usageFromFile(usage: ModelUsageFile): ModelUsage {
343
+ const out: ModelUsage = { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens };
344
+ if (usage.cached_input_tokens !== undefined) out.cachedInputTokens = usage.cached_input_tokens;
345
+ if (usage.cache_write_tokens !== undefined) out.cacheWriteTokens = usage.cache_write_tokens;
346
+ if (usage.reasoning_tokens !== undefined) out.reasoningTokens = usage.reasoning_tokens;
347
+ return out;
348
+ }
349
+
350
+ /**
351
+ * Event types whose field names differ between code and file. Every other
352
+ * payload uses the same names on both sides and needs no codec.
353
+ */
354
+ const codecs: { [T in EventType]?: Codec<T> } = {
355
+ "work.created": {
356
+ // Only agent_name changes its name; every other field, known or not, passes through.
357
+ toFile: ({ agentName, ...rest }) => ({
358
+ ...rest,
359
+ ...(agentName !== undefined && { agent_name: agentName }),
360
+ }),
361
+ fromFile: ({ agent_name, parent, ...rest }) => ({
362
+ ...rest,
363
+ ...(parent !== undefined && { parent }),
364
+ ...(agent_name !== undefined && { agentName: agent_name }),
365
+ }),
366
+ },
367
+ "model.requested": {
368
+ toFile: (p) => ({
369
+ provider: p.provider,
370
+ model: p.model,
371
+ message_count: p.messageCount,
372
+ tool_names: p.toolNames,
373
+ }),
374
+ fromFile: (p) => ({
375
+ provider: p.provider,
376
+ model: p.model,
377
+ messageCount: p.message_count,
378
+ toolNames: p.tool_names,
379
+ }),
380
+ },
381
+ "model.completed": {
382
+ toFile: (p) => {
383
+ const out: FilePayload<"model.completed"> = { stop_reason: p.stopReason, content: p.content };
384
+ if (p.raw !== undefined) out.raw = p.raw;
385
+ return out;
386
+ },
387
+ fromFile: (p) => {
388
+ const out: EventPayloads["model.completed"] = {
389
+ stopReason: p.stop_reason,
390
+ content: p.content as AssistantPart[],
391
+ };
392
+ if (p.raw !== undefined) out.raw = p.raw;
393
+ return out;
394
+ },
395
+ },
396
+ "tool.called": {
397
+ toFile: (p) => ({ call_id: p.callId, provider: p.provider, name: p.name, input: p.input }),
398
+ fromFile: (p) => ({ callId: p.call_id, provider: p.provider, name: p.name, input: p.input }),
399
+ },
400
+ "tool.completed": {
401
+ toFile: (p) => {
402
+ const out: FilePayload<"tool.completed"> = {
403
+ call_id: p.callId,
404
+ content: p.content,
405
+ is_error: p.isError,
406
+ };
407
+ if (p.observation) {
408
+ out.observation = { source: p.observation.source, retrieved_at: p.observation.retrievedAt };
409
+ }
410
+ if (p.after) out.after = p.after;
411
+ return out;
412
+ },
413
+ fromFile: (p) => {
414
+ const out: EventPayloads["tool.completed"] = {
415
+ callId: p.call_id,
416
+ content: p.content as ToolContent[],
417
+ isError: p.is_error,
418
+ };
419
+ if (p.observation) {
420
+ out.observation = { source: p.observation.source, retrievedAt: p.observation.retrieved_at };
421
+ }
422
+ if (p.after) out.after = p.after.map((a) => ({ path: a.path, sha256: a.sha256 }));
423
+ return out;
424
+ },
425
+ },
426
+ "tool.rejected": {
427
+ toFile: (p) => ({ call_id: p.callId, name: p.name, code: p.code, reason: p.reason }),
428
+ fromFile: (p) => ({ callId: p.call_id, name: p.name, code: p.code, reason: p.reason }),
429
+ },
430
+ "human.input_requested": {
431
+ toFile: (p) => ({ call_id: p.callId, question: p.question }),
432
+ fromFile: (p) => ({ callId: p.call_id, question: p.question }),
433
+ },
434
+ "human.input_provided": {
435
+ toFile: (p) => ({ call_id: p.callId, answer: p.answer }),
436
+ fromFile: (p) => ({ callId: p.call_id, answer: p.answer }),
437
+ },
438
+ "usage.recorded": {
439
+ toFile: (p) =>
440
+ p.kind === "tool_execution"
441
+ ? { kind: p.kind, provider: p.provider, usage: { duration_ms: p.usage.durationMs } }
442
+ : { kind: p.kind, provider: p.provider, model: p.model, usage: usageToFile(p.usage) },
443
+ fromFile: (p) =>
444
+ p.kind === "tool_execution"
445
+ ? { kind: p.kind, provider: p.provider, usage: { durationMs: p.usage.duration_ms } }
446
+ : { kind: p.kind, provider: p.provider, model: p.model, usage: usageFromFile(p.usage) },
447
+ },
448
+ };
449
+
450
+ function payloadToFile<T extends EventType>(type: T, payload: EventPayloads[T]): FilePayload<T> {
451
+ const codec = codecs[type] as Codec<T> | undefined;
452
+ return codec ? codec.toFile(payload) : (payload as unknown as FilePayload<T>);
453
+ }
454
+
455
+ function payloadFromFile<T extends EventType>(type: T, payload: FilePayload<T>): EventPayloads[T] {
456
+ const codec = codecs[type] as Codec<T> | undefined;
457
+ return codec ? codec.fromFile(payload) : (payload as unknown as EventPayloads[T]);
458
+ }
@@ -0,0 +1,94 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { OpenshainError } from "../errors.ts";
4
+
5
+ export const LOCK_FILE_NAME = "lock";
6
+
7
+ export interface Lock {
8
+ release(): Promise<void>;
9
+ }
10
+
11
+ interface Holder {
12
+ pid: number;
13
+ startedAt: string;
14
+ }
15
+
16
+ /**
17
+ * Takes the single-writer lock of a work directory. A lock left behind by a
18
+ * process that no longer exists, or one that cannot be read, is taken over.
19
+ * Release only removes the file while it still records this holder.
20
+ *
21
+ * Known limit: liveness is judged by pid. A pid reused by an unrelated process
22
+ * keeps the lock held until that process ends or the file is removed by hand.
23
+ */
24
+ export async function acquireLock(dir: string): Promise<Lock> {
25
+ try {
26
+ await mkdir(dir, { recursive: true });
27
+ } catch (cause) {
28
+ throw new OpenshainError("invalid_path", `cannot create the work directory ${dir}`, { cause });
29
+ }
30
+ const path = join(dir, LOCK_FILE_NAME);
31
+ const holder: Holder = { pid: process.pid, startedAt: new Date().toISOString() };
32
+ const content = JSON.stringify({ pid: holder.pid, started_at: holder.startedAt });
33
+
34
+ for (let attempt = 0; attempt < 3; attempt++) {
35
+ try {
36
+ await writeFile(path, content, { flag: "wx" });
37
+ return lockHandle(path, holder);
38
+ } catch (err) {
39
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
40
+ throw new OpenshainError("invalid_path", `cannot write the lock file ${path}`, {
41
+ cause: err,
42
+ });
43
+ }
44
+ }
45
+ const current = await readHolder(path);
46
+ if (current && isAlive(current.pid)) {
47
+ throw new OpenshainError(
48
+ "lock_held",
49
+ `${dir} is locked by process ${current.pid} since ${current.startedAt}`,
50
+ );
51
+ }
52
+ await rm(path, { force: true });
53
+ }
54
+ throw new OpenshainError("lock_held", `${dir}: could not take the lock`);
55
+ }
56
+
57
+ async function readHolder(path: string): Promise<Holder | undefined> {
58
+ try {
59
+ const parsed = JSON.parse(await readFile(path, "utf8")) as {
60
+ pid?: unknown;
61
+ started_at?: unknown;
62
+ };
63
+ if (typeof parsed.pid !== "number") return undefined;
64
+ return { pid: parsed.pid, startedAt: String(parsed.started_at ?? "unknown") };
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ /** Only real process ids count. 0 and negatives address process groups and would always "exist". */
71
+ function isAlive(pid: number): boolean {
72
+ if (!Number.isInteger(pid) || pid <= 1) return false;
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ } catch (err) {
77
+ return (err as NodeJS.ErrnoException).code === "EPERM";
78
+ }
79
+ }
80
+
81
+ function lockHandle(path: string, holder: Holder): Lock {
82
+ let released = false;
83
+ return {
84
+ async release() {
85
+ if (released) return;
86
+ released = true;
87
+ const current = await readHolder(path);
88
+ if (current && (current.pid !== holder.pid || current.startedAt !== holder.startedAt)) {
89
+ return; // someone else holds it now; not ours to remove
90
+ }
91
+ await rm(path, { force: true });
92
+ },
93
+ };
94
+ }