@donkeylabs/server 2.0.20 → 2.0.22

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/workflows.md CHANGED
@@ -79,7 +79,7 @@ const orderWorkflow = workflow("process-order")
79
79
  ### 2. Register and Start
80
80
 
81
81
  ```typescript
82
- // Register the workflow
82
+ // Register the workflow — modulePath is auto-detected from build()
83
83
  ctx.core.workflows.register(orderWorkflow);
84
84
 
85
85
  // Start an instance
@@ -462,6 +462,60 @@ for (const event of workflowEvents) {
462
462
  }
463
463
  ```
464
464
 
465
+ ## Execution Modes
466
+
467
+ Workflows support two execution modes controlled by the `.isolated()` builder method:
468
+
469
+ ### Isolated Mode (Default)
470
+
471
+ By default, workflows run in a **separate subprocess**. This prevents long-running step handlers from blocking the main server's event loop. The subprocess owns its own database connection and state machine, communicating events back to the main server via Unix sockets (TCP on Windows).
472
+
473
+ ```typescript
474
+ // Isolated mode (default) - runs in subprocess
475
+ const myWorkflow = workflow("heavy-processing")
476
+ .task("compute", {
477
+ handler: async (input, ctx) => {
478
+ // CPU-intensive work runs in subprocess, won't block the server
479
+ return await heavyComputation(input);
480
+ },
481
+ })
482
+ .build();
483
+
484
+ // Just register — modulePath is auto-detected from build()
485
+ ctx.core.workflows.register(myWorkflow);
486
+ ```
487
+
488
+ > **Advanced:** The module path is captured automatically when you call `.build()`. If you re-export a workflow definition from a different module, pass `{ modulePath: import.meta.url }` explicitly so the subprocess can find the definition.
489
+
490
+ ### Inline Mode
491
+
492
+ For lightweight workflows that complete quickly, you can opt into inline execution:
493
+
494
+ ```typescript
495
+ const quickWorkflow = workflow("quick-validation")
496
+ .isolated(false) // Run in the main server process
497
+ .task("validate", {
498
+ handler: async (input, ctx) => {
499
+ return { valid: true };
500
+ },
501
+ end: true,
502
+ })
503
+ .build();
504
+
505
+ // No modulePath needed for inline workflows
506
+ ctx.core.workflows.register(quickWorkflow);
507
+ ```
508
+
509
+ ### Choosing a Mode
510
+
511
+ | | Isolated (default) | Inline |
512
+ |---|---|---|
513
+ | Step types | All (task, choice, parallel, pass) | All (task, choice, parallel, pass) |
514
+ | Event loop | Separate process, won't block server | Runs on main thread |
515
+ | Plugin access | Via IPC proxy | Direct access |
516
+ | Best for | Long-running, CPU-intensive workflows | Quick validations, lightweight flows |
517
+ | Setup | `workflows.register(wf)` | `workflows.register(wf)` |
518
+
465
519
  ## API Reference
466
520
 
467
521
  ### Workflows Service
