@donkeylabs/server 2.0.21 → 2.0.23

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,91 @@ 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
+ #### Isolated Plugin Initialization
491
+
492
+ In isolated mode, the subprocess **boots a full plugin manager** and runs plugin `init` hooks locally. This means your workflow handlers can use `ctx.plugins` without IPC fallbacks, and cron/jobs/workflows/services registered in `init` are available inside the subprocess.
493
+
494
+ Requirements:
495
+ - Plugin modules must be discoverable from their module path (captured during `createPlugin.define()` / `pluginFactory()` calls).
496
+ - Plugin configs and `ctx.core.config` must be JSON-serializable.
497
+
498
+ ```typescript
499
+ // plugins/reports/index.ts
500
+ export const reportsPlugin = createPlugin.define({
501
+ name: "reports",
502
+ service: async (ctx) => ({
503
+ generate: async (id: string) => ctx.db.selectFrom("reports").selectAll().execute(),
504
+ }),
505
+ init: async (ctx) => {
506
+ ctx.core.jobs.register("reports.generate", async () => undefined);
507
+ },
508
+ });
509
+
510
+ // workflows/report.ts
511
+ export const reportWorkflow = workflow("report.generate")
512
+ .task("run", {
513
+ handler: async (input, ctx) => {
514
+ const data = await ctx.plugins.reports.generate(input.reportId);
515
+ return { data };
516
+ },
517
+ })
518
+ .build();
519
+ ```
520
+
521
+ ### Inline Mode
522
+
523
+ For lightweight workflows that complete quickly, you can opt into inline execution:
524
+
525
+ ```typescript
526
+ const quickWorkflow = workflow("quick-validation")
527
+ .isolated(false) // Run in the main server process
528
+ .task("validate", {
529
+ handler: async (input, ctx) => {
530
+ return { valid: true };
531
+ },
532
+ end: true,
533
+ })
534
+ .build();
535
+
536
+ // No modulePath needed for inline workflows
537
+ ctx.core.workflows.register(quickWorkflow);
538
+ ```
539
+
540
+ ### Choosing a Mode
541
+
542
+ | | Isolated (default) | Inline |
543
+ |---|---|---|
544
+ | Step types | All (task, choice, parallel, pass) | All (task, choice, parallel, pass) |
545
+ | Event loop | Separate process, won't block server | Runs on main thread |
546
+ | Plugin access | Local plugin services in subprocess | Direct access |
547
+ | Best for | Long-running, CPU-intensive workflows | Quick validations, lightweight flows |
548
+ | Setup | `workflows.register(wf)` | `workflows.register(wf)` |
549
+
465
550
  ## API Reference
466
551
 
467
552
  ### Workflows Service
@@ -469,7 +554,7 @@ for (const event of workflowEvents) {
469
554
  ```typescript
470
555
  interface Workflows {
471
556
  /** Register a workflow definition */
472
- register(definition: WorkflowDefinition): void;
557
+ register(definition: WorkflowDefinition, options?: WorkflowRegisterOptions): void;
473
558
 
474
559
  /** Start a new workflow instance */
475
560
  start<T = any>(workflowName: string, input: T): Promise<string>;
@@ -486,8 +571,23 @@ interface Workflows {
486
571
  /** Resume workflows after server restart */
487
572
  resume(): Promise<void>;
488
573
 
574
+ /** Update metadata for a workflow instance */
575
+ updateMetadata(instanceId: string, metadata: Record<string, any>): Promise<void>;
576
+
489
577
  /** Stop the workflow service */
490
578
  stop(): Promise<void>;
579
+
580
+ /** Set plugin metadata for isolated workflows (AppServer sets this automatically) */
581
+ setPluginMetadata(metadata: PluginMetadata): void;
582
+ }
583
+
584
+ interface WorkflowRegisterOptions {
585
+ /**
586
+ * Module path for isolated workflows.
587
+ * Auto-detected from build() in most cases.
588
+ * Only needed when re-exporting from a different module.
589
+ */
590
+ modulePath?: string;
491
591
  }
492
592
  ```
493
593
 
@@ -576,13 +676,13 @@ const server = new AppServer({
576
676
  Workflows automatically resume after server restart:
577
677
 
578
678
  1. On startup, `workflows.resume()` is called
579
- 2. All instances with `status: "running"` are retrieved
580
- 3. Execution continues from the current step
679
+ 2. All instances with `status: "running"` are retrieved from the database
680
+ 3. Isolated workflows re-launch a new subprocess that continues from the current step
681
+ 4. Inline workflows re-create the state machine and continue from the current step
581
682
 
582
683
  For this to work properly:
583
684
  - 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
685
+ - Step handlers should be idempotent when possible (a step may re-execute after a crash)
586
686
 
587
687
  ## Complete Example
588
688
 
@@ -670,7 +770,7 @@ const onboardingWorkflow = workflow("user-onboarding")
670
770
  // Setup server
671
771
  const server = new AppServer({ db: createDatabase() });
672
772
 
673
- // Register workflow
773
+ // Register workflow — modulePath auto-detected from build()
674
774
  server.getCore().workflows.register(onboardingWorkflow);
675
775
 
676
776
  // Start workflow from a route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.21",
3
+ "version": "2.0.23",
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
@@ -149,6 +149,7 @@ export {
149
149
  type PassStepDefinition,
150
150
  type RetryConfig,
151
151
  type GetAllWorkflowsOptions,
152
+ type PluginMetadata,
152
153
  WorkflowBuilder,
153
154
  MemoryWorkflowAdapter,
154
155
  workflow,
@@ -272,3 +273,25 @@ export {
272
273
 
273
274
  export { LocalStorageAdapter } from "./storage-adapter-local";
274
275
  export { S3StorageAdapter } from "./storage-adapter-s3";
276
+
277
+ export {
278
+ type Logs,
279
+ type LogSource,
280
+ type PersistentLogEntry,
281
+ type LogsQueryFilters,
282
+ type LogsRetentionConfig,
283
+ type LogsConfig,
284
+ type LogsAdapter,
285
+ MemoryLogsAdapter,
286
+ createLogs,
287
+ } from "./logs";
288
+
289
+ export {
290
+ KyselyLogsAdapter,
291
+ type KyselyLogsAdapterConfig,
292
+ } from "./logs-adapter-kysely";
293
+
294
+ export {
295
+ PersistentTransport,
296
+ type PersistentTransportConfig,
297
+ } 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 {