@xandreed/process-host 0.0.0-stage → 0.15.1

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,72 @@
1
+ import { Cause, Effect, Match, Option, Schema } from "effect";
2
+ import { TurnEndedData, TurnEvent, TurnStartedData } from "@xandreed/core";
3
+ import { ProcessPeerError } from "./process-peer.entity.js";
4
+ import {
5
+ ProcessCommand,
6
+ ProcessEvent,
7
+ ProcessFullEvent,
8
+ ProcessProgress
9
+ } from "./process-protocol.entity.js";
10
+ const strict = { onExcessProperty: "error" }, boundary = (effect, direction) => effect.pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(Cause.fromReasons(cause.reasons.filter(Cause.isInterruptReason))) : Effect.fail(new ProcessPeerError({
11
+ reason: direction === "input" ? "InvalidFrame" : "InvalidEvent",
12
+ direction,
13
+ message: direction === "input" ? "The process command is invalid" : "The process event could not be encoded",
14
+ cause
15
+ })))), snapshotJson = (value) => Effect.try({ try: () => structuredClone(value), catch: (cause) => cause }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.JsonObject)));
16
+ export const decodeProcessCommand = Effect.fnUntraced(function* (text) {
17
+ return yield* boundary(Effect.suspend(() => Schema.decodeUnknownEffect(Schema.fromJsonString(ProcessCommand))(text, strict)), "input");
18
+ });
19
+ const snapshotFullProgress = Effect.fnUntraced(function* (event) {
20
+ return yield* ProcessEvent.matchOrElse(event, {
21
+ "turn.event": (frame) => ProcessProgress.match(frame.progress, {
22
+ projection: () => Effect.succeed(frame),
23
+ full: (progress) => snapshotJson(progress.event).pipe(Effect.flatMap((snapshot) => Schema.decodeUnknownEffect(ProcessProgress.cases.full)({ mode: "full", event: snapshot }, strict)), Effect.map((snapshot) => ({ ...frame, progress: snapshot })))
24
+ })
25
+ }, (frame) => Effect.succeed(frame));
26
+ });
27
+ export const encodeProcessEvent = Effect.fnUntraced(function* (event) {
28
+ return yield* boundary(Effect.suspend(() => snapshotFullProgress(event).pipe(Effect.flatMap(Schema.encodeEffect(ProcessEvent)), Effect.flatMap(snapshotJson), Effect.flatMap(Schema.encodeEffect(Schema.fromJsonString(Schema.JsonObject))))), "output");
29
+ });
30
+ const toolIdentity = (event) => ({
31
+ step: event.step,
32
+ invocationId: event.invocationId,
33
+ ...event.toolCallId === void 0 ? {} : { toolCallId: event.toolCallId },
34
+ tool: event.tool
35
+ }), projectionOf = (event) => TurnEvent.matchOrElse(event, {
36
+ "step.started": (value) => Option.some({ _tag: "step.started", step: value.step, run: value.run, planned: value.planned }),
37
+ "step.ended": (value) => Option.some({ _tag: "step.ended", step: value.step, run: value.run, status: value.status }),
38
+ "assistant.delta": (value) => value.channel === "text" ? Option.some({ _tag: "assistant.delta", step: value.step, id: value.id, delta: value.delta }) : Option.none(),
39
+ "assistant.message": (value) => Option.some({ _tag: "assistant.message", step: value.step, run: value.run, text: value.text }),
40
+ "tool.started": (value) => Option.some({ _tag: "tool.started", ...toolIdentity(value) }),
41
+ "tool.completed": (value) => Option.some({ _tag: "tool.completed", ...toolIdentity(value), ok: value.ok }),
42
+ "tool.refused": (value) => Option.some({ _tag: "tool.refused", ...toolIdentity(value), reason: value.reason }),
43
+ "tool.progress": (value) => value.channel === "status" ? Option.some({ _tag: "tool.progress", ...toolIdentity(value), text: value.text }) : Option.none(),
44
+ "stream.fallback": (value) => Option.some({ _tag: "stream.fallback", step: value.step, run: value.run })
45
+ }, () => Option.none());
46
+ export const processProgressOf = Effect.fnUntraced(function* (event, mode) {
47
+ return yield* boundary(Effect.suspend(() => {
48
+ if (TurnEvent.isAnyOf(["turn.started", "turn.ended"])(event))
49
+ return Effect.succeed(Option.none());
50
+ return Match.value(mode).pipe(Match.when("projection", () => Effect.succeed(Option.map(projectionOf(event), (projected) => ({ mode: "projection", event: projected })))), Match.when("full", () => Schema.encodeEffect(ProcessFullEvent)(event).pipe(Effect.flatMap(snapshotJson), Effect.flatMap((snapshot) => Schema.decodeUnknownEffect(ProcessProgress.cases.full)({ mode: "full", event: snapshot }, strict)), Effect.map(Option.some))), Match.exhaustive);
51
+ }), "output");
52
+ }), processLifecycleOf = Effect.fnUntraced(function* (session, row, requestId) {
53
+ return yield* boundary(Effect.suspend(() => Effect.gen(function* () {
54
+ if (row.kind !== "turn.started" && row.kind !== "turn.ended")
55
+ return Option.none();
56
+ const turn = yield* Schema.decodeUnknownEffect(ProcessEvent.cases["turn.ended"].fields.turn)(Option.getOrElse(row.turn, () => 0)), identity = { session, turn, ...requestId === void 0 ? {} : { requestId }, journal: { seq: row.seq, at: row.at } }, frame = row.kind === "turn.started" ? yield* Schema.decodeUnknownEffect(TurnStartedData)(row.data).pipe(Effect.map((data) => ({
57
+ _tag: "turn.started",
58
+ ...identity,
59
+ runId: data.runId,
60
+ origin: data.origin,
61
+ ...data.input === void 0 ? {} : { input: data.input },
62
+ ...data.task === void 0 ? {} : { task: data.task },
63
+ ...data.resumes === void 0 ? {} : { resumes: data.resumes }
64
+ }))) : yield* Schema.decodeUnknownEffect(TurnEndedData)(row.data).pipe(Effect.map((data) => ({
65
+ _tag: "turn.ended",
66
+ ...identity,
67
+ reason: data.reason,
68
+ ...Option.match(data.failure, { onNone: () => ({}), onSome: (failure) => ({ failureCode: failure.code }) })
69
+ })));
70
+ return Option.some(yield* Schema.decodeUnknownEffect(ProcessEvent)(frame, strict));
71
+ })), "output");
72
+ });
@@ -0,0 +1,155 @@
1
+ import { Schema, Struct } from "effect";
2
+ import {
3
+ ConversationId,
4
+ HarnessCode,
5
+ InboxItemId,
6
+ InvocationId,
7
+ JsonObject,
8
+ ProcessActivity,
9
+ QueuedInput,
10
+ RunId,
11
+ RunKey,
12
+ SessionAddress,
13
+ ShutdownReport,
14
+ StepStatus,
15
+ StreamPartId,
16
+ TaskQuestion,
17
+ TaskQuestionId,
18
+ TaskRef,
19
+ TokenUsage,
20
+ ToolCallId,
21
+ ToolRefusalReason,
22
+ TurnEndReason,
23
+ TurnEvent,
24
+ TurnOrigin,
25
+ TurnOutcomeKind,
26
+ UserMessage
27
+ } from "@xandreed/core";
28
+ export const PROCESS_PROTOCOL_VERSION = 1;
29
+ const Count = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), Positive = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }));
30
+ export const ProcessRequestId = Schema.Trimmed.check(Schema.isMinLength(1), Schema.isMaxLength(128), Schema.isPattern(/^[A-Za-z0-9_.:-]+$/)).pipe(Schema.brand("ProcessRequestId")), ProcessEventMode = Schema.Literals(["projection", "full"]);
31
+ const Input = QueuedInput.mapFields(Struct.omit(["delivery"]));
32
+ export const ProcessCommand = Schema.Union([
33
+ Schema.TaggedStruct("hello", { requestId: ProcessRequestId, protocolVersion: Positive }),
34
+ Schema.TaggedStruct("begin", {
35
+ requestId: ProcessRequestId,
36
+ session: SessionAddress,
37
+ runId: RunId,
38
+ userMessage: UserMessage,
39
+ key: Schema.optionalKey(Schema.String),
40
+ command: Schema.optionalKey(JsonObject),
41
+ meta: Schema.optionalKey(JsonObject),
42
+ resumes: Schema.optionalKey(Positive),
43
+ takeOver: Schema.optionalKey(Schema.Boolean),
44
+ timeLimitMs: Schema.optionalKey(Positive)
45
+ }),
46
+ Schema.TaggedStruct("send", { requestId: ProcessRequestId, session: SessionAddress, input: Input }),
47
+ Schema.TaggedStruct("steer", { requestId: ProcessRequestId, session: SessionAddress, input: Input }),
48
+ Schema.TaggedStruct("answer", {
49
+ requestId: ProcessRequestId,
50
+ parent: SessionAddress,
51
+ taskId: ConversationId,
52
+ questionId: TaskQuestionId,
53
+ text: TaskQuestion.fields.text
54
+ }),
55
+ Schema.TaggedStruct("cancel", { requestId: ProcessRequestId, session: SessionAddress, turn: Positive }),
56
+ Schema.TaggedStruct("ping", { requestId: ProcessRequestId }),
57
+ Schema.TaggedStruct("shutdown", { requestId: ProcessRequestId, deadlineMs: Schema.optionalKey(Count) })
58
+ ]).pipe(Schema.toTaggedUnion("_tag"));
59
+ const ToolIdentity = {
60
+ step: Count,
61
+ invocationId: InvocationId,
62
+ toolCallId: Schema.optionalKey(ToolCallId),
63
+ tool: Schema.String
64
+ };
65
+ export const ProcessProjection = Schema.Union([
66
+ Schema.TaggedStruct("step.started", { step: Count, run: RunKey, planned: Schema.Boolean }),
67
+ Schema.TaggedStruct("step.ended", { step: Count, run: RunKey, status: StepStatus }),
68
+ Schema.TaggedStruct("assistant.delta", { step: Count, id: StreamPartId, delta: Schema.String }),
69
+ Schema.TaggedStruct("assistant.message", { step: Count, run: RunKey, text: Schema.String }),
70
+ Schema.TaggedStruct("tool.started", ToolIdentity),
71
+ Schema.TaggedStruct("tool.completed", { ...ToolIdentity, ok: Schema.Boolean }),
72
+ Schema.TaggedStruct("tool.refused", { ...ToolIdentity, reason: ToolRefusalReason }),
73
+ Schema.TaggedStruct("tool.progress", { ...ToolIdentity, text: Schema.String }),
74
+ Schema.TaggedStruct("stream.fallback", { step: Count, run: RunKey })
75
+ ]).pipe(Schema.toTaggedUnion("_tag")), ProcessFullEvent = Schema.Union([
76
+ TurnEvent.cases["step.started"],
77
+ TurnEvent.cases["tool.started"],
78
+ TurnEvent.cases["tool.refused"],
79
+ TurnEvent.cases["tool.progress"],
80
+ TurnEvent.cases["tool.completed"],
81
+ TurnEvent.cases["step.ended"],
82
+ TurnEvent.cases["completion.evaluated"],
83
+ TurnEvent.cases["skills.activated"],
84
+ TurnEvent.cases["context.built"],
85
+ TurnEvent.cases["decision.recorded"],
86
+ TurnEvent.cases["assistant.message"],
87
+ TurnEvent.cases["assistant.delta"],
88
+ TurnEvent.cases["stream.fallback"],
89
+ TurnEvent.cases.host,
90
+ TurnEvent.cases["model.called"],
91
+ TurnEvent.cases["tool.intercepted"]
92
+ ]);
93
+ const FullProgress = Schema.toEncoded(ProcessFullEvent).check(Schema.makeFilter(Schema.is(Schema.JsonObject)));
94
+ export const ProcessProgress = Schema.Union([
95
+ Schema.Struct({ mode: Schema.Literal("projection"), event: ProcessProjection }),
96
+ Schema.Struct({ mode: Schema.Literal("full"), event: FullProgress })
97
+ ]).pipe(Schema.toTaggedUnion("mode")), ProcessTurnResult = Schema.Struct({
98
+ outcome: TurnOutcomeKind,
99
+ reply: Schema.OptionFromNullOr(Schema.String),
100
+ usage: TokenUsage,
101
+ pending: Schema.optionalKey(Count)
102
+ });
103
+ const TurnIdentity = { session: SessionAddress, turn: Positive }, Correlation = { requestId: Schema.optionalKey(ProcessRequestId) }, Journal = Schema.Struct({ seq: Positive, at: Schema.Finite });
104
+ export const ProcessErrorCode = HarnessCode.pick([
105
+ "protocol.handshake",
106
+ "protocol.version",
107
+ "protocol.sequence",
108
+ "protocol.invalid",
109
+ "host.busy",
110
+ "host.owner",
111
+ "host.stopping",
112
+ "host.unsupported",
113
+ "host.command",
114
+ "host.setup",
115
+ "host.runtime",
116
+ "host.peer"
117
+ ]), ProcessEvent = Schema.Union([
118
+ Schema.TaggedStruct("hello", { requestId: ProcessRequestId, protocolVersion: Schema.Literals([PROCESS_PROTOCOL_VERSION]) }),
119
+ Schema.TaggedStruct("turn.started", {
120
+ ...Correlation,
121
+ ...TurnIdentity,
122
+ runId: RunId,
123
+ origin: TurnOrigin,
124
+ input: Schema.optionalKey(InboxItemId),
125
+ task: Schema.optionalKey(TaskRef),
126
+ resumes: Schema.optionalKey(Positive),
127
+ journal: Journal
128
+ }),
129
+ Schema.TaggedStruct("turn.event", { ...Correlation, ...TurnIdentity, runId: RunId, progress: ProcessProgress }),
130
+ Schema.TaggedStruct("turn.ended", {
131
+ ...Correlation,
132
+ ...TurnIdentity,
133
+ reason: TurnEndReason,
134
+ failureCode: Schema.optionalKey(Schema.String),
135
+ journal: Journal
136
+ }),
137
+ Schema.TaggedStruct("begin.result", { requestId: ProcessRequestId, result: ProcessTurnResult }),
138
+ Schema.TaggedStruct("queued", {
139
+ requestId: ProcessRequestId,
140
+ command: Schema.Literals(["send", "steer"]),
141
+ enqueued: Schema.Boolean,
142
+ pending: Count
143
+ }),
144
+ Schema.TaggedStruct("answered", { requestId: ProcessRequestId, accepted: Schema.Literals([!0]), enqueued: Schema.Boolean }),
145
+ Schema.TaggedStruct("cancelled", { requestId: ProcessRequestId, cancelled: Schema.Boolean, pending: Count }),
146
+ Schema.TaggedStruct("pong", { requestId: ProcessRequestId, activity: ProcessActivity }),
147
+ Schema.TaggedStruct("shutdown", { ...Correlation, report: ShutdownReport }),
148
+ Schema.TaggedStruct("error", {
149
+ ...Correlation,
150
+ code: ProcessErrorCode,
151
+ message: Schema.String,
152
+ fatal: Schema.Boolean,
153
+ frameworkCode: Schema.optionalKey(Schema.String)
154
+ })
155
+ ]).pipe(Schema.toTaggedUnion("_tag"));
@@ -0,0 +1,18 @@
1
+ import type { ProcessHostOptions } from "./process-host.entity.js";
2
+ import type { StdioPeerOptions } from "./stdio-peer.adapter.js";
3
+ /** The native entry point's settings, beside the reusable server's host binding. @public */
4
+ export interface RunProcessHostOptions<E = never> extends ProcessHostOptions<E> {
5
+ readonly peer?: StdioPeerOptions;
6
+ /** Independent of graceful shutdown and scope finalizers; defaults to 30 seconds after stopping begins. */
7
+ readonly hardDeadlineMs?: number;
8
+ }
9
+ /**
10
+ * Run a host's entry module until the process exits. The first signal starts
11
+ * graceful shutdown, a second signal exits immediately, and an independent
12
+ * timer bounds asynchronous stuck work, writes and finalizers. Use serveProcess
13
+ * when embedding the server without owning the native process. Exit 0 follows
14
+ * acknowledged native writes, not the parent's reads: Bun reports a write to a
15
+ * closed stdout pipe as successful, where Node fails it and exits 1.
16
+ * @public
17
+ */
18
+ export declare const runProcessHost: <E>(options: RunProcessHostOptions<E>) => Promise<never>;
@@ -0,0 +1,49 @@
1
+ import process from "node:process";
2
+ import { Cause, Deferred, Effect, Exit, Logger, MutableRef, Option, Predicate, Result, Schema } from "effect";
3
+ import { harnessError } from "@xandreed/core";
4
+ import { serveProcess } from "./process-host.adapter.js";
5
+ import { PROCESS_EXIT_CODES, ProcessRuntimeConfig } from "./process-runtime.entity.js";
6
+ import { StdioPeerLive } from "./stdio-peer.adapter.js";
7
+ export const runProcessHost = (options) => {
8
+ const signal = Deferred.makeUnsafe(), firstSignal = MutableRef.make(Option.none()), timer = MutableRef.make(Option.none()), listeners = MutableRef.make(Option.none()), program = Effect.gen(function* () {
9
+ const config = yield* Schema.decodeUnknownEffect(ProcessRuntimeConfig)({ hardDeadlineMs: options.hardDeadlineMs ?? 30000 }).pipe(Effect.mapError(() => harnessError("config.options", "The process host hard deadline is invalid."))), startTimer = () => {
10
+ if (Option.isNone(MutableRef.get(timer)))
11
+ MutableRef.set(timer, Option.some(setTimeout(() => process.exit(PROCESS_EXIT_CODES.hardDeadline), config.hardDeadlineMs)));
12
+ }, onSignal = (name) => {
13
+ if (Option.isSome(MutableRef.get(firstSignal)))
14
+ process.exit(PROCESS_EXIT_CODES[name]);
15
+ MutableRef.set(firstSignal, Option.some(name));
16
+ startTimer();
17
+ Deferred.doneUnsafe(signal, Effect.void);
18
+ }, handlers = { SIGINT: () => onSignal("SIGINT"), SIGTERM: () => onSignal("SIGTERM") };
19
+ yield* Effect.sync(() => {
20
+ process.on("SIGINT", handlers.SIGINT);
21
+ process.on("SIGTERM", handlers.SIGTERM);
22
+ MutableRef.set(listeners, Option.some(handlers));
23
+ });
24
+ const stopping = Effect.sync(startTimer).pipe(Effect.andThen(options.onStopping ?? Effect.void)), stop = Effect.raceFirst(Deferred.await(signal), options.stop ?? Effect.never);
25
+ return yield* serveProcess({ ...options, stop, onStopping: stopping }).pipe(Effect.onExit(() => Effect.sync(startTimer)), Effect.provide(StdioPeerLive(options.peer)), Effect.scoped);
26
+ }).pipe(Effect.provideService(Logger.LogToStderr, !0));
27
+ return Effect.runPromiseExit(program).then((exit) => {
28
+ Option.match(MutableRef.get(listeners), { onNone: () => {
29
+ return;
30
+ }, onSome: (handlers) => {
31
+ process.removeListener("SIGINT", handlers.SIGINT);
32
+ process.removeListener("SIGTERM", handlers.SIGTERM);
33
+ } });
34
+ Option.match(MutableRef.get(timer), { onNone: () => {
35
+ return;
36
+ }, onSome: clearTimeout });
37
+ const code = Option.match(MutableRef.get(firstSignal), {
38
+ onSome: (name) => PROCESS_EXIT_CODES[name],
39
+ onNone: () => Exit.match(exit, {
40
+ onSuccess: () => PROCESS_EXIT_CODES.clean,
41
+ onFailure: (cause) => Result.match(Cause.findError(cause), {
42
+ onFailure: () => PROCESS_EXIT_CODES.failure,
43
+ onSuccess: (error) => Predicate.isTagged(error, "ProcessHostError") && error.reason === "Protocol" ? PROCESS_EXIT_CODES.protocol : PROCESS_EXIT_CODES.failure
44
+ })
45
+ })
46
+ });
47
+ return process.exit(code);
48
+ });
49
+ };
@@ -0,0 +1,13 @@
1
+ import { Schema } from "effect";
2
+ /** Native process outcomes; a forced exit can truncate an output frame. @public */
3
+ export declare const PROCESS_EXIT_CODES: {
4
+ readonly clean: 0;
5
+ readonly failure: 1;
6
+ readonly protocol: 2;
7
+ readonly hardDeadline: 124;
8
+ readonly SIGINT: 130;
9
+ readonly SIGTERM: 143;
10
+ };
11
+ export declare const ProcessRuntimeConfig: Schema.Struct<{
12
+ readonly hardDeadlineMs: Schema.Int;
13
+ }>;
@@ -0,0 +1,11 @@
1
+ import { Schema } from "effect";
2
+ export const PROCESS_EXIT_CODES = {
3
+ clean: 0,
4
+ failure: 1,
5
+ protocol: 2,
6
+ hardDeadline: 124,
7
+ SIGINT: 130,
8
+ SIGTERM: 143
9
+ }, ProcessRuntimeConfig = Schema.Struct({
10
+ hardDeadlineMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 2147483647 }))
11
+ });
@@ -0,0 +1,11 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import { Layer } from "effect";
3
+ import { ProcessPeer } from "./ports/process-peer.port.js";
4
+ import { ProcessPeerOptions } from "./process-peer.entity.js";
5
+ /** Host-owned native streams remain open when the peer scope closes. @public */
6
+ export interface StdioPeerOptions extends Partial<ProcessPeerOptions> {
7
+ readonly input?: Readable;
8
+ readonly output?: Writable;
9
+ }
10
+ /** Bounded binary NDJSON over Node-compatible streams on Node 24 and Bun. @public */
11
+ export declare const StdioPeerLive: (options?: StdioPeerOptions) => Layer.Layer<ProcessPeer, import("@xandreed/core").HarnessError, never>;
@@ -0,0 +1,260 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import { Cause, Deferred, Effect, Exit, Layer, MutableRef, Option, Queue, Schema, Semaphore, Stream } from "effect";
3
+ import { harnessError } from "@xandreed/core";
4
+ import { ProcessPeer } from "./ports/process-peer.port.js";
5
+ import {
6
+ PROCESS_PEER_DEFAULTS,
7
+ ProcessPeerError,
8
+ ProcessPeerPhase,
9
+ ProcessInputPhase,
10
+ ProcessPeerOptions
11
+ } from "./process-peer.entity.js";
12
+ import { decodeProcessCommand, encodeProcessEvent } from "./process-protocol.entity.functions.js";
13
+ const messages = {
14
+ Input: "The process input failed",
15
+ InvalidFrame: "The process command is invalid",
16
+ InvalidUtf8: "The process frame is not valid UTF-8",
17
+ FrameTooLarge: "The process frame exceeds the configured byte limit",
18
+ InvalidEvent: "The process event could not be encoded",
19
+ Output: "The process output failed",
20
+ WriteTimeout: "The process write acknowledgement timed out",
21
+ Capacity: "The process writer capacity is exhausted",
22
+ Closed: "The process peer is closed"
23
+ }, peerError = (reason, direction, cause) => new ProcessPeerError({ reason, direction, message: messages[reason], ...cause === void 0 ? {} : { cause } }), makePeer = Effect.fnUntraced(function* (options) {
24
+ const config = yield* Schema.decodeUnknownEffect(ProcessPeerOptions)({
25
+ ...PROCESS_PEER_DEFAULTS,
26
+ ...Object.fromEntries(Object.entries(options).filter(([key]) => key !== "input" && key !== "output"))
27
+ }, { onExcessProperty: "error" }).pipe(Effect.mapError(() => harnessError("config.options", "Invalid process peer options"))), input = options.input ?? stdin, output = options.output ?? stdout;
28
+ if (input.readableObjectMode || input.readableEncoding !== null || output.writableObjectMode)
29
+ return yield* Effect.fail(harnessError("config.options", "Process peer streams must use binary mode"));
30
+ const scope = yield* Effect.scope, wake = yield* Queue.sliding(1), serial = yield* Semaphore.make(1), slots = yield* Semaphore.make(config.maxPendingWrites), phase = MutableRef.make({ _tag: "Open" }), source = MutableRef.make(input.readableEnded ? { _tag: "Ended" } : input.destroyed ? { _tag: "Failed", error: peerError("Closed", "input") } : { _tag: "Open" }), consumed = MutableRef.make(!1), disposed = MutableRef.make(!1), pending = new Set, activeWrite = MutableRef.make(Option.none()), partial = MutableRef.make(new Uint8Array(Math.min(config.readChunkBytes, config.maxFrameBytes + 1))), partialSize = MutableRef.make(0), chunk = MutableRef.make(new Uint8Array(0)), offset = MutableRef.make(0), decoder = new TextDecoder("utf-8", { fatal: !0, ignoreBOM: !0 }), encoder = new TextEncoder, currentFailure = () => ProcessPeerPhase.match(phase.current, {
31
+ Open: () => Option.none(),
32
+ Failed: ({ error }) => Option.some(error),
33
+ Closed: () => Option.some(peerError("Closed", "output"))
34
+ }), signal = () => {
35
+ Queue.offerUnsafe(wake, void 0);
36
+ }, latch = (error) => {
37
+ const first = Option.getOrElse(currentFailure(), () => error);
38
+ phase.current = { _tag: "Failed", error: first };
39
+ source.current = { _tag: "Failed", error: first };
40
+ pending.forEach((ack) => {
41
+ Deferred.doneUnsafe(ack, Effect.fail(first));
42
+ });
43
+ Option.map(activeWrite.current, (ack) => Deferred.doneUnsafe(ack, Effect.fail(first)));
44
+ signal();
45
+ }, onReadable = () => {
46
+ signal();
47
+ }, onInputEnd = () => {
48
+ if (source.current._tag === "Open")
49
+ source.current = { _tag: "Ended" };
50
+ signal();
51
+ }, onInputError = (cause) => {
52
+ source.current = { _tag: "Failed", error: peerError("Input", "input", cause) };
53
+ signal();
54
+ }, onInputClose = () => {
55
+ if (!input.readableEnded && source.current._tag === "Open" && !disposed.current)
56
+ source.current = { _tag: "Failed", error: peerError("Closed", "input") };
57
+ signal();
58
+ }, onOutputError = (cause) => {
59
+ latch(peerError("Output", "output", cause));
60
+ }, onOutputClose = () => {
61
+ if (!disposed.current)
62
+ latch(peerError("Closed", "output"));
63
+ };
64
+ input.on("readable", onReadable);
65
+ input.on("end", onInputEnd);
66
+ input.on("error", onInputError);
67
+ input.on("close", onInputClose);
68
+ output.on("error", onOutputError);
69
+ output.on("close", onOutputClose);
70
+ output.on("finish", onOutputClose);
71
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
72
+ disposed.current = !0;
73
+ const error = Option.getOrElse(currentFailure(), () => peerError("Closed", "output"));
74
+ if (phase.current._tag === "Open")
75
+ phase.current = { _tag: "Closed" };
76
+ source.current = { _tag: "Failed", error };
77
+ pending.forEach((ack) => {
78
+ Deferred.doneUnsafe(ack, Effect.fail(error));
79
+ });
80
+ Option.map(activeWrite.current, (ack) => Deferred.doneUnsafe(ack, Effect.fail(error)));
81
+ signal();
82
+ yield* slots.releaseAll;
83
+ yield* Effect.callback((resume) => {
84
+ const handle = setImmediate(() => resume(Effect.void));
85
+ return Effect.sync(() => clearImmediate(handle));
86
+ });
87
+ input.off("readable", onReadable);
88
+ input.off("end", onInputEnd);
89
+ input.off("error", onInputError);
90
+ input.off("close", onInputClose);
91
+ output.off("error", onOutputError);
92
+ output.off("close", onOutputClose);
93
+ output.off("finish", onOutputClose);
94
+ yield* Queue.shutdown(wake);
95
+ }));
96
+ const append = (bytes) => Effect.try({
97
+ try: () => {
98
+ const required = partialSize.current + bytes.byteLength, last = bytes.byteLength > 0 ? bytes[bytes.byteLength - 1] : partial.current[partialSize.current - 1];
99
+ if (required > config.maxFrameBytes + 1 || required > config.maxFrameBytes && last !== 13)
100
+ return !1;
101
+ if (required > partial.current.byteLength) {
102
+ const grown = new Uint8Array(Math.min(config.maxFrameBytes + 1, Math.max(required, partial.current.byteLength * 2)));
103
+ grown.set(partial.current.subarray(0, partialSize.current));
104
+ partial.current = grown;
105
+ }
106
+ partial.current.set(bytes, partialSize.current);
107
+ partialSize.current = required;
108
+ return !0;
109
+ },
110
+ catch: (cause) => peerError("Input", "input", cause)
111
+ }).pipe(Effect.flatMap((fits) => fits ? Effect.void : Effect.fail(peerError("FrameTooLarge", "input")))), nativeChunk = Effect.fnUntraced(function* () {
112
+ const result = MutableRef.make(Option.none());
113
+ yield* Effect.whileLoop({
114
+ while: () => Option.isNone(result.current),
115
+ body: () => Effect.gen(function* () {
116
+ const failure = source.current;
117
+ if (failure._tag === "Failed")
118
+ return yield* Effect.fail(failure.error);
119
+ const raw = yield* Effect.try({
120
+ try: () => input.read(Math.min(config.readChunkBytes, Math.max(1, input.readableLength))),
121
+ catch: (cause) => peerError("Input", "input", cause)
122
+ });
123
+ if (raw !== null) {
124
+ const data = yield* Schema.decodeUnknownEffect(Schema.Uint8Array)(raw).pipe(Effect.mapError((cause) => peerError("Input", "input", cause)));
125
+ if (data.byteLength > config.readChunkBytes || data.byteLength === 0)
126
+ return yield* Effect.fail(peerError("Input", "input"));
127
+ chunk.current = data;
128
+ offset.current = 0;
129
+ result.current = Option.some(!0);
130
+ return;
131
+ }
132
+ yield* ProcessInputPhase.match(source.current, {
133
+ Failed: ({ error }) => Effect.fail(error),
134
+ Ended: () => Effect.sync(() => {
135
+ result.current = Option.some(!1);
136
+ }),
137
+ Open: () => Queue.take(wake)
138
+ });
139
+ }),
140
+ step: () => {}
141
+ });
142
+ return Option.getOrElse(result.current, () => !1);
143
+ }), nextCommand = Effect.fnUntraced(function* () {
144
+ const result = MutableRef.make(Option.none());
145
+ yield* Effect.whileLoop({
146
+ while: () => Option.isNone(result.current),
147
+ body: () => Effect.gen(function* () {
148
+ if (source.current._tag === "Failed")
149
+ return yield* Effect.fail(source.current.error);
150
+ if (offset.current >= chunk.current.byteLength) {
151
+ if (!(yield* nativeChunk())) {
152
+ if (partialSize.current !== 0)
153
+ return yield* Effect.fail(peerError("InvalidFrame", "input"));
154
+ result.current = Option.some(Option.none());
155
+ return;
156
+ }
157
+ }
158
+ const end = chunk.current.indexOf(10, offset.current), boundary = end < 0 ? chunk.current.byteLength : end;
159
+ yield* append(chunk.current.subarray(offset.current, boundary));
160
+ offset.current = boundary + (end < 0 ? 0 : 1);
161
+ if (end < 0)
162
+ return;
163
+ const size = partialSize.current - (partial.current[partialSize.current - 1] === 13 ? 1 : 0);
164
+ if (size > config.maxFrameBytes)
165
+ return yield* Effect.fail(peerError("FrameTooLarge", "input"));
166
+ const text = yield* Effect.try({ try: () => decoder.decode(partial.current.subarray(0, size)), catch: (cause) => peerError("InvalidUtf8", "input", cause) });
167
+ partialSize.current = 0;
168
+ if (text.length === 0 || text.charCodeAt(0) === 65279)
169
+ return yield* Effect.fail(peerError("InvalidFrame", "input"));
170
+ result.current = Option.some(Option.some(yield* decodeProcessCommand(text)));
171
+ }),
172
+ step: () => {}
173
+ });
174
+ return Option.match(Option.flatten(result.current), { onNone: () => {
175
+ return;
176
+ }, onSome: (command) => [command, void 0] });
177
+ }), commands = Stream.unwrap(Effect.suspend(() => {
178
+ if (consumed.current)
179
+ return Effect.fail(peerError("Closed", "input"));
180
+ consumed.current = !0;
181
+ return Effect.succeed(Stream.unfold(void 0, () => nextCommand()));
182
+ })), nativeWrite = Effect.fnUntraced(function* (line) {
183
+ const ack = yield* Deferred.make(), flags = MutableRef.make({ callback: !1, drain: !1, returned: !1, needDrain: !1 }), complete = () => {
184
+ const state = flags.current;
185
+ if (state.returned && state.callback && (!state.needDrain || state.drain))
186
+ Deferred.doneUnsafe(ack, Effect.void);
187
+ }, onDrain = () => {
188
+ flags.current = { ...flags.current, drain: !0 };
189
+ complete();
190
+ };
191
+ yield* Effect.acquireUseRelease(Effect.sync(() => {
192
+ activeWrite.current = Option.some(ack);
193
+ output.on("drain", onDrain);
194
+ }), () => Effect.gen(function* () {
195
+ const accepted = yield* Effect.try({
196
+ try: () => output.write(line, (cause) => {
197
+ if (cause !== void 0 && cause !== null) {
198
+ latch(peerError("Output", "output", cause));
199
+ return;
200
+ }
201
+ flags.current = { ...flags.current, callback: !0 };
202
+ complete();
203
+ }),
204
+ catch: (cause) => peerError("Output", "output", cause)
205
+ });
206
+ flags.current = { ...flags.current, returned: !0, needDrain: !accepted };
207
+ complete();
208
+ yield* Deferred.await(ack).pipe(Effect.timeoutOrElse({ duration: config.writeTimeoutMs, orElse: () => Effect.fail(peerError("WriteTimeout", "output")) }));
209
+ }), () => Effect.sync(() => {
210
+ output.off("drain", onDrain);
211
+ activeWrite.current = Option.none();
212
+ }));
213
+ }), writeEvent = Effect.fnUntraced(function* (event) {
214
+ const failure = currentFailure();
215
+ if (Option.isSome(failure))
216
+ return yield* Effect.fail(failure.value);
217
+ if (output.destroyed || output.writableEnded)
218
+ return yield* Effect.fail(peerError("Closed", "output"));
219
+ const text = yield* encodeProcessEvent(event);
220
+ if (Buffer.byteLength(text, "utf8") > config.maxFrameBytes)
221
+ return yield* Effect.fail(peerError("FrameTooLarge", "output"));
222
+ yield* nativeWrite(encoder.encode(`${text}
223
+ `));
224
+ }), reserve = Effect.suspend(() => Option.match(currentFailure(), { onNone: () => Effect.void, onSome: Effect.fail })).pipe(Effect.andThen(slots.take(1).pipe(Effect.timeoutOrElse({ duration: config.writeTimeoutMs, orElse: () => Effect.fail(peerError("Capacity", "output")) })))), send = Effect.fn("process_peer.send")((event) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
225
+ const ack = yield* Deferred.make();
226
+ yield* restore(reserve);
227
+ yield* Effect.suspend(() => {
228
+ const failure = currentFailure();
229
+ if (Option.isSome(failure))
230
+ return slots.release(1).pipe(Effect.andThen(Effect.fail(failure.value)));
231
+ pending.add(ack);
232
+ return Effect.void;
233
+ });
234
+ const worker = serial.withPermit(writeEvent(event)).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.fail(peerError("Closed", "output")) : Effect.fail(Option.getOrElse(Cause.findErrorOption(cause), () => peerError("Output", "output", cause)))), Effect.onExit((exit) => Effect.sync(() => {
235
+ Exit.match(exit, {
236
+ onFailure: (cause) => {
237
+ const error = Cause.hasInterruptsOnly(cause) ? peerError("Closed", "output") : Option.getOrElse(Cause.findErrorOption(cause), () => peerError("Output", "output", cause));
238
+ latch(error);
239
+ Deferred.doneUnsafe(ack, Effect.fail(error));
240
+ },
241
+ onSuccess: () => {
242
+ Deferred.doneUnsafe(ack, Effect.void);
243
+ }
244
+ });
245
+ pending.delete(ack);
246
+ }).pipe(Effect.andThen(slots.release(1)))), Effect.exit);
247
+ yield* Effect.forkIn(restore(worker), scope);
248
+ yield* restore(Deferred.await(ack));
249
+ }))), flush = Effect.gen(function* () {
250
+ const snapshot = yield* Effect.sync(() => ({ failure: currentFailure(), accepted: Array.from(pending) }));
251
+ if (Option.isSome(snapshot.failure))
252
+ return yield* Effect.fail(snapshot.failure.value);
253
+ yield* Effect.forEach(snapshot.accepted, (ack) => Deferred.await(ack), { concurrency: 1, discard: !0 });
254
+ const failure = currentFailure();
255
+ if (Option.isSome(failure))
256
+ return yield* Effect.fail(failure.value);
257
+ }).pipe(Effect.withSpan("process_peer.flush"));
258
+ return ProcessPeer.of({ commands, send, flush });
259
+ });
260
+ export const StdioPeerLive = (options = {}) => Layer.effect(ProcessPeer, makePeer(options));
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@xandreed/process-host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.15.1",
4
+ "engines": {
5
+ "node": ">=24",
6
+ "bun": ">=1.3.0"
7
+ },
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "dependencies": {},
17
+ "private": false,
18
+ "types": "./dist/index.d.ts",
19
+ "peerDependencies": {
20
+ "@xandreed/core": "^0.15.1",
21
+ "effect": "^4.0.7"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "skills",
26
+ "profiles",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "license": "MIT"
31
+ }