ompclaw 0.3.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,393 @@
1
+ import { join, resolve } from "node:path";
2
+ import { acquireLock, releaseLock, startLockHeartbeat } from "./api";
3
+ import {
4
+ gatewayRpcRuntimeConfig,
5
+ resolveGatewaySecrets,
6
+ type GatewayConfig,
7
+ type GatewaySecrets,
8
+ } from "./gateway-config";
9
+ import { GatewayCore, type GatewayCoreOptions } from "./gateway-core";
10
+ import type { GatewayDelivery } from "./gateway-tools";
11
+ import {
12
+ GatewayScheduler,
13
+ ScheduledDispatchBusyError,
14
+ type GatewayAutomationControl,
15
+ type GatewayScheduledJobStore,
16
+ type GatewaySchedulerOptions,
17
+ } from "./gateway-scheduler";
18
+ import { GatewayStore, type ConversationBinding, type JsonValue, type ScheduledJob } from "./gateway-store";
19
+ import type { InboundMessage, Principal, TransportAdapter, TransportIdentity } from "./gateway-types";
20
+ import { RpcGatewayRuntime, type RpcGatewayRuntimeOptions } from "./rpc-runtime";
21
+ import type { RpcSessionState } from "./rpc-protocol";
22
+ import { prepareInheritedHarness, prepareLearningOverlay } from "./rpc-profile";
23
+ import { TelegramTransportAdapter, type TelegramTransportAdapterOptions } from "./transports/telegram/adapter";
24
+ import { WebSocketTransportAdapter, type WebSocketTransportOptions } from "./transports/websocket/adapter";
25
+
26
+ export interface GatewayApplicationStore extends Partial<GatewayScheduledJobStore> {
27
+ close(): void;
28
+ resolvePrincipal(identity: TransportIdentity): Principal | undefined;
29
+ getCheckpoint(adapter: string, key: string): JsonValue | undefined;
30
+ setCheckpoint(adapter: string, key: string, value: JsonValue): void;
31
+ claimInboundMessage(transport: string, account: string, messageId: string, receivedAt: number): boolean;
32
+ releaseInboundMessage(transport: string, account: string, messageId: string): boolean;
33
+ bindConversation(binding: ConversationBinding): void;
34
+ putPendingInteraction: GatewayStore["putPendingInteraction"];
35
+ deletePendingInteraction: GatewayStore["deletePendingInteraction"];
36
+ }
37
+
38
+ export interface GatewayRuntime {
39
+ start(): Promise<void>;
40
+ stop(): Promise<void>;
41
+ handleInbound(message: InboundMessage): Promise<void>;
42
+ handleScheduled?(message: InboundMessage): Promise<void>;
43
+ isBusy?(): boolean;
44
+ }
45
+
46
+ export type GatewayCoreRuntime = GatewayDelivery & Pick<GatewayCore, "register" | "start" | "stop">;
47
+ export interface GatewaySchedulerRuntime extends GatewayAutomationControl {
48
+ start(): void;
49
+ stop(): void;
50
+ }
51
+
52
+ export interface GatewayApplicationSeams {
53
+ readonly createStore?: (path: string) => GatewayApplicationStore;
54
+ readonly createCore?: (options: GatewayCoreOptions) => GatewayCoreRuntime;
55
+ readonly createRuntime?: (options: RpcGatewayRuntimeOptions) => GatewayRuntime;
56
+ readonly createTelegramAdapter?: (options: TelegramTransportAdapterOptions) => TransportAdapter;
57
+ readonly createWebSocketAdapter?: (options: WebSocketTransportOptions) => TransportAdapter;
58
+ readonly createScheduler?: (options: GatewaySchedulerOptions) => GatewaySchedulerRuntime;
59
+ readonly acquireLock?: (path: string) => { readonly ok: true } | { readonly ok: false; readonly holder: number };
60
+ readonly releaseLock?: (path: string) => void;
61
+ readonly startLockHeartbeat?: (path: string) => () => void;
62
+ readonly now?: () => number;
63
+ }
64
+
65
+ export interface GatewayApplicationOptions {
66
+ readonly config: GatewayConfig;
67
+ /** Test-only seam; production resolves configured env names during start. */
68
+ readonly secrets?: GatewaySecrets;
69
+ readonly seams?: GatewayApplicationSeams;
70
+ }
71
+
72
+ export type GatewayApplicationState = "idle" | "starting" | "started" | "stopping";
73
+
74
+ export interface GatewayApplicationStatus {
75
+ readonly state: GatewayApplicationState;
76
+ readonly lockPath: string;
77
+ readonly adapters: readonly string[];
78
+ readonly sessionFile?: string;
79
+ }
80
+
81
+ /**
82
+ * One process owns one OMP RPC session. Transport adapters are deliberately
83
+ * downstream of that session: no poller or server can accept traffic first.
84
+ */
85
+ export class GatewayApplication {
86
+ readonly #config: GatewayConfig;
87
+ readonly #providedSecrets?: GatewaySecrets;
88
+ readonly #seams: GatewayApplicationSeams;
89
+ readonly #lockPath: string;
90
+ readonly #databasePath: string;
91
+ #state: GatewayApplicationState = "idle";
92
+ #store: GatewayApplicationStore | undefined;
93
+ #core: GatewayCoreRuntime | undefined;
94
+ #runtime: GatewayRuntime | undefined;
95
+ #scheduler: GatewaySchedulerRuntime | undefined;
96
+ #releaseHeartbeat: (() => void) | undefined;
97
+ #adapters: TransportAdapter[] = [];
98
+ #sessionFile: string | undefined;
99
+
100
+ constructor(options: GatewayApplicationOptions) {
101
+ this.#config = options.config;
102
+ this.#providedSecrets = options.secrets;
103
+ this.#seams = options.seams ?? {};
104
+ this.#lockPath = resolve(options.config.stateDir, "ompclaw.lock");
105
+ this.#databasePath = resolve(options.config.stateDir, "ompclaw.sqlite");
106
+ }
107
+
108
+ status(): GatewayApplicationStatus {
109
+ return {
110
+ state: this.#state,
111
+ lockPath: this.#lockPath,
112
+ adapters: this.#adapters.map((adapter) => adapter.id),
113
+ ...(this.#sessionFile === undefined ? {} : { sessionFile: this.#sessionFile }),
114
+ };
115
+ }
116
+
117
+ async start(signal?: AbortSignal): Promise<void> {
118
+ if (this.#state === "started") return;
119
+ if (this.#state !== "idle") throw new Error(`OmpClaw application cannot start while ${this.#state}`);
120
+ this.#state = "starting";
121
+
122
+ let lockHeld = false;
123
+ try {
124
+ const lock = (this.#seams.acquireLock ?? acquireLock)(this.#lockPath);
125
+ if (!lock.ok) throw new Error(`OmpClaw is already running in process ${lock.holder}`);
126
+ lockHeld = true;
127
+ this.#releaseHeartbeat = (this.#seams.startLockHeartbeat ?? startLockHeartbeat)(this.#lockPath);
128
+
129
+ const store = (this.#seams.createStore ?? ((path: string) => new GatewayStore(path)))(this.#databasePath);
130
+ this.#store = store;
131
+ const checkpoint = store.getCheckpoint("omp", "session_file");
132
+ if (checkpoint !== undefined && (typeof checkpoint !== "string" || checkpoint.length === 0)) {
133
+ throw new Error("OMP session checkpoint must be a non-empty string");
134
+ }
135
+ this.#sessionFile = checkpoint;
136
+
137
+ const core = (this.#seams.createCore ?? ((options: GatewayCoreOptions) => new GatewayCore(options)))({
138
+ identityResolver: (identity) => store.resolvePrincipal(identity),
139
+ onInbound: (message) => this.#handleInbound(message),
140
+ });
141
+ this.#core = core;
142
+
143
+ const secrets = this.#providedSecrets ?? resolveGatewaySecrets(this.#config);
144
+ this.#adapters = this.#createAdapters(store, secrets);
145
+ for (const adapter of this.#adapters) core.register(adapter);
146
+
147
+ let scheduler: GatewaySchedulerRuntime | undefined;
148
+ if (this.#config.automation.enabled) {
149
+ const scheduledStore = requireScheduledStore(store);
150
+ scheduler = (this.#seams.createScheduler ?? ((options: GatewaySchedulerOptions) => new GatewayScheduler(options)))({
151
+ store: scheduledStore,
152
+ dispatch: (job, scheduledFor) => this.#dispatchScheduledJob(job, scheduledFor),
153
+ enabled: true,
154
+ pollIntervalMs: this.#config.automation.pollIntervalMs,
155
+ retryDelayMs: this.#config.automation.retryDelayMs,
156
+ maxAttempts: this.#config.automation.maxAttempts,
157
+ ...(this.#seams.now === undefined ? {} : { now: this.#seams.now }),
158
+ onPermanentFailure: (job, error) => this.#notifyScheduledFailure(job, error),
159
+ });
160
+ this.#scheduler = scheduler;
161
+ }
162
+
163
+ let rpcConfig = gatewayRpcRuntimeConfig(this.#config);
164
+ prepareInheritedHarness(rpcConfig);
165
+ const learningOverlay = prepareLearningOverlay(this.#config);
166
+ if (learningOverlay !== undefined) rpcConfig = { ...rpcConfig, configFiles: [...rpcConfig.configFiles, learningOverlay] };
167
+
168
+ const runtime = (this.#seams.createRuntime ?? ((options: RpcGatewayRuntimeOptions) => new RpcGatewayRuntime(options)))({
169
+ config: rpcConfig,
170
+ delivery: core,
171
+ sessionFile: this.#sessionFile,
172
+ onSessionState: (session) => this.#recordSessionState(session),
173
+ ...(scheduler === undefined ? {} : { automation: scheduler }),
174
+ });
175
+ this.#runtime = runtime;
176
+
177
+ // OMP starts before the core makes a transport reachable.
178
+ await runtime.start();
179
+ this.#checkpointSession();
180
+ await core.start(signal);
181
+ scheduler?.start();
182
+ this.#state = "started";
183
+ } catch (error) {
184
+ await this.#rollbackStart(lockHeld);
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ async stop(): Promise<void> {
190
+ if (this.#state === "idle") return;
191
+ if (this.#state === "stopping") return;
192
+ if (this.#state === "starting") throw new Error("OmpClaw application cannot stop while starting");
193
+ this.#state = "stopping";
194
+ const error = await this.#stopResources();
195
+ this.#state = "idle";
196
+ if (error !== undefined) throw error;
197
+ }
198
+
199
+ #createAdapters(store: GatewayApplicationStore, secrets: GatewaySecrets): TransportAdapter[] {
200
+ const adapters: TransportAdapter[] = [];
201
+ const telegram = this.#config.transports.telegram;
202
+ if (telegram?.enabled) {
203
+ if (secrets.telegramToken === undefined) throw new Error("Telegram token was not resolved");
204
+ adapters.push((this.#seams.createTelegramAdapter ?? ((options: TelegramTransportAdapterOptions) => new TelegramTransportAdapter(options)))({
205
+ token: secrets.telegramToken,
206
+ account: telegram.account,
207
+ stateDir: this.#config.stateDir,
208
+ store,
209
+ }));
210
+ }
211
+
212
+ const websocket = this.#config.transports.websocket;
213
+ if (websocket?.enabled) {
214
+ if (secrets.webSocketCredentials.length !== websocket.credentials.length) {
215
+ throw new Error("WebSocket credential resolution does not match configured credentials");
216
+ }
217
+ adapters.push((this.#seams.createWebSocketAdapter ?? ((options: WebSocketTransportOptions) => new WebSocketTransportAdapter(options)))({
218
+ hostname: websocket.hostname,
219
+ port: websocket.port,
220
+ account: websocket.account,
221
+ credentials: secrets.webSocketCredentials,
222
+ }));
223
+ }
224
+ return adapters;
225
+ }
226
+
227
+ async #handleInbound(message: InboundMessage): Promise<void> {
228
+ await this.#processInbound(message, false);
229
+ }
230
+
231
+ async #processInbound(message: InboundMessage, scheduled: boolean): Promise<void> {
232
+ const store = this.#requireStore();
233
+ const runtime = this.#requireRuntime();
234
+ const { transport, account } = message.address;
235
+ if (scheduled) store.releaseInboundMessage(transport, account, message.id);
236
+ if (!store.claimInboundMessage(transport, account, message.id, (this.#seams.now ?? Date.now)())) return;
237
+
238
+ try {
239
+ if (scheduled && runtime.handleScheduled !== undefined) await runtime.handleScheduled(message);
240
+ else await runtime.handleInbound(message);
241
+ const sessionFile = this.#sessionFile;
242
+ if (sessionFile === undefined) throw new Error("OMP runtime did not report its current session file");
243
+ store.bindConversation({
244
+ address: message.address,
245
+ ompSessionPath: sessionFile,
246
+ workspace: this.#config.workspace,
247
+ });
248
+ this.#checkpointSession();
249
+ } catch (error) {
250
+ store.releaseInboundMessage(transport, account, message.id);
251
+ throw error;
252
+ }
253
+ }
254
+
255
+ async #dispatchScheduledJob(job: ScheduledJob, scheduledFor: number): Promise<void> {
256
+ const runtime = this.#requireRuntime();
257
+ if (runtime.isBusy?.()) throw new ScheduledDispatchBusyError("OMP is serving another turn");
258
+ const principal = this.#requireStore().resolvePrincipal(job.identity);
259
+ if (principal === undefined || principal.id !== job.principalId) {
260
+ throw new Error(`Scheduled job ${job.id} no longer has an authorized principal`);
261
+ }
262
+ await this.#processInbound({
263
+ id: `scheduled:${job.id}:${scheduledFor}`,
264
+ sentAt: scheduledFor,
265
+ identity: job.identity,
266
+ principal,
267
+ address: job.address,
268
+ content: {
269
+ text: [
270
+ `Scheduled job "${job.name}" is due (job ${job.id}, scheduled ${new Date(scheduledFor).toISOString()}).`,
271
+ "Execute this unattended task now and report the result to this conversation:",
272
+ job.prompt,
273
+ ].join("\n\n"),
274
+ },
275
+ }, true);
276
+ }
277
+
278
+ async #notifyScheduledFailure(job: ScheduledJob, error: Error): Promise<void> {
279
+ const principal = this.#requireStore().resolvePrincipal(job.identity);
280
+ const core = this.#core;
281
+ if (principal === undefined || principal.id !== job.principalId || core === undefined) return;
282
+ await core.send(
283
+ job.address,
284
+ { text: `Scheduled job "${job.name}" failed after ${this.#config.automation.maxAttempts} attempts: ${error.message}`, format: "text" },
285
+ { principal, origin: job.address },
286
+ );
287
+ }
288
+
289
+ #recordSessionState(state: RpcSessionState): void {
290
+ if (typeof state.sessionFile === "string" && state.sessionFile.length > 0) this.#sessionFile = state.sessionFile;
291
+ }
292
+
293
+ #checkpointSession(): void {
294
+ if (this.#sessionFile !== undefined) this.#requireStore().setCheckpoint("omp", "session_file", this.#sessionFile);
295
+ }
296
+
297
+ async #rollbackStart(lockHeld: boolean): Promise<void> {
298
+ await this.#stopResources(lockHeld);
299
+ this.#state = "idle";
300
+ }
301
+
302
+ async #stopResources(lockHeld = true): Promise<unknown> {
303
+ let firstError: unknown;
304
+ const remember = (error: unknown): void => { firstError ??= error; };
305
+
306
+ const scheduler = this.#scheduler;
307
+ this.#scheduler = undefined;
308
+ if (scheduler !== undefined) {
309
+ try {
310
+ scheduler.stop();
311
+ } catch (error) {
312
+ remember(error);
313
+ }
314
+ }
315
+
316
+ const core = this.#core;
317
+ this.#core = undefined;
318
+ if (core !== undefined) {
319
+ try {
320
+ await core.stop();
321
+ } catch (error) {
322
+ remember(error);
323
+ }
324
+ }
325
+
326
+ const runtime = this.#runtime;
327
+ this.#runtime = undefined;
328
+ if (runtime !== undefined) {
329
+ try {
330
+ await runtime.stop();
331
+ } catch (error) {
332
+ remember(error);
333
+ }
334
+ }
335
+
336
+ const store = this.#store;
337
+ this.#store = undefined;
338
+ if (store !== undefined) {
339
+ try {
340
+ store.close();
341
+ } catch (error) {
342
+ remember(error);
343
+ }
344
+ }
345
+
346
+ try {
347
+ this.#releaseHeartbeat?.();
348
+ } catch (error) {
349
+ remember(error);
350
+ }
351
+ this.#releaseHeartbeat = undefined;
352
+ if (lockHeld) {
353
+ try {
354
+ (this.#seams.releaseLock ?? releaseLock)(this.#lockPath);
355
+ } catch (error) {
356
+ remember(error);
357
+ }
358
+ }
359
+
360
+ this.#adapters = [];
361
+ this.#sessionFile = undefined;
362
+ return firstError;
363
+ }
364
+
365
+ #requireStore(): GatewayApplicationStore {
366
+ if (this.#store === undefined) throw new Error("OmpClaw store is not started");
367
+ return this.#store;
368
+ }
369
+
370
+ #requireRuntime(): GatewayRuntime {
371
+ if (this.#runtime === undefined) throw new Error("OmpClaw runtime is not started");
372
+ return this.#runtime;
373
+ }
374
+ }
375
+
376
+ export function gatewayDatabasePath(config: Pick<GatewayConfig, "stateDir">): string {
377
+ return join(config.stateDir, "ompclaw.sqlite");
378
+ }
379
+
380
+ function requireScheduledStore(store: GatewayApplicationStore): GatewayScheduledJobStore {
381
+ const methods = [
382
+ "createScheduledJob",
383
+ "updateScheduledJob",
384
+ "getScheduledJob",
385
+ "listScheduledJobs",
386
+ "listDueScheduledJobs",
387
+ "deleteScheduledJob",
388
+ ] as const;
389
+ for (const method of methods) {
390
+ if (typeof store[method] !== "function") throw new Error(`OmpClaw store does not support automation method ${method}`);
391
+ }
392
+ return store as GatewayScheduledJobStore;
393
+ }