@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,51 @@
1
+ import { Effect } from "effect";
2
+ import { type AppendSessionEventInput } from "./protocol.js";
3
+ import type { TaskCancellationContext } from "./participant-claimable-task-runner.js";
4
+ import type { ParticipantTaskExecutor } from "./participant-runtime-client.js";
5
+ import type { SessionEvent, TaskRecord } from "./types.js";
6
+ /** Boundary log surface needed by the participant task claim flow. */
7
+ export interface ParticipantTaskClaimFlowLogger {
8
+ /** Writes a debug event for task claim flow diagnostics. */
9
+ readonly debug: (boundary: string, message: string, details?: Record<string, unknown>) => void;
10
+ }
11
+ /** Command surface needed to claim, refresh, publish, and resolve one task. */
12
+ export interface ParticipantTaskClaimFlowClient {
13
+ /** Publishes one participant-originated event. */
14
+ readonly appendEvent: (input: AppendSessionEventInput) => Promise<void>;
15
+ /** Attempts to claim a task. */
16
+ readonly claimTask: (taskId: string) => Promise<TaskRecord | null>;
17
+ /** Completes a claimed task. */
18
+ readonly completeTask: (taskId: string, result: Record<string, unknown>) => Promise<void>;
19
+ /** Fails a claimed task. */
20
+ readonly failTask: (taskId: string, failure: Record<string, unknown>) => Promise<void>;
21
+ /** Refreshes a claimed task lease. */
22
+ readonly refreshTaskClaim: (taskId: string) => Promise<TaskRecord | null>;
23
+ }
24
+ /** Runtime identity and event context for one task claim flow. */
25
+ export interface ParticipantTaskClaimFlowContext {
26
+ /** Concrete runtime process id that owns the task claim. */
27
+ readonly instanceId: string;
28
+ /** Latest observed event sequence. */
29
+ readonly lastObservedSeq: number;
30
+ /** Stable participant identity that owns the task claim. */
31
+ readonly participantId: string;
32
+ /** Recent session events available to the executor. */
33
+ readonly recentEvents: readonly SessionEvent[];
34
+ /** Durable session that owns the task. */
35
+ readonly sessionId: string;
36
+ }
37
+ /** Inputs for running one participant task claim flow. */
38
+ export interface ParticipantTaskClaimFlowInput {
39
+ /** Cancellation state for the active task. */
40
+ readonly cancellation: TaskCancellationContext;
41
+ /** Interval used to refresh the active claim. */
42
+ readonly claimRefreshMs: number;
43
+ /** Adapter-specific executor for the claimed task. */
44
+ readonly executor: ParticipantTaskExecutor;
45
+ /** Claimable task event payload. */
46
+ readonly task: TaskRecord;
47
+ }
48
+ /** Runs claim, refresh, execution, output, and completion for one task. */
49
+ export declare function runParticipantTaskClaimFlow(client: ParticipantTaskClaimFlowClient, context: ParticipantTaskClaimFlowContext, logger: ParticipantTaskClaimFlowLogger, input: ParticipantTaskClaimFlowInput): Promise<void>;
50
+ /** Builds the task claim flow as an Effect program for scoped refresh cleanup. */
51
+ export declare function buildParticipantTaskClaimFlow(client: ParticipantTaskClaimFlowClient, context: ParticipantTaskClaimFlowContext, logger: ParticipantTaskClaimFlowLogger, input: ParticipantTaskClaimFlowInput): Effect.Effect<void, unknown>;
@@ -0,0 +1,248 @@
1
+ import { Effect } from "effect";
2
+ import { buildTaskOutputEventInput, buildTaskProgressEventInput, } from "./protocol.js";
3
+ import { sleepUnrefEffect } from "./effect-timing.js";
4
+ const maxConsecutiveRefreshFailures = 3;
5
+ /** Runs claim, refresh, execution, output, and completion for one task. */
6
+ export async function runParticipantTaskClaimFlow(client, context, logger, input) {
7
+ await Effect.runPromise(buildParticipantTaskClaimFlow(client, context, logger, input));
8
+ }
9
+ /** Builds the task claim flow as an Effect program for scoped refresh cleanup. */
10
+ export function buildParticipantTaskClaimFlow(client, context, logger, input) {
11
+ const shouldStop = () => isTaskStopped(input.cancellation);
12
+ const debugTaskStop = (message) => {
13
+ logger.debug("runTaskClaimFlow", message, {
14
+ participantId: context.participantId,
15
+ sessionId: context.sessionId,
16
+ taskId: input.task.taskId,
17
+ });
18
+ };
19
+ return Effect.scoped(Effect.gen(function* () {
20
+ const claimSetup = yield* claimAndPublishInitialProgress(client, context, logger, input);
21
+ if (claimSetup.type !== "claimed") {
22
+ if (claimSetup.type === "cancelled") {
23
+ debugTaskStop("task.cancelled_before_executor");
24
+ }
25
+ return;
26
+ }
27
+ yield* buildTaskClaimRefresh(client, logger, {
28
+ cancellation: input.cancellation,
29
+ claimExpiresAt: claimSetup.task.claimExpiresAt,
30
+ intervalMs: input.claimRefreshMs,
31
+ taskId: input.task.taskId,
32
+ }).pipe(Effect.forkScoped);
33
+ if (shouldStop()) {
34
+ debugTaskStop("task.cancelled_after_claim");
35
+ return;
36
+ }
37
+ const executorContext = {
38
+ instanceId: context.instanceId,
39
+ participantId: context.participantId,
40
+ recentEvents: context.recentEvents.filter((event) => event.seq < context.lastObservedSeq),
41
+ publishOutput: async (output) => {
42
+ if (shouldStop()) {
43
+ return;
44
+ }
45
+ await client.appendEvent(buildTaskOutputEventInput({
46
+ output,
47
+ participantId: context.participantId,
48
+ sessionId: context.sessionId,
49
+ taskId: input.task.taskId,
50
+ }));
51
+ },
52
+ publishProgress: async () => {
53
+ if (shouldStop()) {
54
+ return;
55
+ }
56
+ await client.appendEvent(buildTaskProgressEventInput({
57
+ participantId: context.participantId,
58
+ sessionId: context.sessionId,
59
+ taskId: input.task.taskId,
60
+ }));
61
+ },
62
+ sessionId: context.sessionId,
63
+ signal: input.cancellation.signal,
64
+ task: claimSetup.task,
65
+ };
66
+ const execution = yield* runExecutor(input.executor, executorContext);
67
+ if (execution.type === "executor_failed") {
68
+ if (!shouldStop()) {
69
+ yield* Effect.tryPromise(() => client.failTask(input.task.taskId, {
70
+ error: execution.error.message,
71
+ }));
72
+ }
73
+ return;
74
+ }
75
+ if (shouldStop()) {
76
+ debugTaskStop("task.cancelled_after_executor");
77
+ return;
78
+ }
79
+ if (execution.execution.output) {
80
+ const outputPublished = yield* Effect.tryPromise(() => executorContext.publishOutput(execution.execution.output ?? "")).pipe(Effect.match({
81
+ onFailure: (error) => {
82
+ logCompletionTransportFailed(logger, context, input.task.taskId, error);
83
+ return false;
84
+ },
85
+ onSuccess: () => true,
86
+ }));
87
+ if (!outputPublished) {
88
+ return;
89
+ }
90
+ if (shouldStop()) {
91
+ debugTaskStop("task.cancelled_after_output");
92
+ return;
93
+ }
94
+ }
95
+ yield* Effect.tryPromise(() => client.completeTask(input.task.taskId, execution.execution.result)).pipe(Effect.catchAll((error) => Effect.sync(() => {
96
+ logCompletionTransportFailed(logger, context, input.task.taskId, error);
97
+ })));
98
+ }));
99
+ }
100
+ function claimAndPublishInitialProgress(client, context, logger, input) {
101
+ if (isTaskStopped(input.cancellation)) {
102
+ return Effect.succeed({ type: "cancelled" });
103
+ }
104
+ return Effect.gen(function* () {
105
+ const claimOutcome = yield* Effect.tryPromise(() => client.claimTask(input.task.taskId)).pipe(Effect.match({
106
+ onFailure: (error) => {
107
+ logger.debug("runTaskClaimFlow", "task.claim_transport_unknown", {
108
+ error: error instanceof Error ? error.message : "Unknown claim error",
109
+ participantId: context.participantId,
110
+ recovery: "lease_expiry_fallback",
111
+ sessionId: context.sessionId,
112
+ taskId: input.task.taskId,
113
+ });
114
+ return { type: "claim_transport_unknown" };
115
+ },
116
+ onSuccess: (task) => task ? { task, type: "claimed" } : { type: "claim_not_won" },
117
+ }));
118
+ if (claimOutcome.type === "claim_transport_unknown") {
119
+ return claimOutcome;
120
+ }
121
+ if (claimOutcome.type === "claim_not_won") {
122
+ logger.debug("runTaskClaimFlow", "task.claim_not_won", {
123
+ participantId: context.participantId,
124
+ sessionId: context.sessionId,
125
+ taskId: input.task.taskId,
126
+ });
127
+ return claimOutcome;
128
+ }
129
+ if (isTaskStopped(input.cancellation)) {
130
+ return { type: "cancelled" };
131
+ }
132
+ const progressPublished = yield* Effect.tryPromise(() => client.appendEvent(buildTaskProgressEventInput({
133
+ participantId: context.participantId,
134
+ sessionId: context.sessionId,
135
+ taskId: input.task.taskId,
136
+ }))).pipe(Effect.match({
137
+ onFailure: (error) => {
138
+ logger.debug("runTaskClaimFlow", "task.setup_failed_before_executor", {
139
+ error: error instanceof Error ? error.message : "Unknown setup error",
140
+ participantId: context.participantId,
141
+ recovery: "lease_expiry_fallback",
142
+ sessionId: context.sessionId,
143
+ taskId: input.task.taskId,
144
+ });
145
+ return false;
146
+ },
147
+ onSuccess: () => true,
148
+ }));
149
+ return progressPublished ? claimOutcome : { type: "setup_failed" };
150
+ });
151
+ }
152
+ function runExecutor(executor, context) {
153
+ return Effect.tryPromise({
154
+ catch: normalizeRuntimeError,
155
+ try: () => executor(context),
156
+ }).pipe(Effect.match({
157
+ onFailure: (error) => ({ error, type: "executor_failed" }),
158
+ onSuccess: (execution) => ({ execution, type: "succeeded" }),
159
+ }));
160
+ }
161
+ function logCompletionTransportFailed(logger, context, taskId, error) {
162
+ logger.debug("runTaskClaimFlow", "task.completion_transport_failed", {
163
+ error: error instanceof Error ? error.message : "Unknown completion transport error",
164
+ participantId: context.participantId,
165
+ recovery: "lease_expiry_fallback",
166
+ sessionId: context.sessionId,
167
+ taskId,
168
+ });
169
+ }
170
+ /** Periodically refreshes a claimed task lease inside the active task scope. */
171
+ function buildTaskClaimRefresh(client, logger, input) {
172
+ if (input.intervalMs <= 0) {
173
+ return Effect.void;
174
+ }
175
+ return Effect.gen(function* () {
176
+ let consecutiveFailures = 0;
177
+ let currentClaimExpiresAt = input.claimExpiresAt;
178
+ while (!isTaskStopped(input.cancellation)) {
179
+ yield* sleepUnrefEffect(input.intervalMs);
180
+ if (isTaskStopped(input.cancellation)) {
181
+ return;
182
+ }
183
+ const refreshOutcome = yield* Effect.tryPromise(() => client.refreshTaskClaim(input.taskId)).pipe(Effect.catchAll((error) => Effect.sync(() => {
184
+ consecutiveFailures += 1;
185
+ logger.debug("runTaskClaimFlow", "task.claim_refresh_failed", {
186
+ consecutiveFailures,
187
+ error: error instanceof Error ? error.message : "Unknown refresh error",
188
+ taskId: input.taskId,
189
+ });
190
+ return "failed";
191
+ })));
192
+ if (refreshOutcome === "failed") {
193
+ if (consecutiveFailures >= maxConsecutiveRefreshFailures ||
194
+ isClaimLeaseExpired(currentClaimExpiresAt)) {
195
+ logger.debug("runTaskClaimFlow", "task.claim_refresh_failure_budget_exhausted", {
196
+ consecutiveFailures,
197
+ maxConsecutiveRefreshFailures,
198
+ taskId: input.taskId,
199
+ });
200
+ input.cancellation.abortActive();
201
+ return;
202
+ }
203
+ continue;
204
+ }
205
+ if (refreshOutcome === null) {
206
+ logger.debug("runTaskClaimFlow", "task.claim_refresh_rejected", {
207
+ taskId: input.taskId,
208
+ });
209
+ input.cancellation.abortActive();
210
+ return;
211
+ }
212
+ consecutiveFailures = 0;
213
+ currentClaimExpiresAt = refreshOutcome.claimExpiresAt;
214
+ if (isClaimLeaseExpired(refreshOutcome.claimExpiresAt)) {
215
+ logger.debug("runTaskClaimFlow", "task.claim_refresh_lease_expired", {
216
+ taskId: input.taskId,
217
+ });
218
+ input.cancellation.abortActive();
219
+ return;
220
+ }
221
+ }
222
+ });
223
+ }
224
+ /** Preserves executor error messages when Effect wraps rejected promises. */
225
+ function normalizeRuntimeError(error) {
226
+ if (error instanceof Error) {
227
+ return error;
228
+ }
229
+ if (isRecord(error) && typeof error.message === "string" && error.message.trim()) {
230
+ return new Error(error.message);
231
+ }
232
+ return new Error(String(error));
233
+ }
234
+ /** Narrows unknown values to plain records for error normalization. */
235
+ function isRecord(value) {
236
+ return typeof value === "object" && value !== null;
237
+ }
238
+ /** Checks whether cancellation has made the current task flow stop publishing visible outputs. */
239
+ function isTaskStopped(cancellation) {
240
+ return cancellation.signal.aborted || cancellation.isCancelled();
241
+ }
242
+ function isClaimLeaseExpired(claimExpiresAt) {
243
+ if (!claimExpiresAt) {
244
+ return false;
245
+ }
246
+ const claimExpiresAtMs = Date.parse(claimExpiresAt);
247
+ return Number.isFinite(claimExpiresAtMs) && claimExpiresAtMs <= Date.now();
248
+ }
@@ -0,0 +1,2 @@
1
+ export { appendEventSchema, buildPublishedEventInput, buildTaskOutputEventInput, buildTaskProgressEventInput, buildWsPublishMessage, buildWsTaskCancelMessage, buildWsTaskClaimMessage, buildWsTaskCompleteMessage, buildWsTaskFailMessage, buildWsTaskRefreshMessage, buildWsTaskReleaseMessage, parseAfterSeq, parseWebSocketServerEnvelope, participantRuntimeKindSchema, registerParticipantSchema, serializeCommandResultEnvelope, serializeErrorEnvelope, sessionEventSchema, sessionEventType, systemProducerId, taskClaimExpiredPayloadSchema, taskCreatedPayloadSchema, taskFromClaimableEvent, taskFromCreatedEvent, taskIdFromCancelledEvent, taskRecordSchema, webSocketOperation, webSocketServerEnvelopeSchema, wsPublishMessageSchema, wsTaskCancelMessageSchema, wsTaskClaimMessageSchema, wsTaskCompleteMessageSchema, wsTaskFailMessageSchema, wsTaskRefreshMessageSchema, wsTaskReleaseMessageSchema, } from "@dungle-scrubs/tether-protocol";
2
+ export type { AppendSessionEventInput, CommandResultEnvelope, WebSocketCommandMessage, WebSocketServerEnvelope, WebSocketTaskCommandMessage, } from "@dungle-scrubs/tether-protocol";
@@ -0,0 +1 @@
1
+ export { appendEventSchema, buildPublishedEventInput, buildTaskOutputEventInput, buildTaskProgressEventInput, buildWsPublishMessage, buildWsTaskCancelMessage, buildWsTaskClaimMessage, buildWsTaskCompleteMessage, buildWsTaskFailMessage, buildWsTaskRefreshMessage, buildWsTaskReleaseMessage, parseAfterSeq, parseWebSocketServerEnvelope, participantRuntimeKindSchema, registerParticipantSchema, serializeCommandResultEnvelope, serializeErrorEnvelope, sessionEventSchema, sessionEventType, systemProducerId, taskClaimExpiredPayloadSchema, taskCreatedPayloadSchema, taskFromClaimableEvent, taskFromCreatedEvent, taskIdFromCancelledEvent, taskRecordSchema, webSocketOperation, webSocketServerEnvelopeSchema, wsPublishMessageSchema, wsTaskCancelMessageSchema, wsTaskClaimMessageSchema, wsTaskCompleteMessageSchema, wsTaskFailMessageSchema, wsTaskRefreshMessageSchema, wsTaskReleaseMessageSchema, } from "@dungle-scrubs/tether-protocol";
@@ -0,0 +1,133 @@
1
+ import WebSocket from "ws";
2
+ import type { SessionEvent } from "./types.js";
3
+ /** Factory for observer WebSocket connections, injectable by tests and hosts. */
4
+ export type SessionEventStreamWebSocketFactory = (url: string) => WebSocket;
5
+ /** Connection settings for a passive session event observer stream. */
6
+ export interface SessionEventStreamClientConfig {
7
+ /** Last observed event sequence to resume after. */
8
+ readonly afterSeq: number;
9
+ /** Bearer token sent to Tether; falls back to SERVICE_AUTH_TOKEN/TETHER_AUTH_TOKEN when omitted. */
10
+ readonly authToken?: string | null;
11
+ /** Optional reconnect backoff settings for long-running observers. */
12
+ readonly reconnect?: {
13
+ /** Initial delay before reconnecting after an unexpected disconnect. */
14
+ readonly baseDelayMs?: number;
15
+ /** Maximum reconnect delay after repeated failures. */
16
+ readonly maxDelayMs?: number;
17
+ };
18
+ /** Base URL of the Tether HTTP service. */
19
+ readonly serviceUrl: string;
20
+ /** Session whose durable event stream should be observed. */
21
+ readonly sessionId: string;
22
+ /** WebSocket implementation factory, injected by tests and non-Node hosts. */
23
+ readonly webSocketFactory?: SessionEventStreamWebSocketFactory;
24
+ }
25
+ /** Runtime diagnostics for one passive session event observer. */
26
+ export interface SessionEventStreamClientDebugInfo {
27
+ /** Number of WebSocket connections opened. */
28
+ readonly connectCount: number;
29
+ /** Number of durable events delivered by the stream. */
30
+ readonly eventCount: number;
31
+ /** Number of registered event handlers. */
32
+ readonly eventHandlerCount: number;
33
+ /** Highest event sequence whose handlers have all resolved; the durable resume cursor. */
34
+ readonly lastObservedSeq: number;
35
+ /** Number of failed reconnect attempts since startup. */
36
+ readonly reconnectFailureCount: number;
37
+ /** Number of successful reconnects since startup. */
38
+ readonly reconnectSuccessCount: number;
39
+ /** Number of replay completion markers observed. */
40
+ readonly replayCompleteCount: number;
41
+ /** Underlying WebSocket ready state. */
42
+ readonly socketReadyState: number;
43
+ /** Whether the client has been intentionally closed. */
44
+ readonly stopped: boolean;
45
+ }
46
+ /**
47
+ * Observer-only stream client for replaying and following durable session
48
+ * events. Use this for passive readers such as bridges, approval observers, and
49
+ * dashboards; use `ParticipantRuntimeClient` when the caller must publish
50
+ * participant events, claim tasks, refresh claims, or complete task work.
51
+ */
52
+ export declare class SessionEventStreamClient {
53
+ private readonly config;
54
+ private readonly deliveryQueue;
55
+ private readonly eventHandlers;
56
+ private readonly errorBacklog;
57
+ private readonly errorHandlers;
58
+ private readonly observability;
59
+ private closePromise;
60
+ private connectCount;
61
+ private draining;
62
+ private eventCount;
63
+ /** Highest sequence whose handlers have all resolved; the durable resume cursor. */
64
+ private lastObservedSeq;
65
+ private reconnectFailureCount;
66
+ private reconnectSuccessCount;
67
+ private replayComplete;
68
+ private replayCompleteCount;
69
+ private replayCompleteSettled;
70
+ private rejectReplayComplete;
71
+ private resolveReplayComplete;
72
+ private socket;
73
+ private stopped;
74
+ private readonly webSocketFactory;
75
+ /** Creates an observer around the supplied stream configuration. */
76
+ private constructor();
77
+ /** Opens a passive observer stream without participant task authority. */
78
+ static connect(config: SessionEventStreamClientConfig): Promise<SessionEventStreamClient>;
79
+ /** Requests a graceful WebSocket close and stops reconnect attempts. */
80
+ close(): void;
81
+ /** Returns stream cursor, connection, handler, and replay diagnostics. */
82
+ debugInfo(): SessionEventStreamClientDebugInfo;
83
+ /** Registers an error callback and returns an unsubscribe function. */
84
+ onError(handler: (error: Error) => void): () => void;
85
+ /** Registers an event callback and resumes serial delivery of any backlog. */
86
+ onEvent(handler: (event: SessionEvent) => void | Promise<void>): () => void;
87
+ /** Reopens the stream from the highest observed event sequence. */
88
+ reconnect(): Promise<SessionEventStreamClient>;
89
+ /** Resolves when the current socket closes. */
90
+ waitForClose(): Promise<void>;
91
+ /** Resolves once the current stream replay has completed. */
92
+ waitForReplayComplete(): Promise<void>;
93
+ /** Opens a WebSocket stream from the requested event cursor. */
94
+ private open;
95
+ /** Reopens the stream with bounded retry backoff. */
96
+ private reconnectWithBackoff;
97
+ /** Builds the bounded reconnect loop as an Effect program. */
98
+ private buildReconnectWithBackoff;
99
+ /** Parses one server envelope and enqueues it for serial, in-order delivery. */
100
+ private handleMessage;
101
+ /**
102
+ * Drains queued events through registered handlers strictly one at a time and
103
+ * in sequence order. A single drain runs at a time, so handlers never overlap
104
+ * and later events wait behind in-flight delivery. The resume cursor advances
105
+ * only after an event's handlers all resolve, and a rejection halts delivery
106
+ * without acknowledging the failed event so the stream reconnects and replays
107
+ * from the last successfully handled position instead of skipping past it.
108
+ */
109
+ private drainDeliveryQueue;
110
+ /** Awaits every registered handler in turn; returns false on the first rejection. */
111
+ private deliverEventToHandlers;
112
+ /**
113
+ * Recovers after a handler rejection by discarding un-acknowledged events and
114
+ * reconnecting from the last successfully handled sequence, so the server
115
+ * replays the failed event rather than the stream advancing past it.
116
+ */
117
+ private recoverFromDeliveryFailure;
118
+ /** Routes operational failures to registered error handlers. */
119
+ private emitError;
120
+ /** Resolves the current replay wait once. */
121
+ private settleReplayComplete;
122
+ /** Rejects the current replay wait once when the stream fails before replay completion. */
123
+ private settleReplayCompleteError;
124
+ }
125
+ /**
126
+ * Runtime-kind value that selects the server's passive full-event observer
127
+ * mode: the connection receives the durable event stream (replay + live) but
128
+ * does not register a durable participant or acquire a control lease. Keep this
129
+ * in sync with `classifyHostPresenceStream` in the Tether gateway.
130
+ */
131
+ export declare const sessionEventObserverRuntimeKind = "observer";
132
+ /** Builds the observer WebSocket URL without participant or task authority. */
133
+ export declare function buildSessionEventStreamUrl(config: SessionEventStreamClientConfig): string;