@@ -469,7 +523,7 @@ for (const event of workflowEvents) {
469
523
  ```typescript
470
524
  interface Workflows {
471
525
  /** Register a workflow definition */
472
- register(definition: WorkflowDefinition): void;
526
+ register(definition: WorkflowDefinition, options?: WorkflowRegisterOptions): void;
473
527
 
474
528
  /** Start a new workflow instance */
475
529
  start<T = any>(workflowName: string, input: T): Promise<string>;
@@ -486,9 +540,21 @@ interface Workflows {
486
540
  /** Resume workflows after server restart */
487
541
  resume(): Promise<void>;
488
542
 
543
+ /** Update metadata for a workflow instance */
544
+ updateMetadata(instanceId: string, metadata: Record<string, any>): Promise<void>;
545
+
489
546
  /** Stop the workflow service */
490
547
  stop(): Promise<void>;
491
548
  }
549
+
550
+ interface WorkflowRegisterOptions {
551
+ /**
552
+ * Module path for isolated workflows.
553
+ * Auto-detected from build() in most cases.
554
+ * Only needed when re-exporting from a different module.
555
+ */
556
+ modulePath?: string;
557
+ }
492
558
  ```
493
559
 
494
560
  ### Workflow Instance
@@ -576,13 +642,13 @@ const server = new AppServer({
576
642
  Workflows automatically resume after server restart:
577
643
 
578
644
  1. On startup, `workflows.resume()` is called
579
- 2. All instances with `status: "running"` are retrieved
580
- 3. Execution continues from the current step
645
+ 2. All instances with `status: "running"` are retrieved from the database
646
+ 3. Isolated workflows re-launch a new subprocess that continues from the current step
647
+ 4. Inline workflows re-create the state machine and continue from the current step
581
648
 
582
649
  For this to work properly:
583
650
  - Use a persistent adapter (not in-memory) in production
584
- - Jobs should be idempotent when possible
585
- - The Jobs service must also support restart resilience
651
+ - Step handlers should be idempotent when possible (a step may re-execute after a crash)
586
652
 
587
653
  ## Complete Example
588
654
 
@@ -670,7 +736,7 @@ const onboardingWorkflow = workflow("user-onboarding")
670
736
  // Setup server
671
737
  const server = new AppServer({ db: createDatabase() });
672
738
 
673
- // Register workflow
739
+ // Register workflow — modulePath auto-detected from build()
674
740
  server.getCore().workflows.register(onboardingWorkflow);
675
741
 
676
742
  // Start workflow from a route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -43,7 +43,7 @@
43
43
  "src",
44
44
  "docs",
45
45
  "examples",
46
- "CLAUDE.md",
46
+ "agents.md",
47
47
  "context.d.ts",
48
48
  "registry.d.ts",
49
49
  "LICENSE",
@@ -18,6 +18,7 @@ const icons = {
18
18
  cache: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></svg>`,
19
19
  plugins: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"/></svg>`,
20
20
  routes: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>`,
21
+ logs: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>`,
21
22
  refresh: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>`,
22
23
  server: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>`,
23
24
  };
@@ -72,6 +73,7 @@ export function renderDashboardLayout(
72
73
  { id: "processes", label: "Processes", icon: icons.processes },
73
74
  { id: "workflows", label: "Workflows", icon: icons.workflows },
74
75
  { id: "audit", label: "Audit Logs", icon: icons.audit },
76
+ { id: "logs", label: "Logs", icon: icons.logs },
75
77
  { id: "sse", label: "SSE Clients", icon: icons.sse },
76
78
  { id: "websocket", label: "WebSocket", icon: icons.websocket },
77
79
  { id: "events", label: "Events", icon: icons.events },
@@ -120,7 +122,7 @@ export function renderDashboardLayout(
120
122
  <nav class="nav-section">
121
123
  <div class="nav-section-title">Core Services</div>
122
124
  ${navItems
123
- .slice(1, 5)
125
+ .slice(1, 6)
124
126
  .map(
125
127
  (item) => `
126
128
  <a href="/${prefix}.dashboard?view=${item.id}"
@@ -138,7 +140,7 @@ export function renderDashboardLayout(
138
140
  <nav class="nav-section">
139
141
  <div class="nav-section-title">Connections</div>
140
142
  ${navItems
141
- .slice(5, 8)
143
+ .slice(6, 9)
142
144
  .map(
143
145
  (item) => `
144
146
  <a href="/${prefix}.dashboard?view=${item.id}"
@@ -156,7 +158,7 @@ export function renderDashboardLayout(
156
158
  <nav class="nav-section">
157
159
  <div class="nav-section-title">Configuration</div>
158
160
  ${navItems
159
- .slice(8)
161
+ .slice(9)
160
162
  .map(
161
163
  (item) => `
162
164
  <a href="/${prefix}.dashboard?view=${item.id}"
@@ -675,6 +677,75 @@ export function renderPlugins(prefix: string, plugins: any[]): string {
675
677
  `;
676
678
  }
677
679
 
680
+ export function renderLogs(prefix: string, logs: any[]): string {
681
+ const levelBadgeClass = (level: string) => {
682
+ switch (level) {
683
+ case "error": return "badge-failed";
684
+ case "warn": return "badge-pending";
685
+ case "info": return "badge-running";
686
+ case "debug": return "badge-completed";
687
+ default: return "";
688
+ }
689
+ };
690
+
691
+ return `
692
+ <div class="page-header">
693
+ <h2 class="page-title">Logs</h2>
694
+ <button class="btn" hx-get="/${prefix}.dashboard?view=logs&partial=1" hx-target="#main-content">
695
+ ${icons.refresh}
696
+ Refresh
697
+ </button>
698
+ </div>
699
+
700
+ <div class="filters">
701
+ <select class="filter-select" hx-get="/${prefix}.dashboard?view=logs&partial=1" hx-target="#main-content" hx-include="this" name="status">
702
+ <option value="">All Sources</option>
703
+ <option value="system">System</option>
704
+ <option value="cron">Cron</option>
705
+ <option value="job">Job</option>
706
+ <option value="workflow">Workflow</option>
707
+ <option value="plugin">Plugin</option>
708
+ <option value="route">Route</option>
709
+ </select>
710
+ </div>
711
+
712
+ <div class="card">
713
+ <div class="table-container">
714
+ <table>
715
+ <thead>
716
+ <tr>
717
+ <th>Level</th>
718
+ <th>Source</th>
719
+ <th>Source ID</th>
720
+ <th>Message</th>
721
+ <th>Timestamp</th>
722
+ </tr>
723
+ </thead>
724
+ <tbody>
725
+ ${
726
+ logs.length === 0
727
+ ? '<tr><td colspan="5" class="empty-state">No log entries found</td></tr>'
728
+ : logs
729
+ .map(
730
+ (log: any) => `
731
+ <tr>
732
+ <td><span class="badge ${levelBadgeClass(log.level)}">${log.level}</span></td>
733
+ <td>${log.source}</td>
734
+ <td class="mono truncate" title="${log.sourceId ?? ""}">${log.sourceId ?? "-"}</td>
735
+ <td class="truncate" title="${log.message}">${log.message.slice(0, 80)}${log.message.length > 80 ? "..." : ""}</td>
736
+ <td class="relative-time">${formatRelativeTime(log.timestamp)}</td>
737
+ </tr>
738
+ `
739
+ )
740
+ .join("")
741
+ }
742
+ </tbody>
743
+ </table>
744
+ </div>
745
+ </div>
746
+ `;
747
+ }
748
+
678
749
  export function renderRoutes(prefix: string, routes: any[]): string {
679
750
  return `
680
751
  <div class="page-header">
@@ -13,6 +13,7 @@ import {
13
13
  renderProcessesList,
14
14
  renderWorkflowsList,
15
15
  renderAuditLogs,
16
+ renderLogs,
16
17
  renderSSEClients,
17
18
  renderWebSocketClients,
18
19
  renderEvents,
@@ -124,6 +125,14 @@ export function createAdminRouter(config: AdminRouteContext) {
124
125
  content = renderWorkflowsList(prefix, workflows);
125
126
  break;
126
127
  }
128
+ case "logs": {
129
+ const logEntries = await ctx.core.logs.query({
130
+ source: (status as any) || undefined,
131
+ limit: 100,
132
+ });
133
+ content = renderLogs(prefix, logEntries);
134
+ break;
135
+ }
127
136
  case "audit": {
128
137
  const logs = await ctx.core.audit.query({
129
138
  limit: 100,
@@ -550,6 +559,59 @@ export function createAdminRouter(config: AdminRouteContext) {
550
559
  },
551
560
  });
552
561
 
562
+ // Logs list route
563
+ router.route("logs.list").typed(
564
+ defineRoute({
565
+ input: z.object({
566
+ source: z.enum(["system", "cron", "job", "workflow", "plugin", "route"]).optional(),
567
+ sourceId: z.string().optional(),
568
+ level: z.enum(["debug", "info", "warn", "error"]).optional(),
569
+ search: z.string().optional(),
570
+ startDate: z.string().optional(),
571
+ endDate: z.string().optional(),
572
+ limit: z.number().default(100),
573
+ offset: z.number().default(0),
574
+ }),
575
+ output: z.array(
576
+ z.object({
577
+ id: z.string(),
578
+ timestamp: z.string(),
579
+ level: z.string(),
580
+ message: z.string(),
581
+ source: z.string(),
582
+ sourceId: z.string().nullable(),
583
+ tags: z.array(z.string()).nullable(),
584
+ data: z.any().nullable(),
585
+ })
586
+ ),
587
+ handle: async (input, ctx) => {
588
+ if (!checkAuth(ctx)) {
589
+ throw ctx.errors.Forbidden("Unauthorized");
590
+ }
591
+ const entries = await ctx.core.logs.query({
592
+ source: input.source as any,
593
+ sourceId: input.sourceId,
594
+ level: input.level as any,
595
+ search: input.search,
596
+ startDate: input.startDate ? new Date(input.startDate) : undefined,
597
+ endDate: input.endDate ? new Date(input.endDate) : undefined,
598
+ limit: input.limit,
599
+ offset: input.offset,
600
+ });
601
+ return entries.map((entry) => ({
602
+ id: entry.id,
603
+ timestamp: entry.timestamp.toISOString(),
604
+ level: entry.level,
605
+ message: entry.message,
606
+ source: entry.source,
607
+ sourceId: entry.sourceId ?? null,
608
+ tags: entry.tags ?? null,
609
+ data: entry.data ?? null,
610
+ }));
611
+ },
612
+ })
613
+ );
614
+
553
615
  // Audit list route
554
616
  router.route("audit.list").typed(
555
617
  defineRoute({
package/src/core/cron.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  // Core Cron Service
2
2
  // Schedule recurring tasks with cron expressions
3
3
 
4
+ import type { Logger } from "./logger";
5
+
4
6
  export interface CronTask {
5
7
  id: string;
6
8
  name: string;
7
9
  expression: string;
8
- handler: () => void | Promise<void>;
10
+ handler: (logger?: Logger) => void | Promise<void>;
9
11
  enabled: boolean;
10
12
  lastRun?: Date;
11
13
  nextRun?: Date;
@@ -13,12 +15,13 @@ export interface CronTask {
13
15
 
14
16
  export interface CronConfig {
15
17
  timezone?: string; // For future use
18
+ logger?: Logger;
16
19
  }
17
20
 
18
21
  export interface Cron {
19
22
  schedule(
20
23
  expression: string,
21
- handler: () => void | Promise<void>,
24
+ handler: (logger?: Logger) => void | Promise<void>,
22
25
  options?: { name?: string; enabled?: boolean }
23
26
  ): string;
24
27
  unschedule(taskId: string): boolean;
@@ -259,14 +262,15 @@ class CronImpl implements Cron {
259
262
  private running = false;
260
263
  private timer: ReturnType<typeof setInterval> | null = null;
261
264
  private taskCounter = 0;
265
+ private logger?: Logger;
262
266
 
263
- constructor(_config: CronConfig = {}) {
264
- // timezone handling for future use
267
+ constructor(config: CronConfig = {}) {
268
+ this.logger = config.logger;
265
269
  }
266
270
 
267
271
  schedule(
268
272
  expression: string,
269
- handler: () => void | Promise<void>,
273
+ handler: (logger?: Logger) => void | Promise<void>,
270
274
  options: { name?: string; enabled?: boolean } = {}
271
275
  ): string {
272
276
  const id = `cron_${++this.taskCounter}_${Date.now()}`;
@@ -336,7 +340,8 @@ class CronImpl implements Cron {
336
340
  if (!task) throw new Error(`Task ${taskId} not found`);
337
341
 
338
342
  task.lastRun = new Date();
339
- await task.handler();
343
+ const scopedLogger = this.logger?.scoped("cron", task.name);
344
+ await task.handler(scopedLogger);
340
345
  }
341
346
 
342
347
  start(): void {
@@ -358,8 +363,9 @@ class CronImpl implements Cron {
358
363
  task.lastRun = now;
359
364
  task.nextRun = cronExpr.getNextRun(now);
360
365
 
361
- // Execute handler (fire and forget, but log errors)
362
- Promise.resolve(task.handler()).catch(err => {
366
+ // Execute handler with scoped logger (fire and forget, but log errors)
367
+ const scopedLogger = this.logger?.scoped("cron", task.name);
368
+ Promise.resolve(task.handler(scopedLogger)).catch(err => {
363
369
  console.error(`[Cron] Task "${task.name}" failed:`, err);
364
370
  });
365
371
  }
@@ -388,8 +394,9 @@ class CronImpl implements Cron {
388
394
  while (missedRun && missedRun < now && catchUpCount < maxCatchUp) {
389
395
  console.log(`[Cron] Catching up missed run for "${task.name}" at ${missedRun.toISOString()}`);
390
396
 
391
- // Execute the handler asynchronously
392
- Promise.resolve(task.handler()).catch(err => {
397
+ // Execute the handler asynchronously with scoped logger
398
+ const scopedLogger = this.logger?.scoped("cron", task.name);
399
+ Promise.resolve(task.handler(scopedLogger)).catch(err => {
393
400
  console.error(`[Cron] Catch-up task "${task.name}" failed:`, err);
394
401
  });
395
402
 
package/src/core/index.ts CHANGED
@@ -217,6 +217,12 @@ export {
217
217
  createProcessSocketServer,
218
218
  } from "./process-socket";
219
219
 
220
+ export {
221
+ WorkflowStateMachine,
222
+ type StateMachineEvents,
223
+ type StateMachineConfig,
224
+ } from "./workflow-state-machine";
225
+
220
226
  export {
221
227
  KyselyWorkflowAdapter,
222
228
  type KyselyWorkflowAdapterConfig,
@@ -266,3 +272,25 @@ export {
266
272
 
267
273
  export { LocalStorageAdapter } from "./storage-adapter-local";
268
274
  export { S3StorageAdapter } from "./storage-adapter-s3";
275
+
276
+ export {
277
+ type Logs,
278
+ type LogSource,
279
+ type PersistentLogEntry,
280
+ type LogsQueryFilters,
281
+ type LogsRetentionConfig,
282
+ type LogsConfig,
283
+ type LogsAdapter,
284
+ MemoryLogsAdapter,
285
+ createLogs,
286
+ } from "./logs";
287
+
288
+ export {
289
+ KyselyLogsAdapter,
290
+ type KyselyLogsAdapterConfig,
291
+ } from "./logs-adapter-kysely";
292
+
293
+ export {
294
+ PersistentTransport,
295
+ type PersistentTransportConfig,
296
+ } from "./logs-transport";
package/src/core/jobs.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // Supports both in-process handlers and external processes (Python, Go, Shell, etc.)
4
4
 
5
5
  import type { Events } from "./events";
6
+ import type { Logger } from "./logger";
6
7
  import type {
7
8
  ExternalJobConfig,
8
9
  ExternalJob,
@@ -58,7 +59,7 @@ export interface Job {
58
59
  }
59
60
 
60
61
  export interface JobHandler<T = any, R = any> {
61
- (data: T): Promise<R>;
62
+ (data: T, ctx?: { logger: Logger }): Promise<R>;
62
63
  }
63
64
 
64
65
  /** Options for listing all jobs */
@@ -94,6 +95,7 @@ export interface JobAdapter {
94
95
  export interface JobsConfig {
95
96
  adapter?: JobAdapter;
96
97
  events?: Events;
98
+ logger?: Logger;
97
99
  concurrency?: number; // Max concurrent jobs, default 5
98
100
  pollInterval?: number; // ms, default 1000
99
101
  maxAttempts?: number; // Default retry attempts, default 3
@@ -247,6 +249,7 @@ class JobsImpl implements Jobs {
247
249
  private adapter: JobAdapter;
248
250
  private sqliteAdapter?: SqliteJobAdapter;
249
251
  private events?: Events;
252
+ private logger?: Logger;
250
253
  private handlers = new Map<string, JobHandler>();
251
254
  private running = false;
252
255
  private timer: ReturnType<typeof setInterval> | null = null;
@@ -268,6 +271,7 @@ class JobsImpl implements Jobs {
268
271
 
269
272
  constructor(config: JobsConfig = {}) {
270
273
  this.events = config.events;
274
+ this.logger = config.logger;
271
275
  this.concurrency = config.concurrency ?? 5;
272
276
  this.pollInterval = config.pollInterval ?? 1000;
273
277
  this.defaultMaxAttempts = config.maxAttempts ?? 3;
@@ -1061,7 +1065,9 @@ class JobsImpl implements Jobs {
1061
1065
  attempts: job.attempts + 1,
1062
1066
  });
1063
1067
 
1064
- const result = await handler(job.data);
1068
+ // Create scoped logger for this job execution
1069
+ const scopedLogger = this.logger?.scoped("job", job.id);
1070
+ const result = await handler(job.data, scopedLogger ? { logger: scopedLogger } : undefined);
1065
1071
 
1066
1072
  await this.adapter.update(job.id, {
1067
1073
  status: "completed",
@@ -32,6 +32,8 @@ export interface Logger {
32
32
  child(context: Record<string, any>): Logger;
33
33
  /** Create a tagged child logger with colored prefix */
34
34
  tag(name: string): Logger;
35
+ /** Create a scoped child logger with source attribution for persistent logging */
36
+ scoped(source: string, sourceId: string): Logger;
35
37
  }
36
38
 
37
39
  const LOG_LEVELS: Record<LogLevel, number> = {
@@ -55,6 +57,14 @@ const TAG_COLORS: ((s: string) => string)[] = [
55
57
  const tagColorCache = new Map<string, (s: string) => string>();
56
58
  let colorIndex = 0;
57
59
 
60
+ // Pre-seed fixed colors for source types
61
+ tagColorCache.set("cron", pc.yellow);
62
+ tagColorCache.set("job", pc.magenta);
63
+ tagColorCache.set("workflow", pc.cyan);
64
+ tagColorCache.set("plugin", pc.green);
65
+ tagColorCache.set("system", pc.blue);
66
+ tagColorCache.set("route", pc.red);
67
+
58
68
  /**
59
69
  * Get a consistent color for a tag name.
60
70
  * Same tag always gets the same color within a process.
@@ -187,6 +197,10 @@ class LoggerImpl implements Logger {
187
197
  [...this.tags, name]
188
198
  );
189
199
  }
200
+
201
+ scoped(source: string, sourceId: string): Logger {
202
+ return this.tag(source).child({ logSource: source, logSourceId: sourceId });
203
+ }
190
204
  }
191
205
 
192
206
  export function createLogger(config?: LoggerConfig): Logger {