@donkeylabs/server 2.0.7 → 2.0.11
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/docs/lifecycle-hooks.md +16 -16
- package/docs/processes.md +93 -0
- package/package.json +13 -3
- package/src/admin/dashboard.ts +717 -0
- package/src/admin/index.ts +85 -0
- package/src/admin/routes.ts +573 -0
- package/src/admin/styles.ts +422 -0
- package/src/core/index.ts +25 -0
- package/src/core/job-adapter-kysely.ts +22 -1
- package/src/core/job-adapter-sqlite.ts +22 -1
- package/src/core/jobs.ts +37 -0
- package/src/core/process-client.ts +121 -0
- package/src/core/processes.ts +67 -0
- package/src/core/storage-adapter-local.ts +403 -0
- package/src/core/storage-adapter-s3.ts +409 -0
- package/src/core/storage.ts +543 -0
- package/src/core/websocket.ts +13 -3
- package/src/core/workflow-adapter-kysely.ts +22 -1
- package/src/core/workflows.ts +37 -0
- package/src/core.ts +10 -1
- package/src/harness.ts +3 -0
- package/src/index.ts +19 -0
- package/src/process-client.ts +7 -0
- package/src/server.ts +71 -31
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// Core Storage Service
|
|
2
|
+
// File storage abstraction supporting multiple providers: S3-compatible, local filesystem, and memory
|
|
3
|
+
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// TYPES
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
/** File visibility for access control */
|
|
9
|
+
export type StorageVisibility = "public" | "private";
|
|
10
|
+
|
|
11
|
+
/** Metadata about a stored file */
|
|
12
|
+
export interface StorageFile {
|
|
13
|
+
/** The file key/path */
|
|
14
|
+
key: string;
|
|
15
|
+
/** File size in bytes */
|
|
16
|
+
size: number;
|
|
17
|
+
/** MIME type of the file */
|
|
18
|
+
contentType?: string;
|
|
19
|
+
/** Last modified date */
|
|
20
|
+
lastModified: Date;
|
|
21
|
+
/** ETag/checksum if available */
|
|
22
|
+
etag?: string;
|
|
23
|
+
/** Custom metadata */
|
|
24
|
+
metadata?: Record<string, string>;
|
|
25
|
+
/** File visibility */
|
|
26
|
+
visibility?: StorageVisibility;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options for uploading a file */
|
|
30
|
+
export interface UploadOptions {
|
|
31
|
+
/** The key/path to store the file at */
|
|
32
|
+
key: string;
|
|
33
|
+
/** The file content - Buffer, Uint8Array, string, Blob, or ReadableStream */
|
|
34
|
+
body: Buffer | Uint8Array | string | Blob | ReadableStream<Uint8Array>;
|
|
35
|
+
/** MIME type of the file */
|
|
36
|
+
contentType?: string;
|
|
37
|
+
/** File visibility (public or private) */
|
|
38
|
+
visibility?: StorageVisibility;
|
|
39
|
+
/** Custom metadata to store with the file */
|
|
40
|
+
metadata?: Record<string, string>;
|
|
41
|
+
/** Content disposition header (e.g., 'attachment; filename="file.pdf"') */
|
|
42
|
+
contentDisposition?: string;
|
|
43
|
+
/** Cache control header */
|
|
44
|
+
cacheControl?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Result of an upload operation */
|
|
48
|
+
export interface UploadResult {
|
|
49
|
+
/** The key/path where the file was stored */
|
|
50
|
+
key: string;
|
|
51
|
+
/** File size in bytes */
|
|
52
|
+
size: number;
|
|
53
|
+
/** ETag/checksum if available */
|
|
54
|
+
etag?: string;
|
|
55
|
+
/** Public URL if the file is public */
|
|
56
|
+
url?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Result of a download operation */
|
|
60
|
+
export interface DownloadResult {
|
|
61
|
+
/** The file content as a readable stream */
|
|
62
|
+
body: ReadableStream<Uint8Array>;
|
|
63
|
+
/** File size in bytes */
|
|
64
|
+
size: number;
|
|
65
|
+
/** MIME type of the file */
|
|
66
|
+
contentType?: string;
|
|
67
|
+
/** Last modified date */
|
|
68
|
+
lastModified: Date;
|
|
69
|
+
/** ETag/checksum if available */
|
|
70
|
+
etag?: string;
|
|
71
|
+
/** Custom metadata */
|
|
72
|
+
metadata?: Record<string, string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Options for listing files */
|
|
76
|
+
export interface ListOptions {
|
|
77
|
+
/** Prefix to filter files by (e.g., "users/123/") */
|
|
78
|
+
prefix?: string;
|
|
79
|
+
/** Maximum number of files to return */
|
|
80
|
+
limit?: number;
|
|
81
|
+
/** Cursor for pagination (from previous ListResult) */
|
|
82
|
+
cursor?: string;
|
|
83
|
+
/** Delimiter for hierarchical listing (usually "/") */
|
|
84
|
+
delimiter?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Result of a list operation */
|
|
88
|
+
export interface ListResult {
|
|
89
|
+
/** Files matching the query */
|
|
90
|
+
files: StorageFile[];
|
|
91
|
+
/** Common prefixes (directories) when using delimiter */
|
|
92
|
+
prefixes: string[];
|
|
93
|
+
/** Cursor for next page, null if no more results */
|
|
94
|
+
cursor: string | null;
|
|
95
|
+
/** Whether there are more results */
|
|
96
|
+
hasMore: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Options for getting a file URL */
|
|
100
|
+
export interface GetUrlOptions {
|
|
101
|
+
/** URL expiration time in seconds (for signed URLs) */
|
|
102
|
+
expiresIn?: number;
|
|
103
|
+
/** Force download with specific filename */
|
|
104
|
+
download?: string | boolean;
|
|
105
|
+
/** Content type override */
|
|
106
|
+
contentType?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Options for copying a file */
|
|
110
|
+
export interface CopyOptions {
|
|
111
|
+
/** Source file key */
|
|
112
|
+
source: string;
|
|
113
|
+
/** Destination file key */
|
|
114
|
+
destination: string;
|
|
115
|
+
/** Override metadata (optional) */
|
|
116
|
+
metadata?: Record<string, string>;
|
|
117
|
+
/** Override visibility (optional) */
|
|
118
|
+
visibility?: StorageVisibility;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// PROVIDER CONFIGS
|
|
123
|
+
// =============================================================================
|
|
124
|
+
|
|
125
|
+
/** S3-compatible provider configuration */
|
|
126
|
+
export interface S3ProviderConfig {
|
|
127
|
+
provider: "s3";
|
|
128
|
+
/** S3 bucket name */
|
|
129
|
+
bucket: string;
|
|
130
|
+
/** AWS region */
|
|
131
|
+
region: string;
|
|
132
|
+
/** AWS access key ID */
|
|
133
|
+
accessKeyId: string;
|
|
134
|
+
/** AWS secret access key */
|
|
135
|
+
secretAccessKey: string;
|
|
136
|
+
/** Custom endpoint URL (for R2, MinIO, DigitalOcean Spaces, etc.) */
|
|
137
|
+
endpoint?: string;
|
|
138
|
+
/** Public URL base for public files (e.g., CDN URL) */
|
|
139
|
+
publicUrl?: string;
|
|
140
|
+
/** Force path-style URLs (required for MinIO, optional for others) */
|
|
141
|
+
forcePathStyle?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Local filesystem provider configuration */
|
|
145
|
+
export interface LocalProviderConfig {
|
|
146
|
+
provider: "local";
|
|
147
|
+
/** Base directory for file storage */
|
|
148
|
+
directory: string;
|
|
149
|
+
/** Base URL for serving files (e.g., "/storage" or "https://cdn.example.com") */
|
|
150
|
+
baseUrl?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Memory provider configuration (for testing) */
|
|
154
|
+
export interface MemoryProviderConfig {
|
|
155
|
+
provider: "memory";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Union of all provider configurations */
|
|
159
|
+
export type StorageConfig = S3ProviderConfig | LocalProviderConfig | MemoryProviderConfig;
|
|
160
|
+
|
|
161
|
+
// =============================================================================
|
|
162
|
+
// ADAPTER INTERFACE
|
|
163
|
+
// =============================================================================
|
|
164
|
+
|
|
165
|
+
/** Storage adapter interface - implement this for custom providers */
|
|
166
|
+
export interface StorageAdapter {
|
|
167
|
+
/** Upload a file */
|
|
168
|
+
upload(options: UploadOptions): Promise<UploadResult>;
|
|
169
|
+
/** Download a file (returns null if not found) */
|
|
170
|
+
download(key: string): Promise<DownloadResult | null>;
|
|
171
|
+
/** Delete a file (returns true if deleted, false if not found) */
|
|
172
|
+
delete(key: string): Promise<boolean>;
|
|
173
|
+
/** Delete multiple files */
|
|
174
|
+
deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }>;
|
|
175
|
+
/** List files with optional filtering and pagination */
|
|
176
|
+
list(options?: ListOptions): Promise<ListResult>;
|
|
177
|
+
/** Get file metadata without downloading (returns null if not found) */
|
|
178
|
+
head(key: string): Promise<StorageFile | null>;
|
|
179
|
+
/** Check if a file exists */
|
|
180
|
+
exists(key: string): Promise<boolean>;
|
|
181
|
+
/** Get a URL for accessing the file */
|
|
182
|
+
getUrl(key: string, options?: GetUrlOptions): Promise<string>;
|
|
183
|
+
/** Copy a file to a new location */
|
|
184
|
+
copy(options: CopyOptions): Promise<UploadResult>;
|
|
185
|
+
/** Cleanup resources (called on shutdown) */
|
|
186
|
+
stop(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// =============================================================================
|
|
190
|
+
// PUBLIC INTERFACE
|
|
191
|
+
// =============================================================================
|
|
192
|
+
|
|
193
|
+
/** Storage service public interface */
|
|
194
|
+
export interface Storage {
|
|
195
|
+
/** Upload a file */
|
|
196
|
+
upload(options: UploadOptions): Promise<UploadResult>;
|
|
197
|
+
/** Download a file (returns null if not found) */
|
|
198
|
+
download(key: string): Promise<DownloadResult | null>;
|
|
199
|
+
/** Delete a file (returns true if deleted, false if not found) */
|
|
200
|
+
delete(key: string): Promise<boolean>;
|
|
201
|
+
/** Delete multiple files */
|
|
202
|
+
deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }>;
|
|
203
|
+
/** List files with optional filtering and pagination */
|
|
204
|
+
list(options?: ListOptions): Promise<ListResult>;
|
|
205
|
+
/** Get file metadata without downloading (returns null if not found) */
|
|
206
|
+
head(key: string): Promise<StorageFile | null>;
|
|
207
|
+
/** Check if a file exists */
|
|
208
|
+
exists(key: string): Promise<boolean>;
|
|
209
|
+
/** Get a URL for accessing the file */
|
|
210
|
+
getUrl(key: string, options?: GetUrlOptions): Promise<string>;
|
|
211
|
+
/** Copy a file to a new location */
|
|
212
|
+
copy(options: CopyOptions): Promise<UploadResult>;
|
|
213
|
+
/** Move a file to a new location (copy + delete) */
|
|
214
|
+
move(source: string, destination: string): Promise<UploadResult>;
|
|
215
|
+
/** Cleanup resources (called on shutdown) */
|
|
216
|
+
stop(): void;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// MEMORY ADAPTER (Testing/Default)
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
interface MemoryFile {
|
|
224
|
+
body: Uint8Array;
|
|
225
|
+
contentType?: string;
|
|
226
|
+
metadata?: Record<string, string>;
|
|
227
|
+
visibility?: StorageVisibility;
|
|
228
|
+
contentDisposition?: string;
|
|
229
|
+
cacheControl?: string;
|
|
230
|
+
lastModified: Date;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** In-memory storage adapter for testing and development */
|
|
234
|
+
export class MemoryStorageAdapter implements StorageAdapter {
|
|
235
|
+
private files = new Map<string, MemoryFile>();
|
|
236
|
+
|
|
237
|
+
async upload(options: UploadOptions): Promise<UploadResult> {
|
|
238
|
+
const body = await this.toUint8Array(options.body);
|
|
239
|
+
|
|
240
|
+
this.files.set(options.key, {
|
|
241
|
+
body,
|
|
242
|
+
contentType: options.contentType,
|
|
243
|
+
metadata: options.metadata,
|
|
244
|
+
visibility: options.visibility,
|
|
245
|
+
contentDisposition: options.contentDisposition,
|
|
246
|
+
cacheControl: options.cacheControl,
|
|
247
|
+
lastModified: new Date(),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
key: options.key,
|
|
252
|
+
size: body.byteLength,
|
|
253
|
+
url: options.visibility === "public" ? `memory://${options.key}` : undefined,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async download(key: string): Promise<DownloadResult | null> {
|
|
258
|
+
const file = this.files.get(key);
|
|
259
|
+
if (!file) return null;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
body: new ReadableStream({
|
|
263
|
+
start(controller) {
|
|
264
|
+
controller.enqueue(file.body);
|
|
265
|
+
controller.close();
|
|
266
|
+
},
|
|
267
|
+
}),
|
|
268
|
+
size: file.body.byteLength,
|
|
269
|
+
contentType: file.contentType,
|
|
270
|
+
lastModified: file.lastModified,
|
|
271
|
+
metadata: file.metadata,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async delete(key: string): Promise<boolean> {
|
|
276
|
+
return this.files.delete(key);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }> {
|
|
280
|
+
const deleted: string[] = [];
|
|
281
|
+
const errors: string[] = [];
|
|
282
|
+
|
|
283
|
+
for (const key of keys) {
|
|
284
|
+
if (this.files.delete(key)) {
|
|
285
|
+
deleted.push(key);
|
|
286
|
+
} else {
|
|
287
|
+
errors.push(key);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { deleted, errors };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async list(options: ListOptions = {}): Promise<ListResult> {
|
|
295
|
+
const { prefix = "", limit = 1000, cursor, delimiter } = options;
|
|
296
|
+
|
|
297
|
+
let keys = Array.from(this.files.keys());
|
|
298
|
+
|
|
299
|
+
// Filter by prefix
|
|
300
|
+
if (prefix) {
|
|
301
|
+
keys = keys.filter((key) => key.startsWith(prefix));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Sort for consistent pagination
|
|
305
|
+
keys.sort();
|
|
306
|
+
|
|
307
|
+
// Apply cursor (simple offset-based)
|
|
308
|
+
if (cursor) {
|
|
309
|
+
const cursorIndex = keys.findIndex((key) => key > cursor);
|
|
310
|
+
if (cursorIndex === -1) {
|
|
311
|
+
return { files: [], prefixes: [], cursor: null, hasMore: false };
|
|
312
|
+
}
|
|
313
|
+
keys = keys.slice(cursorIndex);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Handle delimiter for hierarchical listing
|
|
317
|
+
const prefixes: string[] = [];
|
|
318
|
+
if (delimiter) {
|
|
319
|
+
const prefixSet = new Set<string>();
|
|
320
|
+
const fileKeys: string[] = [];
|
|
321
|
+
|
|
322
|
+
for (const key of keys) {
|
|
323
|
+
const relativePath = prefix ? key.slice(prefix.length) : key;
|
|
324
|
+
const delimiterIndex = relativePath.indexOf(delimiter);
|
|
325
|
+
|
|
326
|
+
if (delimiterIndex !== -1) {
|
|
327
|
+
// This is a "directory" - add to prefixes
|
|
328
|
+
const commonPrefix = prefix + relativePath.slice(0, delimiterIndex + 1);
|
|
329
|
+
prefixSet.add(commonPrefix);
|
|
330
|
+
} else {
|
|
331
|
+
// This is a file at this level
|
|
332
|
+
fileKeys.push(key);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
keys = fileKeys;
|
|
337
|
+
prefixes.push(...Array.from(prefixSet).sort());
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Apply limit
|
|
341
|
+
const hasMore = keys.length > limit;
|
|
342
|
+
const resultKeys = keys.slice(0, limit);
|
|
343
|
+
const nextCursor = hasMore ? resultKeys[resultKeys.length - 1] : null;
|
|
344
|
+
|
|
345
|
+
// Build file list
|
|
346
|
+
const files: StorageFile[] = resultKeys.map((key) => {
|
|
347
|
+
const file = this.files.get(key)!;
|
|
348
|
+
return {
|
|
349
|
+
key,
|
|
350
|
+
size: file.body.byteLength,
|
|
351
|
+
contentType: file.contentType,
|
|
352
|
+
lastModified: file.lastModified,
|
|
353
|
+
metadata: file.metadata,
|
|
354
|
+
visibility: file.visibility,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
files,
|
|
360
|
+
prefixes,
|
|
361
|
+
cursor: nextCursor,
|
|
362
|
+
hasMore,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async head(key: string): Promise<StorageFile | null> {
|
|
367
|
+
const file = this.files.get(key);
|
|
368
|
+
if (!file) return null;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
key,
|
|
372
|
+
size: file.body.byteLength,
|
|
373
|
+
contentType: file.contentType,
|
|
374
|
+
lastModified: file.lastModified,
|
|
375
|
+
metadata: file.metadata,
|
|
376
|
+
visibility: file.visibility,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async exists(key: string): Promise<boolean> {
|
|
381
|
+
return this.files.has(key);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async getUrl(key: string, options: GetUrlOptions = {}): Promise<string> {
|
|
385
|
+
const file = this.files.get(key);
|
|
386
|
+
if (!file) {
|
|
387
|
+
throw new Error(`File not found: ${key}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// For memory adapter, just return a memory:// URL
|
|
391
|
+
let url = `memory://${key}`;
|
|
392
|
+
|
|
393
|
+
if (options.download) {
|
|
394
|
+
const filename = typeof options.download === "string" ? options.download : key.split("/").pop();
|
|
395
|
+
url += `?download=${encodeURIComponent(filename || "file")}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return url;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async copy(options: CopyOptions): Promise<UploadResult> {
|
|
402
|
+
const sourceFile = this.files.get(options.source);
|
|
403
|
+
if (!sourceFile) {
|
|
404
|
+
throw new Error(`Source file not found: ${options.source}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const newFile: MemoryFile = {
|
|
408
|
+
...sourceFile,
|
|
409
|
+
metadata: options.metadata ?? sourceFile.metadata,
|
|
410
|
+
visibility: options.visibility ?? sourceFile.visibility,
|
|
411
|
+
lastModified: new Date(),
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
this.files.set(options.destination, newFile);
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
key: options.destination,
|
|
418
|
+
size: newFile.body.byteLength,
|
|
419
|
+
url: newFile.visibility === "public" ? `memory://${options.destination}` : undefined,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
stop(): void {
|
|
424
|
+
// Nothing to clean up for memory adapter
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Helper to convert various body types to Uint8Array */
|
|
428
|
+
private async toUint8Array(
|
|
429
|
+
body: Buffer | Uint8Array | string | Blob | ReadableStream<Uint8Array>
|
|
430
|
+
): Promise<Uint8Array> {
|
|
431
|
+
if (body instanceof Uint8Array) {
|
|
432
|
+
return body;
|
|
433
|
+
}
|
|
434
|
+
if (typeof body === "string") {
|
|
435
|
+
return new TextEncoder().encode(body);
|
|
436
|
+
}
|
|
437
|
+
if (body instanceof Blob) {
|
|
438
|
+
const buffer = await body.arrayBuffer();
|
|
439
|
+
return new Uint8Array(buffer);
|
|
440
|
+
}
|
|
441
|
+
if (body instanceof ReadableStream) {
|
|
442
|
+
const reader = body.getReader();
|
|
443
|
+
const chunks: Uint8Array[] = [];
|
|
444
|
+
while (true) {
|
|
445
|
+
const { done, value } = await reader.read();
|
|
446
|
+
if (done) break;
|
|
447
|
+
chunks.push(value);
|
|
448
|
+
}
|
|
449
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
450
|
+
const result = new Uint8Array(totalLength);
|
|
451
|
+
let offset = 0;
|
|
452
|
+
for (const chunk of chunks) {
|
|
453
|
+
result.set(chunk, offset);
|
|
454
|
+
offset += chunk.byteLength;
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
458
|
+
// Buffer (Node.js)
|
|
459
|
+
return new Uint8Array(body);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// =============================================================================
|
|
464
|
+
// STORAGE IMPLEMENTATION
|
|
465
|
+
// =============================================================================
|
|
466
|
+
|
|
467
|
+
class StorageImpl implements Storage {
|
|
468
|
+
private adapter: StorageAdapter;
|
|
469
|
+
|
|
470
|
+
constructor(adapter: StorageAdapter) {
|
|
471
|
+
this.adapter = adapter;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async upload(options: UploadOptions): Promise<UploadResult> {
|
|
475
|
+
return this.adapter.upload(options);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async download(key: string): Promise<DownloadResult | null> {
|
|
479
|
+
return this.adapter.download(key);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async delete(key: string): Promise<boolean> {
|
|
483
|
+
return this.adapter.delete(key);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }> {
|
|
487
|
+
return this.adapter.deleteMany(keys);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async list(options?: ListOptions): Promise<ListResult> {
|
|
491
|
+
return this.adapter.list(options);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async head(key: string): Promise<StorageFile | null> {
|
|
495
|
+
return this.adapter.head(key);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async exists(key: string): Promise<boolean> {
|
|
499
|
+
return this.adapter.exists(key);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async getUrl(key: string, options?: GetUrlOptions): Promise<string> {
|
|
503
|
+
return this.adapter.getUrl(key, options);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async copy(options: CopyOptions): Promise<UploadResult> {
|
|
507
|
+
return this.adapter.copy(options);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async move(source: string, destination: string): Promise<UploadResult> {
|
|
511
|
+
const result = await this.adapter.copy({ source, destination });
|
|
512
|
+
await this.adapter.delete(source);
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
stop(): void {
|
|
517
|
+
this.adapter.stop();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// =============================================================================
|
|
522
|
+
// FACTORY
|
|
523
|
+
// =============================================================================
|
|
524
|
+
|
|
525
|
+
import { LocalStorageAdapter } from "./storage-adapter-local";
|
|
526
|
+
import { S3StorageAdapter } from "./storage-adapter-s3";
|
|
527
|
+
|
|
528
|
+
/** Create a storage service with the specified configuration */
|
|
529
|
+
export function createStorage(config?: StorageConfig): Storage {
|
|
530
|
+
let adapter: StorageAdapter;
|
|
531
|
+
|
|
532
|
+
if (!config || config.provider === "memory") {
|
|
533
|
+
adapter = new MemoryStorageAdapter();
|
|
534
|
+
} else if (config.provider === "local") {
|
|
535
|
+
adapter = new LocalStorageAdapter(config);
|
|
536
|
+
} else if (config.provider === "s3") {
|
|
537
|
+
adapter = new S3StorageAdapter(config);
|
|
538
|
+
} else {
|
|
539
|
+
throw new Error(`Unknown storage provider: ${(config as any).provider}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return new StorageImpl(adapter);
|
|
543
|
+
}
|
package/src/core/websocket.ts
CHANGED
|
@@ -53,8 +53,10 @@ export interface WebSocketService {
|
|
|
53
53
|
unsubscribe(clientId: string, channel: string): boolean;
|
|
54
54
|
/** Register a message handler */
|
|
55
55
|
onMessage(handler: WebSocketMessageHandler): void;
|
|
56
|
-
/** Get all
|
|
57
|
-
|
|
56
|
+
/** Get all client IDs in a channel (or all if no channel) */
|
|
57
|
+
getClientIds(channel?: string): string[];
|
|
58
|
+
/** Get all clients with metadata (for admin dashboard) */
|
|
59
|
+
getClients(): Array<{ id: string; connectedAt: Date; channels: string[] }>;
|
|
58
60
|
/** Get client count */
|
|
59
61
|
getClientCount(channel?: string): number;
|
|
60
62
|
/** Check if a client is connected */
|
|
@@ -195,7 +197,7 @@ class WebSocketServiceImpl implements WebSocketService {
|
|
|
195
197
|
this.messageHandlers.push(handler);
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
|
|
200
|
+
getClientIds(channel?: string): string[] {
|
|
199
201
|
if (channel) {
|
|
200
202
|
const channelClients = this.channels.get(channel);
|
|
201
203
|
return channelClients ? Array.from(channelClients) : [];
|
|
@@ -203,6 +205,14 @@ class WebSocketServiceImpl implements WebSocketService {
|
|
|
203
205
|
return Array.from(this.clients.keys());
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
getClients(): Array<{ id: string; connectedAt: Date; channels: string[] }> {
|
|
209
|
+
return Array.from(this.clients.values()).map((client) => ({
|
|
210
|
+
id: client.id,
|
|
211
|
+
connectedAt: client.connectedAt,
|
|
212
|
+
channels: Array.from(client.channels),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
206
216
|
getClientCount(channel?: string): number {
|
|
207
217
|
if (channel) {
|
|
208
218
|
return this.channels.get(channel)?.size ?? 0;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Kysely } from "kysely";
|
|
9
|
-
import type { WorkflowAdapter, WorkflowInstance, WorkflowStatus, StepResult } from "./workflows";
|
|
9
|
+
import type { WorkflowAdapter, WorkflowInstance, WorkflowStatus, StepResult, GetAllWorkflowsOptions } from "./workflows";
|
|
10
10
|
|
|
11
11
|
export interface KyselyWorkflowAdapterConfig {
|
|
12
12
|
/** Auto-cleanup completed workflows older than N days (default: 30, 0 to disable) */
|
|
@@ -176,6 +176,27 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
176
176
|
return rows.map((r) => this.rowToInstance(r));
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
async getAllInstances(options: GetAllWorkflowsOptions = {}): Promise<WorkflowInstance[]> {
|
|
180
|
+
const { status, workflowName, limit = 100, offset = 0 } = options;
|
|
181
|
+
|
|
182
|
+
let query = this.db.selectFrom("__donkeylabs_workflow_instances__").selectAll();
|
|
183
|
+
|
|
184
|
+
if (status) {
|
|
185
|
+
query = query.where("status", "=", status);
|
|
186
|
+
}
|
|
187
|
+
if (workflowName) {
|
|
188
|
+
query = query.where("workflow_name", "=", workflowName);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const rows = await query
|
|
192
|
+
.orderBy("created_at", "desc")
|
|
193
|
+
.limit(limit)
|
|
194
|
+
.offset(offset)
|
|
195
|
+
.execute();
|
|
196
|
+
|
|
197
|
+
return rows.map((r) => this.rowToInstance(r));
|
|
198
|
+
}
|
|
199
|
+
|
|
179
200
|
private rowToInstance(row: WorkflowInstancesTable): WorkflowInstance {
|
|
180
201
|
// Parse step results with proper Date handling
|
|
181
202
|
const rawStepResults = JSON.parse(row.step_results);
|
package/src/core/workflows.ts
CHANGED
|
@@ -201,6 +201,18 @@ export interface WorkflowContext {
|
|
|
201
201
|
// Workflow Adapter (Persistence)
|
|
202
202
|
// ============================================
|
|
203
203
|
|
|
204
|
+
/** Options for listing all workflow instances */
|
|
205
|
+
export interface GetAllWorkflowsOptions {
|
|
206
|
+
/** Filter by status */
|
|
207
|
+
status?: WorkflowStatus;
|
|
208
|
+
/** Filter by workflow name */
|
|
209
|
+
workflowName?: string;
|
|
210
|
+
/** Max number of instances to return (default: 100) */
|
|
211
|
+
limit?: number;
|
|
212
|
+
/** Skip first N instances (for pagination) */
|
|
213
|
+
offset?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
204
216
|
export interface WorkflowAdapter {
|
|
205
217
|
createInstance(instance: Omit<WorkflowInstance, "id">): Promise<WorkflowInstance>;
|
|
206
218
|
getInstance(instanceId: string): Promise<WorkflowInstance | null>;
|
|
@@ -208,6 +220,8 @@ export interface WorkflowAdapter {
|
|
|
208
220
|
deleteInstance(instanceId: string): Promise<boolean>;
|
|
209
221
|
getInstancesByWorkflow(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
|
|
210
222
|
getRunningInstances(): Promise<WorkflowInstance[]>;
|
|
223
|
+
/** Get all workflow instances with optional filtering (for admin dashboard) */
|
|
224
|
+
getAllInstances(options?: GetAllWorkflowsOptions): Promise<WorkflowInstance[]>;
|
|
211
225
|
}
|
|
212
226
|
|
|
213
227
|
// In-memory adapter
|
|
@@ -261,6 +275,23 @@ export class MemoryWorkflowAdapter implements WorkflowAdapter {
|
|
|
261
275
|
}
|
|
262
276
|
return results;
|
|
263
277
|
}
|
|
278
|
+
|
|
279
|
+
async getAllInstances(options: GetAllWorkflowsOptions = {}): Promise<WorkflowInstance[]> {
|
|
280
|
+
const { status, workflowName, limit = 100, offset = 0 } = options;
|
|
281
|
+
const results: WorkflowInstance[] = [];
|
|
282
|
+
|
|
283
|
+
for (const instance of this.instances.values()) {
|
|
284
|
+
if (status && instance.status !== status) continue;
|
|
285
|
+
if (workflowName && instance.workflowName !== workflowName) continue;
|
|
286
|
+
results.push(instance);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Sort by createdAt descending (newest first)
|
|
290
|
+
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
291
|
+
|
|
292
|
+
// Apply pagination
|
|
293
|
+
return results.slice(offset, offset + limit);
|
|
294
|
+
}
|
|
264
295
|
}
|
|
265
296
|
|
|
266
297
|
// ============================================
|
|
@@ -519,6 +550,8 @@ export interface Workflows {
|
|
|
519
550
|
cancel(instanceId: string): Promise<boolean>;
|
|
520
551
|
/** Get all instances of a workflow */
|
|
521
552
|
getInstances(workflowName: string, status?: WorkflowStatus): Promise<WorkflowInstance[]>;
|
|
553
|
+
/** Get all workflow instances with optional filtering (for admin dashboard) */
|
|
554
|
+
getAllInstances(options?: GetAllWorkflowsOptions): Promise<WorkflowInstance[]>;
|
|
522
555
|
/** Resume workflows after server restart */
|
|
523
556
|
resume(): Promise<void>;
|
|
524
557
|
/** Stop the workflow service */
|
|
@@ -624,6 +657,10 @@ class WorkflowsImpl implements Workflows {
|
|
|
624
657
|
return this.adapter.getInstancesByWorkflow(workflowName, status);
|
|
625
658
|
}
|
|
626
659
|
|
|
660
|
+
async getAllInstances(options?: GetAllWorkflowsOptions): Promise<WorkflowInstance[]> {
|
|
661
|
+
return this.adapter.getAllInstances(options);
|
|
662
|
+
}
|
|
663
|
+
|
|
627
664
|
async resume(): Promise<void> {
|
|
628
665
|
const running = await this.adapter.getRunningInstances();
|
|
629
666
|
|