@donkeylabs/server 2.0.37 → 2.1.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.
- package/docs/code-organization.md +424 -0
- package/docs/project-structure.md +37 -26
- package/docs/swift-adapter.md +293 -0
- package/docs/versioning.md +351 -0
- package/package.json +1 -1
- package/src/client/base.ts +18 -5
- package/src/core/events.ts +54 -7
- package/src/core/health.ts +165 -0
- package/src/core/index.ts +12 -0
- package/src/core/jobs.ts +11 -4
- package/src/core/subprocess-bootstrap.ts +3 -0
- package/src/core/workflow-executor.ts +1 -0
- package/src/core/workflow-state-machine.ts +6 -5
- package/src/core/workflows.ts +3 -2
- package/src/core.ts +9 -1
- package/src/generator/index.ts +4 -0
- package/src/harness.ts +17 -5
- package/src/index.ts +21 -0
- package/src/router.ts +22 -2
- package/src/server.ts +458 -117
- package/src/versioning.ts +154 -0
package/src/core/events.ts
CHANGED
|
@@ -11,20 +11,33 @@ export interface Subscription {
|
|
|
11
11
|
unsubscribe(): void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface EventMetadata {
|
|
15
|
+
traceId?: string;
|
|
16
|
+
source?: string;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export interface EventRecord {
|
|
15
21
|
event: string;
|
|
16
22
|
data: any;
|
|
17
23
|
timestamp: Date;
|
|
24
|
+
metadata?: EventMetadata;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface EventAdapter {
|
|
21
|
-
publish(event: string, data: any): Promise<void>;
|
|
28
|
+
publish(event: string, data: any, metadata?: EventMetadata): Promise<void>;
|
|
22
29
|
getHistory(event: string, limit?: number): Promise<EventRecord[]>;
|
|
30
|
+
/** Subscribe to events from other instances (for distributed adapters) */
|
|
31
|
+
subscribe?(callback: (event: string, data: any, metadata?: EventMetadata) => void): Promise<void>;
|
|
32
|
+
/** Stop the adapter and clean up resources */
|
|
33
|
+
stop?(): Promise<void>;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export interface EventsConfig {
|
|
26
37
|
adapter?: EventAdapter;
|
|
27
38
|
maxHistorySize?: number;
|
|
39
|
+
/** SSE service for auto-propagating distributed events to SSE clients */
|
|
40
|
+
sse?: import("./sse").SSE;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
/**
|
|
@@ -41,11 +54,11 @@ export interface Events {
|
|
|
41
54
|
/**
|
|
42
55
|
* Emit a typed event (when EventRegistry is augmented)
|
|
43
56
|
*/
|
|
44
|
-
emit<K extends keyof EventRegistry>(event: K, data: EventRegistry[K]): Promise<void>;
|
|
57
|
+
emit<K extends keyof EventRegistry>(event: K, data: EventRegistry[K], metadata?: EventMetadata): Promise<void>;
|
|
45
58
|
/**
|
|
46
59
|
* Emit an untyped event (fallback for dynamic event names)
|
|
47
60
|
*/
|
|
48
|
-
emit<T = any>(event: string, data: T): Promise<void>;
|
|
61
|
+
emit<T = any>(event: string, data: T, metadata?: EventMetadata): Promise<void>;
|
|
49
62
|
|
|
50
63
|
/**
|
|
51
64
|
* Subscribe to a typed event (when EventRegistry is augmented)
|
|
@@ -67,6 +80,8 @@ export interface Events {
|
|
|
67
80
|
|
|
68
81
|
off(event: string, handler?: EventHandler): void;
|
|
69
82
|
getHistory(event: string, limit?: number): Promise<EventRecord[]>;
|
|
83
|
+
/** Stop the event bus and clean up resources */
|
|
84
|
+
stop(): Promise<void>;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
// In-memory event adapter with history
|
|
@@ -78,11 +93,12 @@ export class MemoryEventAdapter implements EventAdapter {
|
|
|
78
93
|
this.maxHistorySize = maxHistorySize;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
async publish(event: string, data: any): Promise<void> {
|
|
96
|
+
async publish(event: string, data: any, metadata?: EventMetadata): Promise<void> {
|
|
82
97
|
const record: EventRecord = {
|
|
83
98
|
event,
|
|
84
99
|
data,
|
|
85
100
|
timestamp: new Date(),
|
|
101
|
+
metadata,
|
|
86
102
|
};
|
|
87
103
|
|
|
88
104
|
this.history.push(record);
|
|
@@ -103,16 +119,41 @@ export class MemoryEventAdapter implements EventAdapter {
|
|
|
103
119
|
class EventsImpl implements Events {
|
|
104
120
|
private handlers = new Map<string, Set<EventHandler>>();
|
|
105
121
|
private adapter: EventAdapter;
|
|
122
|
+
private sse?: import("./sse").SSE;
|
|
123
|
+
private stopped = false;
|
|
106
124
|
|
|
107
125
|
constructor(config: EventsConfig = {}) {
|
|
108
126
|
this.adapter = config.adapter ?? new MemoryEventAdapter(config.maxHistorySize);
|
|
127
|
+
this.sse = config.sse;
|
|
128
|
+
|
|
129
|
+
// If adapter supports distributed subscriptions, set up the callback
|
|
130
|
+
if (this.adapter.subscribe) {
|
|
131
|
+
this.adapter.subscribe((event, data, metadata) => {
|
|
132
|
+
// Dispatch to local handlers without re-publishing to adapter
|
|
133
|
+
this.dispatchToLocalHandlers(event, data, metadata);
|
|
134
|
+
// Propagate to SSE clients so browser subscribers see distributed events
|
|
135
|
+
this.sse?.broadcastAll(event, data);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
109
138
|
}
|
|
110
139
|
|
|
111
|
-
async emit<T = any>(event: string, data: T): Promise<void> {
|
|
140
|
+
async emit<T = any>(event: string, data: T, metadata?: EventMetadata): Promise<void> {
|
|
141
|
+
if (this.stopped) return;
|
|
142
|
+
|
|
112
143
|
// Store in adapter (for history/persistence)
|
|
113
|
-
await this.adapter.publish(event, data);
|
|
144
|
+
await this.adapter.publish(event, data, metadata);
|
|
145
|
+
|
|
146
|
+
// Dispatch to local handlers
|
|
147
|
+
await this.dispatchToLocalHandlers(event, data, metadata);
|
|
148
|
+
}
|
|
114
149
|
|
|
115
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Dispatch an event to locally registered handlers.
|
|
152
|
+
* Separated from emit() so distributed adapters can deliver remote events
|
|
153
|
+
* without re-publishing to the adapter.
|
|
154
|
+
*/
|
|
155
|
+
private async dispatchToLocalHandlers(event: string, data: any, _metadata?: EventMetadata): Promise<void> {
|
|
156
|
+
// Notify exact-match handlers
|
|
116
157
|
const eventHandlers = this.handlers.get(event);
|
|
117
158
|
if (eventHandlers) {
|
|
118
159
|
const promises: Promise<void>[] = [];
|
|
@@ -185,6 +226,12 @@ class EventsImpl implements Events {
|
|
|
185
226
|
return this.adapter.getHistory(event, limit);
|
|
186
227
|
}
|
|
187
228
|
|
|
229
|
+
async stop(): Promise<void> {
|
|
230
|
+
this.stopped = true;
|
|
231
|
+
await this.adapter.stop?.();
|
|
232
|
+
this.handlers.clear();
|
|
233
|
+
}
|
|
234
|
+
|
|
188
235
|
private matchPattern(event: string, pattern: string): boolean {
|
|
189
236
|
// Convert glob pattern to regex (e.g., "user.*" -> /^user\..*$/)
|
|
190
237
|
const regex = new RegExp(
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Core Health Check Service
|
|
2
|
+
// Provides liveness and readiness probes for production deployments
|
|
3
|
+
|
|
4
|
+
import type { Kysely } from "kysely";
|
|
5
|
+
|
|
6
|
+
export type HealthStatus = "healthy" | "degraded" | "unhealthy";
|
|
7
|
+
|
|
8
|
+
export interface HealthCheckResult {
|
|
9
|
+
status: HealthStatus;
|
|
10
|
+
message?: string;
|
|
11
|
+
latencyMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HealthCheck {
|
|
15
|
+
name: string;
|
|
16
|
+
check: () => Promise<HealthCheckResult> | HealthCheckResult;
|
|
17
|
+
/** Whether failure of this check marks the service as unhealthy (default: true) */
|
|
18
|
+
critical?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HealthResponse {
|
|
22
|
+
status: HealthStatus;
|
|
23
|
+
uptime: number;
|
|
24
|
+
timestamp: string;
|
|
25
|
+
checks: Record<string, HealthCheckResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HealthConfig {
|
|
29
|
+
checks?: HealthCheck[];
|
|
30
|
+
/** Path for liveness probe (default: "/_health") */
|
|
31
|
+
livenessPath?: string;
|
|
32
|
+
/** Path for readiness probe (default: "/_ready") */
|
|
33
|
+
readinessPath?: string;
|
|
34
|
+
/** Timeout per check in ms (default: 5000) */
|
|
35
|
+
checkTimeout?: number;
|
|
36
|
+
/** Whether to register a built-in database check (default: true) */
|
|
37
|
+
dbCheck?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Health {
|
|
41
|
+
/** Register a health check */
|
|
42
|
+
register(check: HealthCheck): void;
|
|
43
|
+
/** Run all readiness checks */
|
|
44
|
+
check(): Promise<HealthResponse>;
|
|
45
|
+
/** Fast liveness probe (no external checks) */
|
|
46
|
+
liveness(isShuttingDown: boolean): HealthResponse;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class HealthImpl implements Health {
|
|
50
|
+
private checks: HealthCheck[] = [];
|
|
51
|
+
private startTime = Date.now();
|
|
52
|
+
private checkTimeout: number;
|
|
53
|
+
|
|
54
|
+
constructor(config: HealthConfig = {}) {
|
|
55
|
+
this.checkTimeout = config.checkTimeout ?? 5000;
|
|
56
|
+
|
|
57
|
+
if (config.checks) {
|
|
58
|
+
for (const check of config.checks) {
|
|
59
|
+
this.checks.push(check);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
register(check: HealthCheck): void {
|
|
65
|
+
this.checks.push(check);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async check(): Promise<HealthResponse> {
|
|
69
|
+
const results: Record<string, HealthCheckResult> = {};
|
|
70
|
+
let overallStatus: HealthStatus = "healthy";
|
|
71
|
+
|
|
72
|
+
await Promise.all(
|
|
73
|
+
this.checks.map(async (check) => {
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
try {
|
|
76
|
+
const result = await Promise.race([
|
|
77
|
+
Promise.resolve(check.check()),
|
|
78
|
+
new Promise<HealthCheckResult>((_, reject) =>
|
|
79
|
+
setTimeout(() => reject(new Error("Health check timed out")), this.checkTimeout)
|
|
80
|
+
),
|
|
81
|
+
]);
|
|
82
|
+
results[check.name] = {
|
|
83
|
+
...result,
|
|
84
|
+
latencyMs: result.latencyMs ?? Date.now() - start,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isCritical = check.critical !== false;
|
|
88
|
+
if (result.status === "unhealthy" && isCritical) {
|
|
89
|
+
overallStatus = "unhealthy";
|
|
90
|
+
} else if (result.status === "degraded" && overallStatus !== "unhealthy") {
|
|
91
|
+
overallStatus = "degraded";
|
|
92
|
+
} else if (result.status === "unhealthy" && !isCritical && overallStatus !== "unhealthy") {
|
|
93
|
+
overallStatus = "degraded";
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
results[check.name] = {
|
|
98
|
+
status: "unhealthy",
|
|
99
|
+
message,
|
|
100
|
+
latencyMs: Date.now() - start,
|
|
101
|
+
};
|
|
102
|
+
const isCritical = check.critical !== false;
|
|
103
|
+
if (isCritical) {
|
|
104
|
+
overallStatus = "unhealthy";
|
|
105
|
+
} else if (overallStatus !== "unhealthy") {
|
|
106
|
+
overallStatus = "degraded";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
status: overallStatus,
|
|
114
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
checks: results,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
liveness(isShuttingDown: boolean): HealthResponse {
|
|
121
|
+
return {
|
|
122
|
+
status: isShuttingDown ? "unhealthy" : "healthy",
|
|
123
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
checks: {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a built-in database health check.
|
|
132
|
+
*/
|
|
133
|
+
export function createDbHealthCheck(db: Kysely<any>): HealthCheck {
|
|
134
|
+
return {
|
|
135
|
+
name: "database",
|
|
136
|
+
critical: true,
|
|
137
|
+
check: async () => {
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
try {
|
|
140
|
+
await db.selectFrom(db.dynamic.ref("sqlite_master") as any)
|
|
141
|
+
.select(db.dynamic.ref("1") as any)
|
|
142
|
+
.execute()
|
|
143
|
+
.catch(async () => {
|
|
144
|
+
// Fallback for non-SQLite databases
|
|
145
|
+
const { sql } = await import("kysely");
|
|
146
|
+
await sql`SELECT 1`.execute(db);
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
status: "healthy" as const,
|
|
150
|
+
latencyMs: Date.now() - start,
|
|
151
|
+
};
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return {
|
|
154
|
+
status: "unhealthy" as const,
|
|
155
|
+
message: err instanceof Error ? err.message : String(err),
|
|
156
|
+
latencyMs: Date.now() - start,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createHealth(config?: HealthConfig): Health {
|
|
164
|
+
return new HealthImpl(config);
|
|
165
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export {
|
|
|
21
21
|
export {
|
|
22
22
|
type Events,
|
|
23
23
|
type EventHandler,
|
|
24
|
+
type EventMetadata,
|
|
24
25
|
type Subscription,
|
|
25
26
|
type EventRecord,
|
|
26
27
|
type EventAdapter,
|
|
@@ -302,3 +303,14 @@ export {
|
|
|
302
303
|
PersistentTransport,
|
|
303
304
|
type PersistentTransportConfig,
|
|
304
305
|
} from "./logs-transport";
|
|
306
|
+
|
|
307
|
+
export {
|
|
308
|
+
type Health,
|
|
309
|
+
type HealthCheck,
|
|
310
|
+
type HealthCheckResult,
|
|
311
|
+
type HealthConfig,
|
|
312
|
+
type HealthResponse,
|
|
313
|
+
type HealthStatus,
|
|
314
|
+
createHealth,
|
|
315
|
+
createDbHealthCheck,
|
|
316
|
+
} from "./health";
|
package/src/core/jobs.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface Job {
|
|
|
56
56
|
lastHeartbeat?: Date;
|
|
57
57
|
/** Current process state */
|
|
58
58
|
processState?: ExternalJobProcessState;
|
|
59
|
+
/** Trace ID for distributed tracing */
|
|
60
|
+
traceId?: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
export interface JobHandler<T = any, R = any> {
|
|
@@ -66,6 +68,8 @@ export interface JobHandlerContext {
|
|
|
66
68
|
logger?: Logger;
|
|
67
69
|
emit?: (event: string, data?: Record<string, any>) => Promise<void>;
|
|
68
70
|
log?: (level: LogLevel, message: string, data?: Record<string, any>) => void;
|
|
71
|
+
/** Trace ID for distributed tracing */
|
|
72
|
+
traceId?: string;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
/** Options for listing all jobs */
|
|
@@ -128,9 +132,9 @@ export interface Jobs {
|
|
|
128
132
|
/** Register an external job (Python, Go, Shell, etc.) */
|
|
129
133
|
registerExternal(name: string, config: ExternalJobConfig): void;
|
|
130
134
|
/** Enqueue a job (works for both in-process and external jobs) */
|
|
131
|
-
enqueue<T = any>(name: string, data: T, options?: { maxAttempts?: number }): Promise<string>;
|
|
135
|
+
enqueue<T = any>(name: string, data: T, options?: { maxAttempts?: number; traceId?: string }): Promise<string>;
|
|
132
136
|
/** Schedule a job to run at a specific time */
|
|
133
|
-
schedule<T = any>(name: string, data: T, runAt: Date, options?: { maxAttempts?: number }): Promise<string>;
|
|
137
|
+
schedule<T = any>(name: string, data: T, runAt: Date, options?: { maxAttempts?: number; traceId?: string }): Promise<string>;
|
|
134
138
|
/** Get a job by ID */
|
|
135
139
|
get(jobId: string): Promise<Job | null>;
|
|
136
140
|
/** Cancel a pending job */
|
|
@@ -339,7 +343,7 @@ class JobsImpl implements Jobs {
|
|
|
339
343
|
return this.externalConfigs.has(name);
|
|
340
344
|
}
|
|
341
345
|
|
|
342
|
-
async enqueue<T = any>(name: string, data: T, options: { maxAttempts?: number } = {}): Promise<string> {
|
|
346
|
+
async enqueue<T = any>(name: string, data: T, options: { maxAttempts?: number; traceId?: string } = {}): Promise<string> {
|
|
343
347
|
const isExternal = this.isExternalJob(name);
|
|
344
348
|
|
|
345
349
|
if (!isExternal && !this.handlers.has(name)) {
|
|
@@ -355,6 +359,7 @@ class JobsImpl implements Jobs {
|
|
|
355
359
|
maxAttempts: options.maxAttempts ?? this.defaultMaxAttempts,
|
|
356
360
|
external: isExternal || undefined,
|
|
357
361
|
processState: isExternal ? "spawning" : undefined,
|
|
362
|
+
traceId: options.traceId,
|
|
358
363
|
});
|
|
359
364
|
|
|
360
365
|
return job.id;
|
|
@@ -364,7 +369,7 @@ class JobsImpl implements Jobs {
|
|
|
364
369
|
name: string,
|
|
365
370
|
data: T,
|
|
366
371
|
runAt: Date,
|
|
367
|
-
options: { maxAttempts?: number } = {}
|
|
372
|
+
options: { maxAttempts?: number; traceId?: string } = {}
|
|
368
373
|
): Promise<string> {
|
|
369
374
|
const isExternal = this.isExternalJob(name);
|
|
370
375
|
|
|
@@ -382,6 +387,7 @@ class JobsImpl implements Jobs {
|
|
|
382
387
|
maxAttempts: options.maxAttempts ?? this.defaultMaxAttempts,
|
|
383
388
|
external: isExternal || undefined,
|
|
384
389
|
processState: isExternal ? "spawning" : undefined,
|
|
390
|
+
traceId: options.traceId,
|
|
385
391
|
});
|
|
386
392
|
|
|
387
393
|
return job.id;
|
|
@@ -1147,6 +1153,7 @@ class JobsImpl implements Jobs {
|
|
|
1147
1153
|
logger: scopedLogger,
|
|
1148
1154
|
emit,
|
|
1149
1155
|
log,
|
|
1156
|
+
traceId: job.traceId,
|
|
1150
1157
|
});
|
|
1151
1158
|
|
|
1152
1159
|
await this.adapter.update(job.id, {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
KyselyWorkflowAdapter,
|
|
21
21
|
MemoryAuditAdapter,
|
|
22
22
|
MemoryLogsAdapter,
|
|
23
|
+
createHealth,
|
|
23
24
|
} from "./index";
|
|
24
25
|
import { PluginManager, type CoreServices, type ConfiguredPlugin } from "../core";
|
|
25
26
|
|
|
@@ -96,6 +97,7 @@ export async function bootstrapSubprocess(
|
|
|
96
97
|
const audit = createAudit({ adapter: auditAdapter });
|
|
97
98
|
const websocket = createWebSocket();
|
|
98
99
|
const storage = createStorage();
|
|
100
|
+
const health = createHealth({ dbCheck: false });
|
|
99
101
|
|
|
100
102
|
const core: CoreServices = {
|
|
101
103
|
db,
|
|
@@ -114,6 +116,7 @@ export async function bootstrapSubprocess(
|
|
|
114
116
|
websocket,
|
|
115
117
|
storage,
|
|
116
118
|
logs,
|
|
119
|
+
health,
|
|
117
120
|
};
|
|
118
121
|
|
|
119
122
|
workflows.setCore(core);
|
|
@@ -751,12 +751,13 @@ export class WorkflowStateMachine {
|
|
|
751
751
|
|
|
752
752
|
this.events.onStepCompleted(instanceId, stepName, output, nextStep);
|
|
753
753
|
|
|
754
|
-
// Calculate progress
|
|
754
|
+
// Calculate progress — re-fetch after persist to get accurate count
|
|
755
|
+
const updated = await this.adapter.getInstance(instanceId);
|
|
755
756
|
const totalSteps = definition.steps.size;
|
|
756
|
-
const completedSteps =
|
|
757
|
-
(r) => r.status === "completed"
|
|
758
|
-
|
|
759
|
-
const progress = Math.round((completedSteps / totalSteps) * 100);
|
|
757
|
+
const completedSteps = updated
|
|
758
|
+
? Object.values(updated.stepResults).filter((r) => r.status === "completed").length
|
|
759
|
+
: 1;
|
|
760
|
+
const progress = Math.min(100, Math.round((completedSteps / totalSteps) * 100));
|
|
760
761
|
|
|
761
762
|
this.events.onProgress(instanceId, progress, stepName, completedSteps, totalSteps);
|
|
762
763
|
}
|
package/src/core/workflows.ts
CHANGED
|
@@ -1762,20 +1762,21 @@ class WorkflowsImpl implements Workflows {
|
|
|
1762
1762
|
await this.emitEvent("workflow.progress", {
|
|
1763
1763
|
instanceId,
|
|
1764
1764
|
progress: event.progress,
|
|
1765
|
+
currentStep: event.stepName,
|
|
1765
1766
|
completedSteps: event.completedSteps,
|
|
1766
1767
|
totalSteps: event.totalSteps,
|
|
1767
1768
|
});
|
|
1768
1769
|
if (this.sse) {
|
|
1769
1770
|
this.sse.broadcast(`workflow:${instanceId}`, "progress", {
|
|
1770
1771
|
progress: event.progress,
|
|
1772
|
+
currentStep: event.stepName,
|
|
1771
1773
|
completedSteps: event.completedSteps,
|
|
1772
1774
|
totalSteps: event.totalSteps,
|
|
1773
1775
|
});
|
|
1774
1776
|
this.sse.broadcast("workflows:all", "workflow.progress", {
|
|
1775
1777
|
instanceId,
|
|
1776
1778
|
progress: event.progress,
|
|
1777
|
-
|
|
1778
|
-
totalSteps: event.totalSteps,
|
|
1779
|
+
currentStep: event.stepName,
|
|
1779
1780
|
});
|
|
1780
1781
|
}
|
|
1781
1782
|
break;
|
package/src/core.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { Audit } from "./core/audit";
|
|
|
17
17
|
import type { WebSocketService } from "./core/websocket";
|
|
18
18
|
import type { Storage } from "./core/storage";
|
|
19
19
|
import type { Logs } from "./core/logs";
|
|
20
|
+
import type { Health } from "./core/health";
|
|
20
21
|
|
|
21
22
|
// ============================================
|
|
22
23
|
// Auto-detect caller module for plugin define()
|
|
@@ -137,6 +138,7 @@ export interface CoreServices {
|
|
|
137
138
|
websocket: WebSocketService;
|
|
138
139
|
storage: Storage;
|
|
139
140
|
logs: Logs;
|
|
141
|
+
health: Health;
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/**
|
|
@@ -182,6 +184,8 @@ export interface GlobalContext {
|
|
|
182
184
|
ip: string;
|
|
183
185
|
/** Unique request ID */
|
|
184
186
|
requestId: string;
|
|
187
|
+
/** Trace ID for distributed tracing (from X-Request-Id/X-Trace-Id header, or defaults to requestId) */
|
|
188
|
+
traceId: string;
|
|
185
189
|
/** Authenticated user (set by auth middleware) */
|
|
186
190
|
user?: any;
|
|
187
191
|
/**
|
|
@@ -431,7 +435,11 @@ export type InferService<T> = UnwrapPluginFactory<T> extends { _service: infer S
|
|
|
431
435
|
: never;
|
|
432
436
|
export type InferSchema<T> = UnwrapPluginFactory<T> extends { _schema: infer S } ? S : never;
|
|
433
437
|
export type InferHandlers<T> = UnwrapPluginFactory<T> extends { handlers?: infer H } ? H : {};
|
|
434
|
-
export type InferMiddleware<T> = UnwrapPluginFactory<T> extends {
|
|
438
|
+
export type InferMiddleware<T> = UnwrapPluginFactory<T> extends {
|
|
439
|
+
middleware?: (ctx: any, service: any) => infer M;
|
|
440
|
+
}
|
|
441
|
+
? M
|
|
442
|
+
: {};
|
|
435
443
|
export type InferDependencies<T> = UnwrapPluginFactory<T> extends { _dependencies: infer D } ? D : readonly [];
|
|
436
444
|
export type InferConfig<T> = T extends (config: infer C) => any ? C : void;
|
|
437
445
|
export type InferEvents<T> = UnwrapPluginFactory<T> extends { events?: infer E } ? E : {};
|
package/src/generator/index.ts
CHANGED
|
@@ -19,6 +19,10 @@ export interface RouteInfo {
|
|
|
19
19
|
outputSource?: string;
|
|
20
20
|
/** SSE event schemas (for sse handler) */
|
|
21
21
|
eventsSource?: Record<string, string>;
|
|
22
|
+
/** API version (semver) if from a versioned router */
|
|
23
|
+
version?: string;
|
|
24
|
+
/** Whether this route version is deprecated */
|
|
25
|
+
deprecated?: boolean;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export interface EventInfo {
|
package/src/harness.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
createWebSocket,
|
|
18
18
|
createStorage,
|
|
19
19
|
createLogs,
|
|
20
|
+
createHealth,
|
|
20
21
|
KyselyJobAdapter,
|
|
21
22
|
KyselyWorkflowAdapter,
|
|
22
23
|
MemoryAuditAdapter,
|
|
@@ -71,6 +72,7 @@ export async function createTestHarness(targetPlugin: Plugin, dependencies: Plug
|
|
|
71
72
|
const websocket = createWebSocket();
|
|
72
73
|
const storage = createStorage(); // Uses memory adapter by default
|
|
73
74
|
const logs = createLogs({ adapter: new MemoryLogsAdapter(), events });
|
|
75
|
+
const health = createHealth({ dbCheck: false }); // No DB check in unit tests
|
|
74
76
|
|
|
75
77
|
const core: CoreServices = {
|
|
76
78
|
db,
|
|
@@ -89,6 +91,7 @@ export async function createTestHarness(targetPlugin: Plugin, dependencies: Plug
|
|
|
89
91
|
websocket,
|
|
90
92
|
storage,
|
|
91
93
|
logs,
|
|
94
|
+
health,
|
|
92
95
|
};
|
|
93
96
|
|
|
94
97
|
const manager = new PluginManager(core);
|
|
@@ -158,28 +161,37 @@ export class TestApiClient extends ApiClientBase {
|
|
|
158
161
|
* const user = await client.call("users.create", { name: "Test", email: "test@example.com" });
|
|
159
162
|
* ```
|
|
160
163
|
*/
|
|
161
|
-
async call<TOutput = any>(
|
|
164
|
+
async call<TOutput = any>(
|
|
165
|
+
route: string,
|
|
166
|
+
input: any = {},
|
|
167
|
+
options?: { version?: string }
|
|
168
|
+
): Promise<TOutput> {
|
|
162
169
|
const routeDef = this.routeMap.get(route);
|
|
163
170
|
if (!routeDef) {
|
|
164
171
|
throw new Error(`Route not found: ${route}. Available routes: ${[...this.routeMap.keys()].join(", ")}`);
|
|
165
172
|
}
|
|
166
173
|
|
|
174
|
+
const versionHeaders: Record<string, string> = {};
|
|
175
|
+
if (options?.version) {
|
|
176
|
+
versionHeaders["X-API-Version"] = options.version;
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
// Handle different handler types
|
|
168
180
|
if (routeDef.handler === "typed" || routeDef.handler === "formData") {
|
|
169
|
-
return this.request(route, input);
|
|
181
|
+
return this.request(route, input, { headers: versionHeaders });
|
|
170
182
|
} else if (routeDef.handler === "stream" || routeDef.handler === "html") {
|
|
171
183
|
const response = await this.rawRequest(route, {
|
|
172
184
|
method: "POST",
|
|
173
|
-
headers: { "Content-Type": "application/json" },
|
|
185
|
+
headers: { "Content-Type": "application/json", ...versionHeaders },
|
|
174
186
|
body: JSON.stringify(input),
|
|
175
187
|
});
|
|
176
188
|
return response as any;
|
|
177
189
|
} else if (routeDef.handler === "raw") {
|
|
178
|
-
const response = await this.rawRequest(route);
|
|
190
|
+
const response = await this.rawRequest(route, { headers: versionHeaders });
|
|
179
191
|
return response as any;
|
|
180
192
|
}
|
|
181
193
|
|
|
182
|
-
return this.request(route, input);
|
|
194
|
+
return this.request(route, input, { headers: versionHeaders });
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
/**
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export {
|
|
5
5
|
AppServer,
|
|
6
6
|
type ServerConfig,
|
|
7
|
+
type ShutdownConfig,
|
|
7
8
|
// Lifecycle hooks
|
|
8
9
|
type HookContext,
|
|
9
10
|
type OnReadyHandler,
|
|
@@ -91,14 +92,34 @@ export function defineConfig(config: DonkeylabsConfig): DonkeylabsConfig {
|
|
|
91
92
|
// Re-export HttpError for custom error creation
|
|
92
93
|
export { HttpError } from "./core/errors";
|
|
93
94
|
|
|
95
|
+
// Versioning
|
|
96
|
+
export {
|
|
97
|
+
parseSemVer,
|
|
98
|
+
type SemVer,
|
|
99
|
+
type VersioningConfig,
|
|
100
|
+
type DeprecationInfo,
|
|
101
|
+
type RouterOptions,
|
|
102
|
+
} from "./versioning";
|
|
103
|
+
|
|
94
104
|
// Core services types
|
|
95
105
|
export {
|
|
96
106
|
type Logger,
|
|
97
107
|
type LogLevel,
|
|
98
108
|
type ErrorFactory,
|
|
99
109
|
type ErrorFactories,
|
|
110
|
+
type EventMetadata,
|
|
100
111
|
} from "./core/index";
|
|
101
112
|
|
|
113
|
+
// Health checks
|
|
114
|
+
export {
|
|
115
|
+
type Health,
|
|
116
|
+
type HealthCheck,
|
|
117
|
+
type HealthCheckResult,
|
|
118
|
+
type HealthConfig,
|
|
119
|
+
type HealthResponse,
|
|
120
|
+
type HealthStatus,
|
|
121
|
+
} from "./core/health";
|
|
122
|
+
|
|
102
123
|
// Logs (persistent logging)
|
|
103
124
|
export {
|
|
104
125
|
type Logs,
|
package/src/router.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { GlobalContext, PluginHandlerRegistry } from "./core";
|
|
4
4
|
import type { MiddlewareDefinition } from "./middleware";
|
|
5
|
+
import type { RouterOptions, DeprecationInfo } from "./versioning";
|
|
5
6
|
|
|
6
7
|
export type ServerContext = GlobalContext;
|
|
7
8
|
|
|
@@ -255,6 +256,8 @@ export interface IRouter {
|
|
|
255
256
|
getRoutes(): RouteDefinition<any, any, any>[];
|
|
256
257
|
getMetadata(): RouteMetadata[];
|
|
257
258
|
getPrefix(): string;
|
|
259
|
+
getVersion(): string | undefined;
|
|
260
|
+
getDeprecation(): DeprecationInfo | undefined;
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
export class Router implements IRouter {
|
|
@@ -262,9 +265,13 @@ export class Router implements IRouter {
|
|
|
262
265
|
private childRouters: IRouter[] = [];
|
|
263
266
|
private prefix: string;
|
|
264
267
|
private _middlewareStack: MiddlewareDefinition[] = [];
|
|
268
|
+
private _version?: string;
|
|
269
|
+
private _deprecated?: DeprecationInfo;
|
|
265
270
|
|
|
266
|
-
constructor(prefix: string = "") {
|
|
271
|
+
constructor(prefix: string = "", options?: RouterOptions) {
|
|
267
272
|
this.prefix = prefix;
|
|
273
|
+
this._version = options?.version;
|
|
274
|
+
this._deprecated = options?.deprecated;
|
|
268
275
|
}
|
|
269
276
|
|
|
270
277
|
route(name: string): IRouteBuilder<this> {
|
|
@@ -277,6 +284,11 @@ export class Router implements IRouter {
|
|
|
277
284
|
const fullPrefix = this.prefix ? `${this.prefix}.${prefixOrRouter}` : prefixOrRouter;
|
|
278
285
|
const childRouter = new Router(fullPrefix);
|
|
279
286
|
childRouter._middlewareStack = [...this._middlewareStack];
|
|
287
|
+
// Inherit parent version unless child overrides
|
|
288
|
+
if (this._version && !childRouter._version) {
|
|
289
|
+
childRouter._version = this._version;
|
|
290
|
+
childRouter._deprecated = this._deprecated;
|
|
291
|
+
}
|
|
280
292
|
// Track child router so its routes are included in getRoutes()
|
|
281
293
|
this.childRouters.push(childRouter);
|
|
282
294
|
return childRouter;
|
|
@@ -355,6 +367,14 @@ export class Router implements IRouter {
|
|
|
355
367
|
getPrefix(): string {
|
|
356
368
|
return this.prefix;
|
|
357
369
|
}
|
|
370
|
+
|
|
371
|
+
getVersion(): string | undefined {
|
|
372
|
+
return this._version;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
getDeprecation(): DeprecationInfo | undefined {
|
|
376
|
+
return this._deprecated;
|
|
377
|
+
}
|
|
358
378
|
}
|
|
359
379
|
|
|
360
380
|
/** Creates a Proxy that intercepts middleware method calls and adds them to the router's middleware stack */
|
|
@@ -372,7 +392,7 @@ function createMiddlewareBuilderProxy<TRouter extends Router>(router: TRouter):
|
|
|
372
392
|
});
|
|
373
393
|
}
|
|
374
394
|
|
|
375
|
-
export const createRouter = (prefix?: string): IRouter => new Router(prefix);
|
|
395
|
+
export const createRouter = (prefix?: string, options?: RouterOptions): IRouter => new Router(prefix, options);
|
|
376
396
|
|
|
377
397
|
/**
|
|
378
398
|
* Define a route with type inference for input/output schemas.
|