@dungle-scrubs/tether-client 0.1.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,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SpanStatusCode, trace } from "@opentelemetry/api";
3
+ /**
4
+ * Error thrown when a Tether module invariant breaks.
5
+ */
6
+ export class TetherInvariantError extends Error {
7
+ code = "TETHER_INVARIANT_VIOLATION";
8
+ details;
9
+ moduleName;
10
+ operation;
11
+ /**
12
+ * Captures a violated invariant with machine-readable module and operation
13
+ * context.
14
+ */
15
+ constructor(input) {
16
+ super(input.message, input.cause === undefined ? undefined : { cause: input.cause });
17
+ this.details = input.details ?? {};
18
+ this.moduleName = input.moduleName;
19
+ this.name = "TetherInvariantError";
20
+ this.operation = input.operation;
21
+ }
22
+ }
23
+ /**
24
+ * JSON logger used when boundary logs are enabled through module options.
25
+ */
26
+ export class ConsoleStructuredLogger {
27
+ /**
28
+ * Writes structured log entries to the matching console level.
29
+ */
30
+ log(entry) {
31
+ const encoded = JSON.stringify(entry);
32
+ if (entry.level === "error") {
33
+ console.error(encoded);
34
+ return;
35
+ }
36
+ if (entry.level === "warn") {
37
+ console.warn(encoded);
38
+ return;
39
+ }
40
+ console.log(encoded);
41
+ }
42
+ }
43
+ /**
44
+ * Boundary observability helper for deep modules.
45
+ */
46
+ export class ModuleObservability {
47
+ activeOperations = 0;
48
+ boundaryCalls = 0;
49
+ boundaryFailures = 0;
50
+ boundaryLogsEnabled;
51
+ debugEnabled;
52
+ lastError = null;
53
+ lastOperation = null;
54
+ logger;
55
+ moduleName;
56
+ tracer;
57
+ /**
58
+ * Creates an observability boundary helper with optional logging and tracing
59
+ * dependencies.
60
+ */
61
+ constructor(options) {
62
+ this.boundaryLogsEnabled = options.boundaryLogsEnabled ?? false;
63
+ this.debugEnabled = options.debugEnabled ?? false;
64
+ this.logger = options.logger ?? new ConsoleStructuredLogger();
65
+ this.moduleName = options.moduleName;
66
+ this.tracer = trace.getTracer(options.tracerName ?? options.moduleName);
67
+ }
68
+ /**
69
+ * Snapshots boundary counters and the most recent failure.
70
+ */
71
+ debugInfo() {
72
+ return {
73
+ activeOperations: this.activeOperations,
74
+ boundaryCalls: this.boundaryCalls,
75
+ boundaryFailures: this.boundaryFailures,
76
+ boundaryLogsEnabled: this.boundaryLogsEnabled,
77
+ debugEnabled: this.debugEnabled,
78
+ lastError: this.lastError,
79
+ lastOperation: this.lastOperation,
80
+ moduleName: this.moduleName,
81
+ };
82
+ }
83
+ /**
84
+ * Runs one public boundary method inside logs and an OpenTelemetry span.
85
+ */
86
+ async traceBoundary(operation, input, action, summarize = () => ({})) {
87
+ const traceId = `trace_${randomUUID()}`;
88
+ this.activeOperations += 1;
89
+ this.boundaryCalls += 1;
90
+ this.lastOperation = operation;
91
+ this.logBoundary("debug", "boundary.enter", operation, traceId, input);
92
+ const startedAt = performance.now();
93
+ return this.tracer.startActiveSpan(`${this.moduleName}.${operation}`, { attributes: toSpanAttributes({ ...input, traceId }) }, async (span) => {
94
+ try {
95
+ const value = await action();
96
+ const durationMs = Math.round(performance.now() - startedAt);
97
+ const summary = summarize(value);
98
+ span.setAttributes(toSpanAttributes({ ...summary, durationMs }));
99
+ span.setStatus({ code: SpanStatusCode.OK });
100
+ this.logBoundary("info", "boundary.exit", operation, traceId, {
101
+ ...summary,
102
+ durationMs,
103
+ });
104
+ return value;
105
+ }
106
+ catch (error) {
107
+ const durationMs = Math.round(performance.now() - startedAt);
108
+ const errorInfo = toStructuredErrorInfo(error);
109
+ this.boundaryFailures += 1;
110
+ this.lastError = errorInfo;
111
+ span.recordException(error instanceof Error ? error : String(error));
112
+ span.setAttributes(toSpanAttributes({ durationMs, errorName: errorInfo.name }));
113
+ span.setStatus({ code: SpanStatusCode.ERROR, message: errorInfo.message });
114
+ this.logBoundary("error", "boundary.error", operation, traceId, {
115
+ durationMs,
116
+ error: errorInfo,
117
+ });
118
+ throw error;
119
+ }
120
+ finally {
121
+ span.end();
122
+ this.activeOperations -= 1;
123
+ }
124
+ });
125
+ }
126
+ /**
127
+ * Throws a typed invariant error when internal module assumptions break.
128
+ */
129
+ assertInvariant(condition, operation, message, details = {}) {
130
+ if (condition) {
131
+ return;
132
+ }
133
+ throw new TetherInvariantError({
134
+ details,
135
+ message,
136
+ moduleName: this.moduleName,
137
+ operation,
138
+ });
139
+ }
140
+ /**
141
+ * Emits scoped verbose data without turning on global logging.
142
+ */
143
+ debug(operation, message, data = {}) {
144
+ if (!this.debugEnabled) {
145
+ return;
146
+ }
147
+ this.logBoundary("debug", message, operation, `trace_${randomUUID()}`, data);
148
+ }
149
+ logBoundary(level, message, operation, traceId, data) {
150
+ if (!this.boundaryLogsEnabled && !this.debugEnabled) {
151
+ return;
152
+ }
153
+ this.logger.log({
154
+ at: new Date().toISOString(),
155
+ data,
156
+ level,
157
+ message,
158
+ moduleName: this.moduleName,
159
+ operation,
160
+ traceId,
161
+ });
162
+ }
163
+ }
164
+ /**
165
+ * Reads module observability toggles from environment variables.
166
+ */
167
+ export function readModuleObservabilityOptions(moduleName, env = process.env) {
168
+ return {
169
+ boundaryLogsEnabled: parseBooleanEnv(env.OBSERVABILITY),
170
+ debugEnabled: parseBooleanEnv(env.DEBUG),
171
+ moduleName,
172
+ };
173
+ }
174
+ /**
175
+ * Parses truthy boolean environment values used by observability toggles.
176
+ */
177
+ function parseBooleanEnv(value) {
178
+ return value === "1" || value === "true" || value === "yes";
179
+ }
180
+ /**
181
+ * Converts structured log fields into OpenTelemetry-compatible span attributes.
182
+ */
183
+ function toSpanAttributes(fields) {
184
+ const attributes = {};
185
+ for (const [key, value] of Object.entries(fields)) {
186
+ const attribute = toSpanAttribute(value);
187
+ if (attribute !== null) {
188
+ attributes[key] = attribute;
189
+ }
190
+ }
191
+ return attributes;
192
+ }
193
+ /**
194
+ * Keeps only scalar and homogeneous scalar-array values supported by
195
+ * OpenTelemetry span attributes.
196
+ */
197
+ function toSpanAttribute(value) {
198
+ if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
199
+ return value;
200
+ }
201
+ if (Array.isArray(value)) {
202
+ if (value.every((item) => typeof item === "string")) {
203
+ return value;
204
+ }
205
+ if (value.every((item) => typeof item === "number")) {
206
+ return value;
207
+ }
208
+ if (value.every((item) => typeof item === "boolean")) {
209
+ return value;
210
+ }
211
+ return null;
212
+ }
213
+ return null;
214
+ }
215
+ /**
216
+ * Normalizes unknown thrown values into structured error metadata.
217
+ */
218
+ function toStructuredErrorInfo(error) {
219
+ if (error instanceof Error) {
220
+ return { message: error.message, name: error.name };
221
+ }
222
+ return { message: "Unknown error", name: "UnknownError" };
223
+ }
@@ -0,0 +1,36 @@
1
+ import { Effect } from "effect";
2
+ import type { ParticipantRuntimeTaskLoopOptions, ParticipantTaskExecutor } from "./participant-runtime-client.js";
3
+ import type { SessionEvent, TaskRecord } from "./types.js";
4
+ /** Cancellation state passed from the claimable-task runner into one task flow. */
5
+ export interface TaskCancellationContext {
6
+ /** Aborts the active executor signal for this task. */
7
+ readonly abortActive: () => void;
8
+ /** Returns whether cancellation has been observed for this task. */
9
+ readonly isCancelled: () => boolean;
10
+ /** Executor cancellation signal. */
11
+ readonly signal: AbortSignal;
12
+ }
13
+ /** Minimal participant client surface needed by the claimable-task runner. */
14
+ export interface ParticipantClaimableTaskRunnerClient {
15
+ /** Intentionally closes the participant client. */
16
+ readonly close: () => void;
17
+ /** Returns whether the participant client is intentionally stopped. */
18
+ readonly isStopped: () => boolean;
19
+ /** Registers a live/replayed event handler. */
20
+ readonly onEvent: (handler: (event: SessionEvent) => void) => () => void;
21
+ /** Reconnects the participant stream using the client's configured backoff. */
22
+ readonly reconnectAfterClose: () => Promise<void>;
23
+ /** Runs one claimed task flow. */
24
+ readonly runTaskClaimFlow: (input: {
25
+ readonly cancellation: TaskCancellationContext;
26
+ readonly claimRefreshMs: number;
27
+ readonly executor: ParticipantTaskExecutor;
28
+ readonly task: TaskRecord;
29
+ }) => Promise<void>;
30
+ /** Resolves when the current socket closes. */
31
+ readonly waitForClose: () => Promise<void>;
32
+ /** Resolves after server replay is complete. */
33
+ readonly waitForReplayComplete: () => Promise<void>;
34
+ }
35
+ /** Builds the replay/live claimable-task loop for one participant runtime. */
36
+ export declare function buildParticipantClaimableTaskLoop(client: ParticipantClaimableTaskRunnerClient, options: ParticipantRuntimeTaskLoopOptions): Effect.Effect<void, unknown>;
@@ -0,0 +1,92 @@
1
+ import { Effect } from "effect";
2
+ import { taskFromClaimableEvent } from "./protocol.js";
3
+ import { TaskCancellationRegistry } from "./task-cancellation-registry.js";
4
+ /** Builds the replay/live claimable-task loop for one participant runtime. */
5
+ export function buildParticipantClaimableTaskLoop(client, options) {
6
+ const cancellations = new TaskCancellationRegistry();
7
+ const processing = new Set();
8
+ const pendingWork = new Set();
9
+ const replayTasks = new Map();
10
+ let replayComplete = false;
11
+ const startTask = (task) => {
12
+ if (cancellations.isCancelled(task.taskId)) {
13
+ return;
14
+ }
15
+ const work = processTask(client, options.executor, options.claimRefreshMs, cancellations, processing, task);
16
+ pendingWork.add(work);
17
+ void work
18
+ .catch((error) => {
19
+ console.error(error);
20
+ })
21
+ .finally(() => pendingWork.delete(work));
22
+ };
23
+ const subscribe = () => client.onEvent((event) => {
24
+ const cancelledTaskId = cancellations.observe(event);
25
+ if (cancelledTaskId) {
26
+ replayTasks.delete(cancelledTaskId);
27
+ return;
28
+ }
29
+ const task = taskFromClaimableEvent(event);
30
+ if (!task || !isDispatchableClaimableTask(task) || !options.shouldClaimTask(task)) {
31
+ return;
32
+ }
33
+ if (!replayComplete) {
34
+ replayTasks.set(task.taskId, task);
35
+ return;
36
+ }
37
+ startTask(task);
38
+ });
39
+ const loop = Effect.gen(function* () {
40
+ while (!client.isStopped()) {
41
+ replayComplete = false;
42
+ const replayReady = Effect.tryPromise(() => client.waitForReplayComplete()).pipe(Effect.map(() => {
43
+ replayComplete = true;
44
+ for (const task of replayTasks.values()) {
45
+ startTask(task);
46
+ }
47
+ replayTasks.clear();
48
+ return "replay_complete";
49
+ }));
50
+ const closeReady = Effect.tryPromise(() => client.waitForClose()).pipe(Effect.as("closed"));
51
+ const firstResult = yield* Effect.race(replayReady, closeReady);
52
+ if (firstResult === "replay_complete") {
53
+ if (options.once) {
54
+ yield* Effect.promise(() => Promise.allSettled([...pendingWork]));
55
+ client.close();
56
+ return;
57
+ }
58
+ yield* closeReady;
59
+ }
60
+ if (!client.isStopped()) {
61
+ yield* Effect.tryPromise(() => client.reconnectAfterClose());
62
+ }
63
+ }
64
+ });
65
+ return Effect.acquireUseRelease(Effect.sync(subscribe), () => loop, (unsubscribe) => Effect.sync(unsubscribe));
66
+ }
67
+ function isDispatchableClaimableTask(task) {
68
+ return !task.completedAt && !task.failedAt && !task.cancelledAt;
69
+ }
70
+ async function processTask(client, executor, claimRefreshMs, cancellations, processing, task) {
71
+ if (processing.has(task.taskId) || cancellations.isCancelled(task.taskId)) {
72
+ return;
73
+ }
74
+ const signal = cancellations.begin(task.taskId);
75
+ processing.add(task.taskId);
76
+ try {
77
+ await client.runTaskClaimFlow({
78
+ cancellation: {
79
+ abortActive: () => cancellations.abortActive(task.taskId),
80
+ isCancelled: () => cancellations.isCancelled(task.taskId),
81
+ signal,
82
+ },
83
+ claimRefreshMs,
84
+ executor,
85
+ task,
86
+ });
87
+ }
88
+ finally {
89
+ cancellations.end(task.taskId);
90
+ processing.delete(task.taskId);
91
+ }
92
+ }