@positronic/cloudflare 0.0.2

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.
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export {
2
+ BrainRunnerDO,
3
+ setBrainRunner,
4
+ setManifest,
5
+ getManifest,
6
+ } from './brain-runner-do.js';
7
+ export { MonitorDO } from './monitor-do.js';
8
+ export { ScheduleDO } from './schedule-do.js';
9
+ export { PositronicManifest } from './manifest.js';
10
+ export { default as api } from './api.js';
11
+ export { CloudflareR2Loader } from './r2-loader.js';
@@ -0,0 +1,69 @@
1
+ import type { Brain } from '@positronic/core';
2
+
3
+ interface BrainImportStrategy {
4
+ import(name: string): Promise<Brain | undefined>;
5
+ list(): string[];
6
+ }
7
+
8
+ class StaticManifestStrategy implements BrainImportStrategy {
9
+ constructor(private manifest: Record<string, Brain>) {}
10
+
11
+ async import(name: string): Promise<Brain | undefined> {
12
+ return this.manifest[name];
13
+ }
14
+
15
+ list(): string[] {
16
+ return Object.keys(this.manifest);
17
+ }
18
+ }
19
+
20
+ class DynamicImportStrategy implements BrainImportStrategy {
21
+ constructor(private brainsDir: string) {}
22
+
23
+ async import(name: string): Promise<Brain | undefined> {
24
+ try {
25
+ const module = await import(`${this.brainsDir}/${name}.ts`);
26
+ return module.default;
27
+ } catch (e) {
28
+ console.error(`Failed to import brain ${name}:`, e);
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ list(): string[] {
34
+ // For dynamic imports, we can't easily list files at runtime in a worker environment
35
+ // This would need to be handled differently, perhaps by generating a list at build time
36
+ console.warn('DynamicImportStrategy.list() is not implemented - returning empty array');
37
+ return [];
38
+ }
39
+ }
40
+
41
+ export class PositronicManifest {
42
+ private importStrategy: BrainImportStrategy;
43
+
44
+ constructor(options: {
45
+ staticManifest?: Record<string, Brain>;
46
+ brainsDir?: string;
47
+ }) {
48
+ if (options.staticManifest && options.brainsDir) {
49
+ throw new Error(
50
+ 'Cannot provide both staticManifest and brainsDir - choose one import strategy'
51
+ );
52
+ }
53
+ if (!options.staticManifest && !options.brainsDir) {
54
+ throw new Error('Must provide either staticManifest or brainsDir');
55
+ }
56
+
57
+ this.importStrategy = options.staticManifest
58
+ ? new StaticManifestStrategy(options.staticManifest)
59
+ : new DynamicImportStrategy(options.brainsDir!);
60
+ }
61
+
62
+ async import(name: string): Promise<Brain | undefined> {
63
+ return this.importStrategy.import(name);
64
+ }
65
+
66
+ list(): string[] {
67
+ return this.importStrategy.list();
68
+ }
69
+ }
@@ -0,0 +1,268 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { BRAIN_EVENTS, STATUS } from '@positronic/core';
3
+ import type { BrainEvent } from '@positronic/core';
4
+
5
+ export interface Env {
6
+ // Add any environment bindings here as needed
7
+ }
8
+
9
+ export class MonitorDO extends DurableObject<Env> {
10
+ private readonly storage: SqlStorage;
11
+ private eventStreamHandler = new EventStreamHandler();
12
+
13
+ constructor(state: DurableObjectState, env: Env) {
14
+ super(state, env);
15
+ this.storage = state.storage.sql;
16
+
17
+ // Update table schema and indexes
18
+ this.storage.exec(`
19
+ CREATE TABLE IF NOT EXISTS brain_runs (
20
+ run_id TEXT PRIMARY KEY,
21
+ brain_title TEXT NOT NULL, -- Renamed column
22
+ brain_description TEXT, -- Renamed column
23
+ type TEXT NOT NULL,
24
+ status TEXT NOT NULL,
25
+ options TEXT,
26
+ error TEXT,
27
+ created_at INTEGER NOT NULL,
28
+ started_at INTEGER,
29
+ completed_at INTEGER
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_brain_status -- Renamed index
33
+ ON brain_runs(brain_title, status);
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_brain_time -- Renamed index
36
+ ON brain_runs(created_at DESC);
37
+ `);
38
+ }
39
+
40
+ handleBrainEvent(event: BrainEvent<any>) {
41
+ if (
42
+ event.type === BRAIN_EVENTS.START ||
43
+ event.type === BRAIN_EVENTS.RESTART ||
44
+ event.type === BRAIN_EVENTS.COMPLETE ||
45
+ event.type === BRAIN_EVENTS.ERROR
46
+ ) {
47
+ const currentTime = Date.now();
48
+ const startTime =
49
+ event.type === BRAIN_EVENTS.START || event.type === BRAIN_EVENTS.RESTART
50
+ ? currentTime
51
+ : null;
52
+ const completeTime =
53
+ event.type === BRAIN_EVENTS.COMPLETE ||
54
+ event.type === BRAIN_EVENTS.ERROR
55
+ ? currentTime
56
+ : null;
57
+ const error =
58
+ event.type === BRAIN_EVENTS.ERROR ? JSON.stringify(event.error) : null;
59
+
60
+ // Update SQL insert/update with new column names, read from existing event fields
61
+ this.storage.exec(
62
+ `
63
+ INSERT INTO brain_runs (
64
+ run_id, brain_title, brain_description, type, status,
65
+ options, error, created_at, started_at, completed_at
66
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
67
+ ON CONFLICT(run_id) DO UPDATE SET
68
+ type = excluded.type,
69
+ status = excluded.status,
70
+ error = excluded.error,
71
+ completed_at = excluded.completed_at
72
+ `,
73
+ event.brainRunId, // Use brainRunId for run_id
74
+ event.brainTitle, // Read from event field, store in brain_title
75
+ event.brainDescription || null, // Read from event field, store in brain_description
76
+ event.type,
77
+ event.status,
78
+ JSON.stringify(event.options || {}),
79
+ error,
80
+ currentTime,
81
+ startTime,
82
+ completeTime
83
+ );
84
+
85
+ this.broadcastRunningBrains();
86
+ }
87
+ }
88
+
89
+ private async broadcastRunningBrains() {
90
+ const runningBrains = await this.storage
91
+ .exec(
92
+ `
93
+ SELECT
94
+ run_id as brainRunId,
95
+ brain_title as brainTitle,
96
+ brain_description as brainDescription,
97
+ type,
98
+ status,
99
+ options,
100
+ error,
101
+ created_at as createdAt,
102
+ started_at as startedAt,
103
+ completed_at as completedAt
104
+ FROM brain_runs
105
+ WHERE status = ?
106
+ ORDER BY created_at DESC
107
+ `,
108
+ STATUS.RUNNING
109
+ )
110
+ .toArray();
111
+
112
+ this.eventStreamHandler.broadcast({ runningBrains });
113
+ }
114
+
115
+ async fetch(request: Request) {
116
+ const url = new URL(request.url);
117
+ const encoder = new TextEncoder();
118
+
119
+ if (url.pathname === '/watch') {
120
+ const stream = new ReadableStream({
121
+ start: async (controller) => {
122
+ try {
123
+ const runningBrains = await this.storage
124
+ .exec(
125
+ `
126
+ SELECT
127
+ run_id as brainRunId,
128
+ brain_title as brainTitle,
129
+ brain_description as brainDescription,
130
+ type,
131
+ status,
132
+ options,
133
+ error,
134
+ created_at as createdAt,
135
+ started_at as startedAt,
136
+ completed_at as completedAt
137
+ FROM brain_runs
138
+ WHERE status = ?
139
+ ORDER BY created_at DESC
140
+ `,
141
+ STATUS.RUNNING
142
+ )
143
+ .toArray();
144
+
145
+ controller.enqueue(
146
+ encoder.encode(`data: ${JSON.stringify({ runningBrains })}\n\n`)
147
+ );
148
+
149
+ this.eventStreamHandler.subscribe(controller);
150
+ } catch (err) {
151
+ console.error('[MONITOR_DO] Error during stream start:', err);
152
+ controller.close();
153
+ this.eventStreamHandler.unsubscribe(controller);
154
+ }
155
+ },
156
+ cancel: (controller) => {
157
+ this.eventStreamHandler.unsubscribe(controller);
158
+ },
159
+ });
160
+
161
+ return new Response(stream, {
162
+ headers: {
163
+ 'Content-Type': 'text/event-stream',
164
+ 'Cache-Control': 'no-cache',
165
+ Connection: 'keep-alive',
166
+ },
167
+ });
168
+ }
169
+
170
+ return new Response('Not found', { status: 404 });
171
+ }
172
+
173
+ // No changes needed for getLastEvent, uses run_id
174
+ getLastEvent(brainRunId: string) {
175
+ return this.storage
176
+ .exec(
177
+ `
178
+ SELECT * FROM brain_runs WHERE run_id = ?
179
+ `,
180
+ brainRunId
181
+ )
182
+ .one();
183
+ }
184
+
185
+ // Update history method parameter and query
186
+ history(brainTitle: string, limit: number = 10) {
187
+ // Renamed parameter
188
+ // Update select query with aliases and filter by brain_title
189
+ return this.storage
190
+ .exec(
191
+ `
192
+ SELECT
193
+ run_id as brainRunId,
194
+ brain_title as brainTitle,
195
+ brain_description as brainDescription,
196
+ type,
197
+ status,
198
+ options,
199
+ error,
200
+ created_at as createdAt,
201
+ started_at as startedAt,
202
+ completed_at as completedAt
203
+ FROM brain_runs
204
+ WHERE brain_title = ? -- Filter by new column name
205
+ ORDER BY created_at DESC
206
+ LIMIT ?
207
+ `,
208
+ brainTitle,
209
+ limit
210
+ )
211
+ .toArray(); // Use renamed parameter
212
+ }
213
+
214
+ // Get active/running brain runs for a specific brain
215
+ activeRuns(brainTitle: string) {
216
+ return this.storage
217
+ .exec(
218
+ `
219
+ SELECT
220
+ run_id as brainRunId,
221
+ brain_title as brainTitle,
222
+ brain_description as brainDescription,
223
+ type,
224
+ status,
225
+ options,
226
+ error,
227
+ created_at as createdAt,
228
+ started_at as startedAt,
229
+ completed_at as completedAt
230
+ FROM brain_runs
231
+ WHERE brain_title = ? AND status = ?
232
+ ORDER BY created_at DESC
233
+ `,
234
+ brainTitle,
235
+ STATUS.RUNNING
236
+ )
237
+ .toArray();
238
+ }
239
+ }
240
+
241
+ class EventStreamHandler {
242
+ private subscribers: Set<ReadableStreamDefaultController> = new Set();
243
+ private encoder = new TextEncoder();
244
+
245
+ subscribe(controller: ReadableStreamDefaultController) {
246
+ this.subscribers.add(controller);
247
+ }
248
+
249
+ unsubscribe(controller: ReadableStreamDefaultController) {
250
+ this.subscribers.delete(controller);
251
+ }
252
+
253
+ broadcast(data: any) {
254
+ const message = `data: ${JSON.stringify(data)}\n\n`;
255
+ const encodedMessage = this.encoder.encode(message);
256
+ this.subscribers.forEach((controller) => {
257
+ try {
258
+ controller.enqueue(encodedMessage);
259
+ } catch (e) {
260
+ console.error(
261
+ '[MONITOR_DO_SSE] Failed to send message to subscriber, removing.',
262
+ e
263
+ );
264
+ this.unsubscribe(controller);
265
+ }
266
+ });
267
+ }
268
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Node.js-compatible exports from @positronic/cloudflare
3
+ * This file is used when importing from Node.js environments (like the CLI)
4
+ * and excludes any Cloudflare Workers-specific modules.
5
+ */
6
+
7
+ // Only export modules that don't depend on cloudflare:workers
8
+ export { PositronicManifest } from './manifest.js';
9
+ export { CloudflareR2Loader } from './r2-loader.js';
10
+ export { CloudflareDevServer } from './dev-server.js';
11
+ // Export with standard name for CLI to use
12
+ export { CloudflareDevServer as DevServer } from './dev-server.js';
13
+
14
+ // Note: We do NOT export BrainRunnerDO, MonitorDO, or api here
15
+ // because they depend on cloudflare:workers runtime
@@ -0,0 +1,27 @@
1
+ import type { R2Bucket } from '@cloudflare/workers-types';
2
+ import type { ResourceLoader } from '@positronic/core';
3
+ import { Buffer } from 'buffer';
4
+
5
+ export class CloudflareR2Loader implements ResourceLoader {
6
+ constructor(private bucket: R2Bucket) {}
7
+
8
+ async load(resourceName: string, type: 'text'): Promise<string>;
9
+ async load(resourceName: string, type: 'binary'): Promise<Buffer>;
10
+ async load(
11
+ resourceName: string,
12
+ type: 'text' | 'binary' = 'text'
13
+ ): Promise<string | Buffer> {
14
+ const object = await this.bucket.get(resourceName);
15
+
16
+ if (object === null) {
17
+ throw new Error(`Resource "${resourceName}" not found in R2 bucket.`);
18
+ }
19
+
20
+ if (type === 'binary') {
21
+ const arrayBuffer = await object.arrayBuffer();
22
+ return Buffer.from(arrayBuffer);
23
+ }
24
+ // Defaults to text
25
+ return object.text();
26
+ }
27
+ }