@zintrust/workers 0.4.4 → 0.4.34

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/README.md CHANGED
@@ -659,6 +659,27 @@ registerWorkerRoutes(Router);
659
659
  Processor specs can be file paths or URL specs (recommended for production). Remote processors
660
660
  must export a named `ZinTrustProcessor` function.
661
661
 
662
+ For file-backed worker discovery, export a `workerDefinition` object from the worker module. This
663
+ lets fresh projects surface worker metadata before any persistence record exists.
664
+
665
+ ```typescript
666
+ export const workerDefinition = Object.freeze({
667
+ name: 'example-worker',
668
+ queueName: 'example-worker',
669
+ version: '1.0.0',
670
+ autoStart: false,
671
+ activeStatus: true,
672
+ concurrency: 1,
673
+ processorSpec: 'app/Workers/ExampleWorker.ts',
674
+ });
675
+
676
+ export async function ZinTrustProcessor(payload: unknown): Promise<void> {
677
+ return undefined;
678
+ }
679
+
680
+ export default ZinTrustProcessor;
681
+ ```
682
+
662
683
  Workers support `activeStatus` to pause without deletion; inactive workers do not auto-start.
663
684
 
664
685
  See the [API Reference](#api-reference) section for all available endpoints.
@@ -683,6 +704,11 @@ await WorkerInit.initialize({
683
704
  });
684
705
  ```
685
706
 
707
+ When `WORKER_AUTO_START=true`, ZinTrust first auto-starts persisted workers. If persisted discovery
708
+ finds no auto-start candidates, `WorkerInit.autoStartPersistedWorkers()` falls back to file-backed
709
+ worker definitions discovered from project files. This keeps existing persisted deployments stable
710
+ while allowing fresh apps to boot workers from code-first definitions.
711
+
686
712
  ### Graceful Shutdown
687
713
 
688
714
  Shutdown workers gracefully:
@@ -19,5 +19,10 @@ export declare const BroadcastWorker: Readonly<{
19
19
  signal?: AbortSignal;
20
20
  maxDurationMs?: number;
21
21
  }) => Promise<number>;
22
+ __zintrustQueueWorkerMeta?: Readonly<{
23
+ kindLabel: string;
24
+ defaultQueueName: string;
25
+ maxAttempts: number;
26
+ }>;
22
27
  }>;
23
28
  export default BroadcastWorker;
@@ -19,5 +19,10 @@ export declare const NotificationWorker: Readonly<{
19
19
  signal?: AbortSignal;
20
20
  maxDurationMs?: number;
21
21
  }) => Promise<number>;
22
+ __zintrustQueueWorkerMeta?: Readonly<{
23
+ kindLabel: string;
24
+ defaultQueueName: string;
25
+ maxAttempts: number;
26
+ }>;
22
27
  }>;
23
28
  export default NotificationWorker;
@@ -188,6 +188,14 @@ export declare const WorkerFactory: Readonly<{
188
188
  * Start a worker from persisted storage when it is not registered.
189
189
  */
190
190
  startFromPersisted(name: string, persistenceOverride?: WorkerPersistenceConfig): Promise<void>;
191
+ /**
192
+ * List worker definitions discovered from project files.
193
+ */
194
+ listFileBackedRecords(): Promise<WorkerRecord[]>;
195
+ /**
196
+ * Get a file-backed worker definition by name.
197
+ */
198
+ getFileBackedRecord(name: string): Promise<WorkerRecord | null>;
191
199
  /**
192
200
  * Get persisted worker record
193
201
  */
@@ -3,7 +3,7 @@
3
3
  * Central factory for creating workers with all advanced features
4
4
  * Sealed namespace for immutability
5
5
  */
6
- import { Cloudflare, createRedisConnection, databaseConfig, Env, ErrorFactory, generateUuid, getBullMQSafeQueueName, JobStateTracker, Logger, NodeSingletons, queueConfig, registerDatabasesFromRuntimeConfig, useEnsureDbConnected, workersConfig, ZintrustLang, } from '@zintrust/core';
6
+ import { Cloudflare, createRedisConnection, databaseConfig, Env, ErrorFactory, generateUuid, getBullMQSafeQueueName, isFunction, isNonEmptyString, isObject, JobStateTracker, Logger, NodeSingletons, queueConfig, registerDatabasesFromRuntimeConfig, useEnsureDbConnected, workersConfig, ZintrustLang, } from '@zintrust/core';
7
7
  import { Worker } from 'bullmq';
8
8
  import { AutoScaler } from './AutoScaler.js';
9
9
  import { CanaryController } from './CanaryController.js';
@@ -100,7 +100,6 @@ const resolvePackageSpecifierUrl = (specifier) => {
100
100
  return resolveLocalPackageFallback(specifier);
101
101
  }
102
102
  };
103
- const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
104
103
  const rewriteProcessorImports = (code) => {
105
104
  const replacements = [];
106
105
  const coreUrl = resolvePackageSpecifierUrl('@zintrust/core');
@@ -113,8 +112,8 @@ const rewriteProcessorImports = (code) => {
113
112
  return code;
114
113
  let updated = code;
115
114
  for (const { from, to } of replacements) {
116
- const pattern = new RegExp(String.raw `(['"])${escapeRegExp(from)}\1`, 'g');
117
- updated = updated.replace(pattern, `$1${to}$1`);
115
+ updated = updated.replaceAll(`'${from}'`, `'${to}'`);
116
+ updated = updated.replaceAll(`"${from}"`, `"${to}"`);
118
117
  }
119
118
  return updated;
120
119
  };
@@ -241,6 +240,24 @@ const processorRegistry = new Map();
241
240
  const processorPathRegistry = new Map();
242
241
  const processorResolvers = [];
243
242
  const processorSpecRegistry = new Map();
243
+ const queueWorkerMetaKey = '__zintrustQueueWorkerMeta';
244
+ const fileWorkerDefinitionExportKeys = Object.freeze([
245
+ 'workerDefinition',
246
+ 'workerConfig',
247
+ 'zintrustWorker',
248
+ 'ZinTrustWorker',
249
+ 'worker',
250
+ 'defaultWorkerDefinition',
251
+ ]);
252
+ const workerDiscoveryDirectories = Object.freeze([
253
+ ['dist', 'app', 'Workers'],
254
+ ['dist', 'src', 'workers'],
255
+ ['dist', 'src', 'Workers'],
256
+ ['app', 'Workers'],
257
+ ['src', 'workers'],
258
+ ['src', 'Workers'],
259
+ ]);
260
+ const workerDiscoveryExtensions = new Set(['.js', '.mjs', '.cjs', '.ts']);
244
261
  const processorCache = new Map();
245
262
  let processorCacheSize = 0;
246
263
  const buildPersistenceBootstrapConfig = () => {
@@ -311,6 +328,299 @@ const parseCacheControl = (value) => {
311
328
  return Number.isFinite(parsed) ? { maxAge: parsed } : {};
312
329
  };
313
330
  const getProcessorSpecConfig = () => workersConfig.processorSpec;
331
+ const toPosixPath = (value) => value.split(path.sep).join('/');
332
+ const isUpperAlpha = (value) => /^[A-Z]$/.test(value);
333
+ const isLowerAlphaOrDigit = (value) => /^[a-z\d]$/.test(value);
334
+ const isAlphaNumeric = (value) => /^[A-Za-z\d]$/.test(value);
335
+ const shouldInsertWorkerNameDash = (previous, current, next) => {
336
+ if (!isAlphaNumeric(previous) || !isAlphaNumeric(current))
337
+ return false;
338
+ if (isLowerAlphaOrDigit(previous) && isUpperAlpha(current)) {
339
+ return true;
340
+ }
341
+ if (isUpperAlpha(previous) && isUpperAlpha(current) && isLowerAlphaOrDigit(next ?? '')) {
342
+ return true;
343
+ }
344
+ return false;
345
+ };
346
+ const toKebabWorkerName = (value) => {
347
+ if (!isNonEmptyString(value))
348
+ return value;
349
+ let normalized = '';
350
+ for (let index = 0; index < value.length; index += 1) {
351
+ const current = value[index] ?? '';
352
+ const previous = index > 0 ? (value[index - 1] ?? '') : '';
353
+ const next = value[index + 1];
354
+ if (current === ' ' || current === '_') {
355
+ if (!normalized.endsWith('-'))
356
+ normalized += '-';
357
+ continue;
358
+ }
359
+ if (shouldInsertWorkerNameDash(previous, current, next) && !normalized.endsWith('-')) {
360
+ normalized += '-';
361
+ }
362
+ normalized += current;
363
+ }
364
+ return normalized.replaceAll(/-+/g, '-');
365
+ };
366
+ const normalizeWorkerFileName = (fileName) => {
367
+ const baseName = fileName.replace(/\.[^.]+$/, '');
368
+ return toKebabWorkerName(baseName).toLowerCase();
369
+ };
370
+ const supportsWorkerFileDiscovery = () => {
371
+ return (isNodeRuntime() &&
372
+ canUseProjectFileImports() &&
373
+ typeof NodeSingletons.fs.readdirSync === 'function' &&
374
+ typeof NodeSingletons.fs.statSync === 'function');
375
+ };
376
+ const isSupportedWorkerModuleFile = (fileName) => {
377
+ if (!isNonEmptyString(fileName))
378
+ return false;
379
+ if (fileName.endsWith('.d.ts'))
380
+ return false;
381
+ if (fileName.includes('.test.') || fileName.includes('.spec.'))
382
+ return false;
383
+ return workerDiscoveryExtensions.has(path.extname(fileName));
384
+ };
385
+ const getWorkerDiscoveryDirectories = () => {
386
+ if (!supportsWorkerFileDiscovery())
387
+ return [];
388
+ const root = resolveProjectRoot();
389
+ return workerDiscoveryDirectories.map((segments) => path.join(root, ...segments));
390
+ };
391
+ const listWorkerDefinitionFiles = () => {
392
+ if (!supportsWorkerFileDiscovery())
393
+ return [];
394
+ const discovered = new Set();
395
+ for (const directory of getWorkerDiscoveryDirectories()) {
396
+ try {
397
+ if (!NodeSingletons.fs.existsSync(directory))
398
+ continue;
399
+ const stats = NodeSingletons.fs.statSync(directory);
400
+ if (!stats.isDirectory())
401
+ continue;
402
+ const entries = NodeSingletons.fs.readdirSync(directory, { withFileTypes: true });
403
+ for (const entry of entries) {
404
+ if (!entry.isFile() || !isSupportedWorkerModuleFile(entry.name))
405
+ continue;
406
+ discovered.add(path.join(directory, entry.name));
407
+ }
408
+ }
409
+ catch (error) {
410
+ Logger.debug(`Worker file discovery failed for directory: ${directory}`, error);
411
+ }
412
+ }
413
+ return Array.from(discovered);
414
+ };
415
+ const getProjectRelativeWorkerSpec = (sourcePath) => {
416
+ const relativePath = path.relative(resolveProjectRoot(), sourcePath);
417
+ return isNonEmptyString(relativePath) ? toPosixPath(relativePath) : toPosixPath(sourcePath);
418
+ };
419
+ const isQueueWorkerMeta = (value) => {
420
+ return (isObject(value) &&
421
+ isNonEmptyString(value['kindLabel']) &&
422
+ isNonEmptyString(value['defaultQueueName']) &&
423
+ typeof value['maxAttempts'] === 'number');
424
+ };
425
+ const getExportedQueueWorkerMeta = (mod) => {
426
+ for (const value of Object.values(mod)) {
427
+ if (!isObject(value))
428
+ continue;
429
+ const meta = value[queueWorkerMetaKey];
430
+ if (isQueueWorkerMeta(meta))
431
+ return meta;
432
+ }
433
+ return undefined;
434
+ };
435
+ const assignStringDefinitionField = (definition, key, value) => {
436
+ if (isNonEmptyString(value)) {
437
+ definition[key] = value;
438
+ }
439
+ };
440
+ const assignBooleanDefinitionField = (definition, key, value) => {
441
+ if (typeof value === 'boolean') {
442
+ definition[key] = value;
443
+ }
444
+ };
445
+ const assignObjectDefinitionField = (definition, key, value) => {
446
+ if (isObject(value)) {
447
+ definition[key] = value;
448
+ }
449
+ };
450
+ const assignConcurrencyDefinitionField = (definition, value) => {
451
+ if (typeof value === 'number' && Number.isFinite(value)) {
452
+ definition.concurrency = Math.max(1, Math.floor(value));
453
+ }
454
+ };
455
+ const assignProcessorDefinitionField = (definition, value) => {
456
+ if (isFunction(value)) {
457
+ definition.processor = value;
458
+ }
459
+ };
460
+ const normalizeFileWorkerDefinition = (value) => {
461
+ if (!isObject(value))
462
+ return undefined;
463
+ const definition = {};
464
+ assignStringDefinitionField(definition, 'name', value['name']);
465
+ assignStringDefinitionField(definition, 'queueName', value['queueName']);
466
+ assignStringDefinitionField(definition, 'version', value['version']);
467
+ assignStringDefinitionField(definition, 'status', value['status']);
468
+ assignStringDefinitionField(definition, 'region', value['region']);
469
+ assignStringDefinitionField(definition, 'processorSpec', value['processorSpec']);
470
+ assignBooleanDefinitionField(definition, 'autoStart', value['autoStart']);
471
+ assignBooleanDefinitionField(definition, 'activeStatus', value['activeStatus']);
472
+ assignObjectDefinitionField(definition, 'features', value['features']);
473
+ assignObjectDefinitionField(definition, 'infrastructure', value['infrastructure']);
474
+ assignObjectDefinitionField(definition, 'datacenter', value['datacenter']);
475
+ assignConcurrencyDefinitionField(definition, value['concurrency']);
476
+ assignProcessorDefinitionField(definition, value['processor']);
477
+ return definition;
478
+ };
479
+ const getExportedFileWorkerDefinition = (mod) => {
480
+ for (const key of fileWorkerDefinitionExportKeys) {
481
+ const normalized = normalizeFileWorkerDefinition(mod[key]);
482
+ if (normalized)
483
+ return normalized;
484
+ }
485
+ const defaultExport = mod['default'];
486
+ if (!isFunction(defaultExport)) {
487
+ const normalized = normalizeFileWorkerDefinition(defaultExport);
488
+ if (normalized)
489
+ return normalized;
490
+ }
491
+ return undefined;
492
+ };
493
+ const importWorkerDefinitionModule = async (sourcePath) => {
494
+ if (!supportsWorkerFileDiscovery())
495
+ return undefined;
496
+ try {
497
+ return (await import(NodeSingletons.url.pathToFileURL(sourcePath).href));
498
+ }
499
+ catch (error) {
500
+ Logger.debug(`Failed to import worker definition module: ${sourcePath}`, error);
501
+ return undefined;
502
+ }
503
+ };
504
+ const resolveDiscoveredWorkerProcessor = (definition, mod, sourcePath) => {
505
+ if (definition?.processor)
506
+ return definition.processor;
507
+ return extractZinTrustProcessor(mod, sourcePath) ?? pickProcessorFromModule(mod, sourcePath);
508
+ };
509
+ const resolveDiscoveredWorkerName = (definition, sourcePath) => {
510
+ return (definition?.name ?? normalizeWorkerFileName(path.basename(sourcePath, path.extname(sourcePath))));
511
+ };
512
+ const resolveDiscoveredQueueName = (definition, queueWorkerMeta, recordName) => {
513
+ if (definition?.queueName)
514
+ return definition.queueName;
515
+ if (queueWorkerMeta?.defaultQueueName)
516
+ return queueWorkerMeta.defaultQueueName;
517
+ return `${recordName}-queue`;
518
+ };
519
+ const resolveDiscoveredProcessorSpec = (definition, sourcePath) => {
520
+ return definition?.processorSpec ?? getProjectRelativeWorkerSpec(sourcePath);
521
+ };
522
+ const resolveDiscoveredVersion = (definition) => {
523
+ return definition?.version ?? '1.0.0';
524
+ };
525
+ const resolveDiscoveredStatus = (definition) => {
526
+ return definition?.status ?? WorkerCreationStatus.STOPPED;
527
+ };
528
+ const resolveDiscoveredAutoStart = (definition) => {
529
+ return definition?.autoStart ?? false;
530
+ };
531
+ const resolveDiscoveredConcurrency = (definition) => {
532
+ return definition?.concurrency ?? 1;
533
+ };
534
+ const resolveDiscoveredActiveStatus = (definition) => {
535
+ return definition?.activeStatus ?? true;
536
+ };
537
+ const buildDiscoveredWorkerRecord = (definition, queueWorkerMeta, sourcePath) => {
538
+ const recordName = resolveDiscoveredWorkerName(definition, sourcePath);
539
+ if (!isNonEmptyString(recordName))
540
+ return undefined;
541
+ return {
542
+ name: recordName,
543
+ queueName: resolveDiscoveredQueueName(definition, queueWorkerMeta, recordName),
544
+ version: resolveDiscoveredVersion(definition),
545
+ status: resolveDiscoveredStatus(definition),
546
+ autoStart: resolveDiscoveredAutoStart(definition),
547
+ concurrency: resolveDiscoveredConcurrency(definition),
548
+ region: definition?.region ?? null,
549
+ processorSpec: resolveDiscoveredProcessorSpec(definition, sourcePath),
550
+ activeStatus: resolveDiscoveredActiveStatus(definition),
551
+ features: definition?.features ?? null,
552
+ infrastructure: definition?.infrastructure ?? null,
553
+ datacenter: definition?.datacenter ?? null,
554
+ createdAt: new Date(),
555
+ updatedAt: new Date(),
556
+ };
557
+ };
558
+ const buildDiscoveredWorker = (mod, sourcePath) => {
559
+ const definition = getExportedFileWorkerDefinition(mod);
560
+ const queueWorkerMeta = getExportedQueueWorkerMeta(mod);
561
+ const processor = resolveDiscoveredWorkerProcessor(definition, mod, sourcePath);
562
+ const record = buildDiscoveredWorkerRecord(definition, queueWorkerMeta, sourcePath);
563
+ if (!record)
564
+ return undefined;
565
+ return {
566
+ record,
567
+ processor,
568
+ sourcePath,
569
+ };
570
+ };
571
+ const resolveStartFromPersistedRecord = async (name, persistenceOverride) => {
572
+ const persistedRecord = await getPersistedRecord(name, persistenceOverride);
573
+ const discovered = persistedRecord ? null : await getDiscoveredFileWorker(name);
574
+ const effectiveRecord = persistedRecord ?? discovered?.record ?? null;
575
+ if (!effectiveRecord) {
576
+ throw ErrorFactory.createNotFoundError(`Worker "${name}" not found in persistence store`);
577
+ }
578
+ return { record: effectiveRecord, discovered };
579
+ };
580
+ const resolveStartFromPersistedProcessor = async (name, record, discovered) => {
581
+ let processor = await resolveProcessor(name);
582
+ if (!processor && discovered?.processor) {
583
+ processor = discovered.processor;
584
+ }
585
+ const spec = record.processorSpec ?? undefined;
586
+ if (!processor && spec) {
587
+ try {
588
+ processor = await resolveProcessorSpec(spec);
589
+ }
590
+ catch (error) {
591
+ Logger.error(`Failed to resolve processor module for "${name}"`, error);
592
+ }
593
+ }
594
+ if (!processor) {
595
+ throw ErrorFactory.createConfigError(`Worker "${name}" processor is not registered or resolvable. Register the processor at startup or persist a processorSpec.`);
596
+ }
597
+ return processor;
598
+ };
599
+ const discoverFileBackedWorkers = async () => {
600
+ const files = listWorkerDefinitionFiles();
601
+ if (files.length === 0)
602
+ return [];
603
+ const discovered = new Map();
604
+ for (const filePath of files) {
605
+ // eslint-disable-next-line no-await-in-loop
606
+ const mod = await importWorkerDefinitionModule(filePath);
607
+ if (!mod)
608
+ continue;
609
+ const discoveredWorker = buildDiscoveredWorker(mod, filePath);
610
+ if (!discoveredWorker)
611
+ continue;
612
+ if (discovered.has(discoveredWorker.record.name)) {
613
+ Logger.warn(`Duplicate file-backed worker definition detected for "${discoveredWorker.record.name}". Keeping the first discovered module.`);
614
+ continue;
615
+ }
616
+ discovered.set(discoveredWorker.record.name, discoveredWorker);
617
+ }
618
+ return Array.from(discovered.values());
619
+ };
620
+ const getDiscoveredFileWorker = async (name) => {
621
+ const discovered = await discoverFileBackedWorkers();
622
+ return discovered.find((entry) => entry.record.name === name) ?? null;
623
+ };
314
624
  const computeSha256 = async (value) => {
315
625
  if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
316
626
  const data = new TextEncoder().encode(value);
@@ -384,7 +694,7 @@ const waitForWorkerConnection = async (worker, name, _queueName, timeoutMs) => {
384
694
  const checkConnection = async () => {
385
695
  try {
386
696
  // Check if worker is actually running
387
- const isRunning = await worker.isRunning();
697
+ const isRunning = await worker.isRunning(); // NOSONAR - BullMQ's isRunning method
388
698
  if (!isRunning) {
389
699
  throw ErrorFactory.createWorkerError('Worker not running');
390
700
  }
@@ -1086,14 +1396,11 @@ const resolveRedisConfigFromDirect = (config, context) => {
1086
1396
  if (typeof config.db === 'number') {
1087
1397
  normalizedDb = config.db;
1088
1398
  }
1089
- else if (typeof config.database === 'number') {
1090
- normalizedDb = config.database;
1091
- }
1092
1399
  return {
1093
1400
  host: requireRedisHost(config.host, context),
1094
1401
  port: config.port,
1095
1402
  db: normalizedDb,
1096
- password: config.password ?? Env.get('REDIS_PASSWORD', undefined),
1403
+ password: config.password ?? Env.get('REDIS_PASSWORD'),
1097
1404
  };
1098
1405
  };
1099
1406
  const resolveRedisConfig = (config, context) => isRedisEnvConfig(config)
@@ -1398,12 +1705,8 @@ const resolveAutoScalerConfig = (input) => {
1398
1705
  };
1399
1706
  const resolveWorkerOptions = (config, autoStart) => {
1400
1707
  const options = config.options ? { ...config.options } : {};
1401
- if (options.prefix === undefined) {
1402
- options.prefix = getBullMQSafeQueueName();
1403
- }
1404
- if (options.autorun === undefined) {
1405
- options.autorun = autoStart;
1406
- }
1708
+ options.prefix ??= getBullMQSafeQueueName();
1709
+ options.autorun ??= autoStart;
1407
1710
  if (options.connection)
1408
1711
  return options;
1409
1712
  const redisConfig = resolveRedisConfigWithFallback(config.infrastructure?.redis, undefined, 'Worker requires a connection. Provide options.connection or infrastructure.redis config', 'infrastructure.redis');
@@ -2098,26 +2401,11 @@ export const WorkerFactory = Object.freeze({
2098
2401
  * Start a worker from persisted storage when it is not registered.
2099
2402
  */
2100
2403
  async startFromPersisted(name, persistenceOverride) {
2101
- const record = await getPersistedRecord(name, persistenceOverride);
2102
- if (!record) {
2103
- throw ErrorFactory.createNotFoundError(`Worker "${name}" not found in persistence store`);
2104
- }
2404
+ const { record, discovered } = await resolveStartFromPersistedRecord(name, persistenceOverride);
2105
2405
  if (record.activeStatus === false) {
2106
2406
  throw ErrorFactory.createConfigError(`Worker "${name}" is inactive`);
2107
2407
  }
2108
- let processor = await resolveProcessor(name);
2109
- const spec = record.processorSpec ?? undefined;
2110
- if (!processor && spec) {
2111
- try {
2112
- processor = await resolveProcessorSpec(spec);
2113
- }
2114
- catch (error) {
2115
- Logger.error(`Failed to resolve processor module for "${name}"`, error);
2116
- }
2117
- }
2118
- if (!processor) {
2119
- throw ErrorFactory.createConfigError(`Worker "${name}" processor is not registered or resolvable. Register the processor at startup or persist a processorSpec.`);
2120
- }
2408
+ const processor = await resolveStartFromPersistedProcessor(name, record, discovered);
2121
2409
  await WorkerFactory.create({
2122
2410
  name: record.name,
2123
2411
  queueName: record.queueName,
@@ -2132,6 +2420,20 @@ export const WorkerFactory = Object.freeze({
2132
2420
  datacenter: record.datacenter,
2133
2421
  });
2134
2422
  },
2423
+ /**
2424
+ * List worker definitions discovered from project files.
2425
+ */
2426
+ async listFileBackedRecords() {
2427
+ const discovered = await discoverFileBackedWorkers();
2428
+ return discovered.map((entry) => entry.record);
2429
+ },
2430
+ /**
2431
+ * Get a file-backed worker definition by name.
2432
+ */
2433
+ async getFileBackedRecord(name) {
2434
+ const discovered = await getDiscoveredFileWorker(name);
2435
+ return discovered?.record ?? null;
2436
+ },
2135
2437
  /**
2136
2438
  * Get persisted worker record
2137
2439
  */
@@ -7,6 +7,7 @@
7
7
  * - Sets up auto-scaling and health checks
8
8
  * - Ensures graceful startup and shutdown
9
9
  */
10
+ import type { WorkerPersistenceConfig } from './WorkerFactory';
10
11
  export interface IWorkerInitOptions {
11
12
  /**
12
13
  * Whether to start resource monitoring on initialization
@@ -42,6 +43,22 @@ interface IInitState {
42
43
  autoScaling: boolean;
43
44
  shutdownHandlersRegistered: boolean;
44
45
  }
46
+ type AutoStartCandidate = {
47
+ name: string;
48
+ autoStart: boolean;
49
+ activeStatus?: boolean;
50
+ };
51
+ type PersistenceOverride = WorkerPersistenceConfig;
52
+ type AutoStartTask = AutoStartCandidate & {
53
+ persistenceOverride: PersistenceOverride;
54
+ source: 'database' | 'redis' | 'memory' | 'file';
55
+ };
56
+ export declare const buildFileBackedAutoStartTasks: (records: AutoStartCandidate[], warn?: (message: string) => void) => AutoStartTask[];
57
+ export declare const selectAutoStartTasks: (persistedTasks: AutoStartTask[], fileRecords: AutoStartCandidate[], warn?: (message: string) => void) => AutoStartTask[];
58
+ export declare const selectAutoStartNames: (persistedRecords: AutoStartCandidate[], fileRecords: AutoStartCandidate[], warn?: (message: string) => void) => {
59
+ names: string[];
60
+ source: "persisted" | "file" | "none";
61
+ };
45
62
  /**
46
63
  * Initialize the worker management system
47
64
  */
@@ -141,6 +141,54 @@ const collectAutoStartTasks = async () => {
141
141
  }
142
142
  return tasks;
143
143
  };
144
+ export const buildFileBackedAutoStartTasks = (records, warn = Logger.warn) => {
145
+ const tasks = [];
146
+ const seenWorkerNames = new Set();
147
+ const candidates = resolveAutoStartCandidates(records);
148
+ for (const record of candidates) {
149
+ if (seenWorkerNames.has(record.name)) {
150
+ warn(`Worker ${record.name} appears multiple times in file-backed discovery; keeping the first definition and skipping duplicates.`);
151
+ continue;
152
+ }
153
+ seenWorkerNames.add(record.name);
154
+ tasks.push({
155
+ ...record,
156
+ persistenceOverride: { driver: 'memory' },
157
+ source: 'file',
158
+ });
159
+ }
160
+ return tasks;
161
+ };
162
+ export const selectAutoStartTasks = (persistedTasks, fileRecords, warn = Logger.warn) => {
163
+ if (persistedTasks.length > 0) {
164
+ return persistedTasks;
165
+ }
166
+ return buildFileBackedAutoStartTasks(fileRecords, warn);
167
+ };
168
+ export const selectAutoStartNames = (persistedRecords, fileRecords, warn = Logger.warn) => {
169
+ const persistedNames = resolveAutoStartCandidates(persistedRecords).map((record) => record.name);
170
+ if (persistedNames.length > 0) {
171
+ return { names: persistedNames, source: 'persisted' };
172
+ }
173
+ const fileNames = buildFileBackedAutoStartTasks(fileRecords, warn).map((record) => record.name);
174
+ return { names: fileNames, source: fileNames.length > 0 ? 'file' : 'none' };
175
+ };
176
+ const collectFileBackedAutoStartTasks = async () => {
177
+ try {
178
+ const records = await WorkerFactory.listFileBackedRecords();
179
+ const tasks = buildFileBackedAutoStartTasks(records);
180
+ Logger.debug('File-backed auto-start discovery', {
181
+ totalRecords: records.length,
182
+ candidateCount: tasks.length,
183
+ });
184
+ return tasks;
185
+ }
186
+ catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ Logger.warn(`File-backed auto-start discovery failed: ${message}`);
189
+ }
190
+ return [];
191
+ };
144
192
  const isWorkerTrulyRunning = async (name) => {
145
193
  const existing = WorkerFactory.get(name);
146
194
  if (!existing)
@@ -238,14 +286,18 @@ async function autoStartPersistedWorkers() {
238
286
  return;
239
287
  }
240
288
  try {
241
- const candidates = await collectAutoStartTasks();
289
+ let candidates = await collectAutoStartTasks();
290
+ if (candidates.length === 0) {
291
+ candidates = await collectFileBackedAutoStartTasks();
292
+ }
242
293
  const results = await Promise.all(candidates.map(async (record) => autoStartOneWorker(record)));
243
294
  const startedCount = results.filter((item) => item.started).length;
244
295
  const skippedCount = results.filter((item) => item.skipped).length;
245
- Logger.info('Auto-started persisted workers', {
296
+ Logger.info('Auto-started workers', {
246
297
  total: candidates.length,
247
298
  started: startedCount,
248
299
  skipped: skippedCount,
300
+ source: candidates[0]?.source ?? 'none',
249
301
  });
250
302
  }
251
303
  catch (error) {
@@ -0,0 +1,12 @@
1
+ type DurableObjectState = {
2
+ storage: {
3
+ get: (key: string) => Promise<unknown>;
4
+ put: (key: string, value: unknown) => Promise<void>;
5
+ };
6
+ };
7
+ export declare class ZinTrustWorkerShutdownDurableObject {
8
+ private readonly state;
9
+ constructor(state: DurableObjectState);
10
+ fetch(request: Request): Promise<Response>;
11
+ }
12
+ export {};
@@ -0,0 +1,41 @@
1
+ import { Logger } from '@zintrust/core';
2
+ const loadState = async (state) => {
3
+ const stored = (await state.storage.get('shutdown'));
4
+ return stored ?? { shuttingDown: false };
5
+ };
6
+ const saveState = async (state, value) => {
7
+ await state.storage.put('shutdown', value);
8
+ };
9
+ // eslint-disable-next-line no-restricted-syntax
10
+ export class ZinTrustWorkerShutdownDurableObject {
11
+ state;
12
+ constructor(state) {
13
+ this.state = state;
14
+ }
15
+ async fetch(request) {
16
+ const url = new URL(request.url);
17
+ const path = url.pathname;
18
+ if (request.method === 'GET' && path === '/status') {
19
+ const current = await loadState(this.state);
20
+ return new Response(JSON.stringify(current), {
21
+ status: 200,
22
+ headers: { 'content-type': 'application/json' },
23
+ });
24
+ }
25
+ if (request.method === 'POST' && path === '/shutdown') {
26
+ const payload = (await request.json().catch(() => ({})));
27
+ const next = {
28
+ shuttingDown: true,
29
+ startedAt: new Date().toISOString(),
30
+ reason: payload.reason ?? 'manual',
31
+ };
32
+ await saveState(this.state, next);
33
+ Logger.info('Worker shutdown requested via Durable Object', next);
34
+ return new Response(JSON.stringify({ ok: true }), {
35
+ status: 202,
36
+ headers: { 'content-type': 'application/json' },
37
+ });
38
+ }
39
+ return new Response('Not Found', { status: 404 });
40
+ }
41
+ }