@donkeylabs/server 2.3.0 → 2.5.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/processes.md +53 -2
- package/package.json +1 -1
- package/src/core/core-events.ts +116 -0
- package/src/core/index.ts +2 -0
- package/src/core/processes.ts +55 -1
- package/src/core.ts +22 -2
- package/src/index.ts +5 -0
- package/src/server.ts +25 -1
package/docs/processes.md
CHANGED
|
@@ -323,9 +323,12 @@ interface ProcessDefinition {
|
|
|
323
323
|
/** Environment variables */
|
|
324
324
|
env?: Record<string, string>;
|
|
325
325
|
|
|
326
|
-
/** Typed events the process can emit */
|
|
326
|
+
/** Typed events the process can emit (validated at runtime) */
|
|
327
327
|
events?: Record<string, ZodSchema>;
|
|
328
328
|
|
|
329
|
+
/** Typed command schemas for messages sent TO the process via send() (validated at runtime) */
|
|
330
|
+
commands?: Record<string, ZodSchema>;
|
|
331
|
+
|
|
329
332
|
/** Heartbeat timeout in ms (default: 30000) */
|
|
330
333
|
heartbeatTimeout?: number;
|
|
331
334
|
|
|
@@ -363,9 +366,12 @@ interface Processes {
|
|
|
363
366
|
/** Get processes by name */
|
|
364
367
|
getByName(name: string): ManagedProcess[];
|
|
365
368
|
|
|
366
|
-
/** Send a message to a running process */
|
|
369
|
+
/** Send a message to a running process (validated against command schemas if defined) */
|
|
367
370
|
send(processId: string, message: any): Promise<boolean>;
|
|
368
371
|
|
|
372
|
+
/** Get all registered process definitions */
|
|
373
|
+
getDefinitions(): Map<string, ProcessDefinition>;
|
|
374
|
+
|
|
369
375
|
/** Stop a process */
|
|
370
376
|
stop(processId: string, signal?: NodeJS.Signals): Promise<void>;
|
|
371
377
|
|
|
@@ -575,6 +581,51 @@ ctx.core.events.on("process.stats", ({ processId, name, stats }) => {
|
|
|
575
581
|
|
|
576
582
|
The server can send messages to running processes via `ctx.core.processes.send()`. The ProcessClient receives these messages through the `onMessage` callback.
|
|
577
583
|
|
|
584
|
+
### Typed Commands
|
|
585
|
+
|
|
586
|
+
Define command schemas to get runtime validation on `send()`. If a command fails validation, `send()` returns `false` and the message is not sent.
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
import { z } from "zod";
|
|
590
|
+
|
|
591
|
+
ctx.core.processes.register({
|
|
592
|
+
name: "ws-daemon",
|
|
593
|
+
config: {
|
|
594
|
+
command: "bun",
|
|
595
|
+
args: ["./workers/ws-daemon.ts"],
|
|
596
|
+
},
|
|
597
|
+
events: {
|
|
598
|
+
ready: z.object({ port: z.number() }),
|
|
599
|
+
clientCount: z.object({ count: z.number() }),
|
|
600
|
+
},
|
|
601
|
+
// Commands are validated before sending to the process
|
|
602
|
+
commands: {
|
|
603
|
+
subscribe: z.object({
|
|
604
|
+
type: z.literal("subscribe"),
|
|
605
|
+
channel: z.string(),
|
|
606
|
+
}),
|
|
607
|
+
configUpdate: z.object({
|
|
608
|
+
type: z.literal("configUpdate"),
|
|
609
|
+
settings: z.record(z.any()),
|
|
610
|
+
}),
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Valid command - sends successfully
|
|
615
|
+
await ctx.core.processes.send(processId, {
|
|
616
|
+
type: "subscribe",
|
|
617
|
+
channel: "live-scores",
|
|
618
|
+
}); // true
|
|
619
|
+
|
|
620
|
+
// Invalid command - returns false, message not sent
|
|
621
|
+
await ctx.core.processes.send(processId, {
|
|
622
|
+
type: "subscribe",
|
|
623
|
+
channel: 12345, // schema requires string
|
|
624
|
+
}); // false
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Commands without schemas are not validated (passthrough). The `type` field of the message is used to match against the command schema name.
|
|
628
|
+
|
|
578
629
|
### Sending Messages from Server
|
|
579
630
|
|
|
580
631
|
```typescript
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Event Map
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for all core service event types.
|
|
5
|
+
* These events are emitted by workflows, jobs, processes, cron, and logs.
|
|
6
|
+
* They are always available in EventRegistry without code generation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ProcessStats } from "./processes";
|
|
10
|
+
import type { PersistentLogEntry } from "./logs";
|
|
11
|
+
|
|
12
|
+
export interface CoreEventMap {
|
|
13
|
+
// Workflow events
|
|
14
|
+
"workflow.started": { instanceId: string; workflowName: string; input: any };
|
|
15
|
+
"workflow.completed": { instanceId: string; output: any };
|
|
16
|
+
"workflow.failed": { instanceId: string; workflowName: string; error: string };
|
|
17
|
+
"workflow.cancelled": { instanceId: string; workflowName: string };
|
|
18
|
+
"workflow.progress": { instanceId: string; progress: number; currentStep: string; completedSteps: number; totalSteps: number };
|
|
19
|
+
"workflow.event": { instanceId: string; workflowName: string; event: string; data: any };
|
|
20
|
+
"workflow.step.started": { instanceId: string; stepName: string; stepType: string };
|
|
21
|
+
"workflow.step.completed": { instanceId: string; stepName: string; output: any };
|
|
22
|
+
"workflow.step.failed": { instanceId: string; stepName: string; error: string; attempts: number };
|
|
23
|
+
"workflow.step.poll": { instanceId: string; stepName: string; pollCount: number; done: boolean; result: any };
|
|
24
|
+
"workflow.step.loop": { instanceId: string; stepName: string; loopCount: number; target: string };
|
|
25
|
+
"workflow.step.retry": { instanceId: string; stepName: string; attempt: number; maxAttempts: number; delay: number };
|
|
26
|
+
"workflow.watchdog.stale": { instanceId: string; reason: string; timeoutMs: number };
|
|
27
|
+
"workflow.watchdog.killed": { instanceId: string; reason: string; timeoutMs: number };
|
|
28
|
+
|
|
29
|
+
// Job events
|
|
30
|
+
"job.completed": { jobId: string; name: string; result: any };
|
|
31
|
+
"job.failed": { jobId: string; name: string; error: string; attempts?: number; stack?: string };
|
|
32
|
+
"job.stale": { jobId: string; name: string; timeSinceHeartbeat: number };
|
|
33
|
+
"job.reconnected": { jobId: string; name: string };
|
|
34
|
+
"job.lost": { jobId: string; name: string };
|
|
35
|
+
"job.event": { jobId: string; name: string; event: string; data?: any };
|
|
36
|
+
"job.external.spawned": { jobId: string; name: string };
|
|
37
|
+
"job.external.progress": { jobId: string; name: string; percent: number; message: string; data: any };
|
|
38
|
+
"job.external.log": { jobId: string; name: string; level: string; message: string; data?: any };
|
|
39
|
+
"job.watchdog.stale": { jobId: string; name: string; timeSinceHeartbeat: number };
|
|
40
|
+
"job.watchdog.killed": { jobId: string; name: string; reason: string };
|
|
41
|
+
|
|
42
|
+
// Process events
|
|
43
|
+
"process.spawned": { processId: string; name: string; pid: number };
|
|
44
|
+
"process.stopped": { processId: string; name: string };
|
|
45
|
+
"process.crashed": { processId: string; name: string; exitCode: number | null };
|
|
46
|
+
"process.restarted": { oldProcessId: string; newProcessId: string; name: string; attempt: number };
|
|
47
|
+
"process.reconnected": { processId: string; name: string; pid: number };
|
|
48
|
+
"process.stats": { processId: string; name: string; stats: ProcessStats };
|
|
49
|
+
"process.limits_exceeded": { processId: string; name: string; reason: string; limit: number; value?: number };
|
|
50
|
+
"process.heartbeat_missed": { processId: string; name: string };
|
|
51
|
+
"process.event": { processId: string; name: string; event: string; data: any };
|
|
52
|
+
"process.message": { processId: string; name: string; message: any };
|
|
53
|
+
"process.watchdog.stale": { processId: string; name: string; reason: string; timeoutMs: number };
|
|
54
|
+
"process.watchdog.killed": { processId: string; name: string; reason: string; value?: number };
|
|
55
|
+
|
|
56
|
+
// Cron events
|
|
57
|
+
"cron.event": { taskId: string; name: string; event: string; data?: any };
|
|
58
|
+
|
|
59
|
+
// Log events
|
|
60
|
+
"log.created": PersistentLogEntry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serializable core event definitions for CLI type generation.
|
|
65
|
+
* Maps event name to TypeScript type string (used by `donkeylabs generate`).
|
|
66
|
+
*/
|
|
67
|
+
export const CORE_EVENT_DEFINITIONS: Record<string, string> = {
|
|
68
|
+
// Workflow events
|
|
69
|
+
"workflow.started": "{ instanceId: string; workflowName: string; input: any }",
|
|
70
|
+
"workflow.completed": "{ instanceId: string; output: any }",
|
|
71
|
+
"workflow.failed": "{ instanceId: string; workflowName: string; error: string }",
|
|
72
|
+
"workflow.cancelled": "{ instanceId: string; workflowName: string }",
|
|
73
|
+
"workflow.progress": "{ instanceId: string; progress: number; currentStep: string; completedSteps: number; totalSteps: number }",
|
|
74
|
+
"workflow.event": "{ instanceId: string; workflowName: string; event: string; data: any }",
|
|
75
|
+
"workflow.step.started": "{ instanceId: string; stepName: string; stepType: string }",
|
|
76
|
+
"workflow.step.completed": "{ instanceId: string; stepName: string; output: any }",
|
|
77
|
+
"workflow.step.failed": "{ instanceId: string; stepName: string; error: string; attempts: number }",
|
|
78
|
+
"workflow.step.poll": "{ instanceId: string; stepName: string; pollCount: number; done: boolean; result: any }",
|
|
79
|
+
"workflow.step.loop": "{ instanceId: string; stepName: string; loopCount: number; target: string }",
|
|
80
|
+
"workflow.step.retry": "{ instanceId: string; stepName: string; attempt: number; maxAttempts: number; delay: number }",
|
|
81
|
+
"workflow.watchdog.stale": "{ instanceId: string; reason: string; timeoutMs: number }",
|
|
82
|
+
"workflow.watchdog.killed": "{ instanceId: string; reason: string; timeoutMs: number }",
|
|
83
|
+
|
|
84
|
+
// Job events
|
|
85
|
+
"job.completed": "{ jobId: string; name: string; result: any }",
|
|
86
|
+
"job.failed": "{ jobId: string; name: string; error: string; attempts?: number; stack?: string }",
|
|
87
|
+
"job.stale": "{ jobId: string; name: string; timeSinceHeartbeat: number }",
|
|
88
|
+
"job.reconnected": "{ jobId: string; name: string }",
|
|
89
|
+
"job.lost": "{ jobId: string; name: string }",
|
|
90
|
+
"job.event": "{ jobId: string; name: string; event: string; data?: any }",
|
|
91
|
+
"job.external.spawned": "{ jobId: string; name: string }",
|
|
92
|
+
"job.external.progress": "{ jobId: string; name: string; percent: number; message: string; data: any }",
|
|
93
|
+
"job.external.log": "{ jobId: string; name: string; level: string; message: string; data?: any }",
|
|
94
|
+
"job.watchdog.stale": "{ jobId: string; name: string; timeSinceHeartbeat: number }",
|
|
95
|
+
"job.watchdog.killed": "{ jobId: string; name: string; reason: string }",
|
|
96
|
+
|
|
97
|
+
// Process events
|
|
98
|
+
"process.spawned": "{ processId: string; name: string; pid: number }",
|
|
99
|
+
"process.stopped": "{ processId: string; name: string }",
|
|
100
|
+
"process.crashed": "{ processId: string; name: string; exitCode: number | null }",
|
|
101
|
+
"process.restarted": "{ oldProcessId: string; newProcessId: string; name: string; attempt: number }",
|
|
102
|
+
"process.reconnected": "{ processId: string; name: string; pid: number }",
|
|
103
|
+
"process.stats": "{ processId: string; name: string; stats: { cpu: { user: number; system: number; percent: number }; memory: { rss: number; heapTotal: number; heapUsed: number; external: number }; uptime: number } }",
|
|
104
|
+
"process.limits_exceeded": "{ processId: string; name: string; reason: string; limit: number; value?: number }",
|
|
105
|
+
"process.heartbeat_missed": "{ processId: string; name: string }",
|
|
106
|
+
"process.event": "{ processId: string; name: string; event: string; data: any }",
|
|
107
|
+
"process.message": "{ processId: string; name: string; message: any }",
|
|
108
|
+
"process.watchdog.stale": "{ processId: string; name: string; reason: string; timeoutMs: number }",
|
|
109
|
+
"process.watchdog.killed": "{ processId: string; name: string; reason: string; value?: number }",
|
|
110
|
+
|
|
111
|
+
// Cron events
|
|
112
|
+
"cron.event": "{ taskId: string; name: string; event: string; data?: any }",
|
|
113
|
+
|
|
114
|
+
// Log events
|
|
115
|
+
"log.created": "{ id: string; timestamp: Date; level: string; message: string; source: string; sourceId?: string; tags?: string[]; data?: Record<string, any>; context?: Record<string, any> }",
|
|
116
|
+
};
|
package/src/core/index.ts
CHANGED
package/src/core/processes.ts
CHANGED
|
@@ -136,6 +136,19 @@ export interface ProcessDefinition {
|
|
|
136
136
|
* ```
|
|
137
137
|
*/
|
|
138
138
|
events?: Record<string, import("zod").ZodType<any>>;
|
|
139
|
+
/**
|
|
140
|
+
* Command schemas for messages sent TO the process via send().
|
|
141
|
+
* Commands are validated at runtime using safeParse() before sending.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* commands: {
|
|
146
|
+
* subscribe: z.object({ channel: z.string() }),
|
|
147
|
+
* configUpdate: z.object({ settings: z.record(z.any()) }),
|
|
148
|
+
* }
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
commands?: Record<string, import("zod").ZodType<any>>;
|
|
139
152
|
/** Called when a message is received from the process */
|
|
140
153
|
onMessage?: (process: ManagedProcess, message: any) => void | Promise<void>;
|
|
141
154
|
/** Called when the process crashes unexpectedly */
|
|
@@ -209,6 +222,8 @@ export interface Processes {
|
|
|
209
222
|
getRunning(): Promise<ManagedProcess[]>;
|
|
210
223
|
/** Send a message to a process via socket */
|
|
211
224
|
send(processId: string, message: any): Promise<boolean>;
|
|
225
|
+
/** Get all registered process definitions */
|
|
226
|
+
getDefinitions(): Map<string, ProcessDefinition>;
|
|
212
227
|
/** Start the service (recovery, monitoring) */
|
|
213
228
|
start(): void;
|
|
214
229
|
/** Shutdown the service and all managed processes */
|
|
@@ -552,9 +567,32 @@ export class ProcessesImpl implements Processes {
|
|
|
552
567
|
}
|
|
553
568
|
|
|
554
569
|
async send(processId: string, message: any): Promise<boolean> {
|
|
570
|
+
// Validate message against command schemas if defined
|
|
571
|
+
if (message && typeof message === "object" && message.type) {
|
|
572
|
+
const proc = await this.adapter.get(processId);
|
|
573
|
+
if (proc) {
|
|
574
|
+
const definition = this.definitions.get(proc.name);
|
|
575
|
+
if (definition?.commands) {
|
|
576
|
+
const commandSchema = definition.commands[message.type];
|
|
577
|
+
if (commandSchema) {
|
|
578
|
+
const result = commandSchema.safeParse(message);
|
|
579
|
+
if (!result.success) {
|
|
580
|
+
console.warn(
|
|
581
|
+
`[Processes] Command validation failed for '${message.type}' on ${proc.name}: ${result.error.message}`
|
|
582
|
+
);
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
555
589
|
return this.socketServer.send(processId, message);
|
|
556
590
|
}
|
|
557
591
|
|
|
592
|
+
getDefinitions(): Map<string, ProcessDefinition> {
|
|
593
|
+
return this.definitions;
|
|
594
|
+
}
|
|
595
|
+
|
|
558
596
|
start(): void {
|
|
559
597
|
// Recover orphaned processes
|
|
560
598
|
if (this.autoRecoverOrphans) {
|
|
@@ -684,7 +722,23 @@ export class ProcessesImpl implements Processes {
|
|
|
684
722
|
// Handle typed event messages from ProcessClient.emit()
|
|
685
723
|
if (type === "event" && message.event) {
|
|
686
724
|
const eventName = message.event as string;
|
|
687
|
-
|
|
725
|
+
let eventData = message.data ?? {};
|
|
726
|
+
|
|
727
|
+
// Validate event data against schema if defined
|
|
728
|
+
const definition = this.definitions.get(proc.name);
|
|
729
|
+
if (definition?.events) {
|
|
730
|
+
const eventSchema = definition.events[eventName];
|
|
731
|
+
if (eventSchema) {
|
|
732
|
+
const result = eventSchema.safeParse(eventData);
|
|
733
|
+
if (!result.success) {
|
|
734
|
+
console.warn(
|
|
735
|
+
`[Processes] Event validation failed for '${eventName}' on ${proc.name}: ${result.error.message}`
|
|
736
|
+
);
|
|
737
|
+
return; // Drop invalid events
|
|
738
|
+
}
|
|
739
|
+
eventData = result.data;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
688
742
|
|
|
689
743
|
// Emit to events service as "process.<name>.<event>"
|
|
690
744
|
await this.emitEvent(`process.${proc.name}.${eventName}`, {
|
package/src/core.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { WebSocketService } from "./core/websocket";
|
|
|
18
18
|
import type { Storage } from "./core/storage";
|
|
19
19
|
import type { Logs } from "./core/logs";
|
|
20
20
|
import type { Health } from "./core/health";
|
|
21
|
+
import type { CoreEventMap } from "./core/core-events";
|
|
21
22
|
|
|
22
23
|
// ============================================
|
|
23
24
|
// Auto-detect caller module for plugin define()
|
|
@@ -71,7 +72,26 @@ export type EventSchemas = Record<string, z.ZodType<any>>;
|
|
|
71
72
|
* ```
|
|
72
73
|
*/
|
|
73
74
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
74
|
-
export interface EventRegistry {}
|
|
75
|
+
export interface EventRegistry extends CoreEventMap {}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Registry for process type definitions.
|
|
79
|
+
* Augment this interface to add typed process commands/events:
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* declare module "@donkeylabs/server" {
|
|
84
|
+
* interface ProcessRegistry {
|
|
85
|
+
* "video-encoder": {
|
|
86
|
+
* events: { progress: { percent: number }; complete: { outputPath: string } };
|
|
87
|
+
* commands: { subscribe: { channel: string }; configUpdate: { settings: Record<string, any> } };
|
|
88
|
+
* };
|
|
89
|
+
* }
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
94
|
+
export interface ProcessRegistry {}
|
|
75
95
|
|
|
76
96
|
/**
|
|
77
97
|
* Define server-level events with Zod schemas.
|
|
@@ -240,7 +260,7 @@ type ExtractServices<T extends readonly (keyof PluginRegistry)[] | undefined> =
|
|
|
240
260
|
T extends readonly []
|
|
241
261
|
? {}
|
|
242
262
|
: T extends readonly (infer K)[]
|
|
243
|
-
? K extends keyof PluginRegistry
|
|
263
|
+
? [K] extends [keyof PluginRegistry]
|
|
244
264
|
? { [P in K]: PluginRegistry[P] extends { service: infer S } ? S : unknown }
|
|
245
265
|
: {}
|
|
246
266
|
: {};
|
package/src/index.ts
CHANGED
|
@@ -51,7 +51,9 @@ export {
|
|
|
51
51
|
defineEvents,
|
|
52
52
|
type EventRegistry,
|
|
53
53
|
type EventSchemas,
|
|
54
|
+
// Registries
|
|
54
55
|
type PluginRegistry,
|
|
56
|
+
type ProcessRegistry,
|
|
55
57
|
type PluginHandlerRegistry,
|
|
56
58
|
type PluginMiddlewareRegistry,
|
|
57
59
|
type CoreServices,
|
|
@@ -110,6 +112,9 @@ export {
|
|
|
110
112
|
type EventMetadata,
|
|
111
113
|
} from "./core/index";
|
|
112
114
|
|
|
115
|
+
// Core event types
|
|
116
|
+
export { type CoreEventMap, CORE_EVENT_DEFINITIONS } from "./core/core-events";
|
|
117
|
+
|
|
113
118
|
// Health checks
|
|
114
119
|
export {
|
|
115
120
|
type Health,
|
package/src/server.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
import { createHealth, createDbHealthCheck, type HealthConfig } from "./core/health";
|
|
57
57
|
import type { AdminConfig } from "./admin";
|
|
58
58
|
import { zodSchemaToTs } from "./generator/zod-to-ts";
|
|
59
|
+
import { CORE_EVENT_DEFINITIONS } from "./core/core-events";
|
|
59
60
|
|
|
60
61
|
export interface TypeGenerationConfig {
|
|
61
62
|
/** Output path for generated client types (e.g., "./src/lib/api.ts") */
|
|
@@ -855,7 +856,30 @@ export class AppServer {
|
|
|
855
856
|
}
|
|
856
857
|
}
|
|
857
858
|
|
|
858
|
-
|
|
859
|
+
// Collect process definitions with their schemas
|
|
860
|
+
const processes = [];
|
|
861
|
+
const definitions = this.coreServices.processes.getDefinitions();
|
|
862
|
+
for (const [name, def] of definitions) {
|
|
863
|
+
const events: Record<string, string> = {};
|
|
864
|
+
if (def.events) {
|
|
865
|
+
for (const [eventName, schema] of Object.entries(def.events)) {
|
|
866
|
+
events[eventName] = zodSchemaToTs(schema);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const commands: Record<string, string> = {};
|
|
870
|
+
if (def.commands) {
|
|
871
|
+
for (const [cmdName, schema] of Object.entries(def.commands)) {
|
|
872
|
+
commands[cmdName] = zodSchemaToTs(schema);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
processes.push({
|
|
876
|
+
name,
|
|
877
|
+
events: Object.keys(events).length > 0 ? events : undefined,
|
|
878
|
+
commands: Object.keys(commands).length > 0 ? commands : undefined,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
console.log(JSON.stringify({ routes, processes, coreEvents: CORE_EVENT_DEFINITIONS }));
|
|
859
883
|
}
|
|
860
884
|
|
|
861
885
|
/**
|