pixivflow 2.22.1 → 2.23.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.
Files changed (42) hide show
  1. package/dist/commands/SchedulerCommand.js +12 -3
  2. package/dist/commands/scheduler-runtime.d.ts +29 -0
  3. package/dist/commands/scheduler-runtime.js +54 -1
  4. package/dist/download/exec/DownloadExecutor.js +11 -2
  5. package/dist/download/handlers/IllustrationTargetHandler.js +15 -0
  6. package/dist/download/handlers/NovelTargetHandler.js +12 -2
  7. package/dist/interfaces/IDatabase.d.ts +11 -0
  8. package/dist/logger.d.ts +24 -0
  9. package/dist/logger.js +88 -11
  10. package/dist/observability/classify.d.ts +7 -0
  11. package/dist/observability/classify.js +51 -0
  12. package/dist/observability/context.d.ts +10 -0
  13. package/dist/observability/context.js +22 -0
  14. package/dist/observability/index.d.ts +7 -0
  15. package/dist/observability/index.js +36 -0
  16. package/dist/observability/types.d.ts +23 -0
  17. package/dist/observability/types.js +3 -0
  18. package/dist/package.json +1 -1
  19. package/dist/scheduler/RecoveryOutcome.d.ts +24 -0
  20. package/dist/scheduler/RecoveryOutcome.js +44 -0
  21. package/dist/scheduler/ScheduleTriggerServer.d.ts +4 -0
  22. package/dist/scheduler/SlotBusinessStatus.d.ts +32 -0
  23. package/dist/scheduler/SlotBusinessStatus.js +43 -0
  24. package/dist/scheduler/SlotCoordinator.d.ts +7 -0
  25. package/dist/scheduler/SlotCoordinator.js +13 -0
  26. package/dist/storage/Database.d.ts +4 -0
  27. package/dist/storage/Database.js +7 -0
  28. package/dist/storage/DatabaseMigration.js +21 -0
  29. package/dist/storage/repositories/SystemErrorRepository.d.ts +19 -0
  30. package/dist/storage/repositories/SystemErrorRepository.js +92 -0
  31. package/dist/version.js +1 -1
  32. package/dist/webui/package.json +1 -1
  33. package/dist/webui/routes/admin-logs.d.ts +3 -0
  34. package/dist/webui/routes/admin-logs.js +9 -0
  35. package/dist/webui/routes/handlers/admin-logs-handlers.d.ts +6 -0
  36. package/dist/webui/routes/handlers/admin-logs-handlers.js +106 -0
  37. package/dist/webui/routes/handlers/system-errors-handlers.d.ts +6 -0
  38. package/dist/webui/routes/handlers/system-errors-handlers.js +59 -0
  39. package/dist/webui/routes/system-errors.d.ts +3 -0
  40. package/dist/webui/routes/system-errors.js +9 -0
  41. package/dist/webui/server/server-routes.js +5 -0
  42. package/package.json +1 -1
@@ -12,6 +12,7 @@ const ScheduleTriggerServer_1 = require("../scheduler/ScheduleTriggerServer");
12
12
  const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
13
13
  const schedules_1 = require("../scheduler/schedules");
14
14
  const scheduler_runtime_1 = require("./scheduler-runtime");
15
+ const RecoveryOutcome_1 = require("../scheduler/RecoveryOutcome");
15
16
  const SchedulerIdleLifecycle_1 = require("./SchedulerIdleLifecycle");
16
17
  /**
17
18
  * Decide whether the authenticated HTTP trigger server should be mounted. It is
@@ -263,9 +264,17 @@ class SchedulerCommand extends Command_1.BaseCommand {
263
264
  recoverStatus: (targetId, requestId) => {
264
265
  const slot = runtime.database.slots.findRecoverySlot(requestId, targetId);
265
266
  const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
266
- return slot && cell
267
- ? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
268
- : null;
267
+ if (!slot || !cell)
268
+ return null;
269
+ const businessState = (0, RecoveryOutcome_1.recoveryOutcomeFor)(cell);
270
+ return {
271
+ requestId,
272
+ slotId: slot.id,
273
+ state: cell.status,
274
+ slotStatus: slot.status,
275
+ business_state: businessState,
276
+ message: (0, RecoveryOutcome_1.recoveryUserMessage)(businessState),
277
+ };
269
278
  },
270
279
  status: (scheduleId) => {
271
280
  const cfg = resolveConfig();
@@ -119,6 +119,35 @@ export interface SchedulerRuntime {
119
119
  close(): void;
120
120
  }
121
121
  export declare function notifyScheduleFailure(config: StandaloneConfig, database: Database, schedule: ScheduleConfig, failure: JobFailure): Promise<void>;
122
+ /**
123
+ * A `no_candidate` scan that attempted nothing and whose only candidate skips
124
+ * are `duplicate` is a TRUE dead-end: every candidate the scan surfaced was
125
+ * already delivered, and nothing was attempted to be widened. Advancing the
126
+ * fallback stage for this would re-fetch the SAME stale duplicate pool with a
127
+ * wider bound and burn the whole scheduler budget again (the production bug
128
+ * that turned an empty day on bot1 into a 30-minute OperationCancelledError).
129
+ * Terminalise it immediately instead of entering the fallback re-run loop.
130
+ */
131
+ export interface CandidateExhaustionDiagnostic {
132
+ stage: 'candidate_selection';
133
+ result: 'no_candidate';
134
+ reason: 'duplicate_exhausted' | 'filter_exhausted' | 'no_candidate';
135
+ searched: number;
136
+ duplicates: number;
137
+ filtered: number;
138
+ attempted: number;
139
+ }
140
+ /**
141
+ * Structured diagnostic for a terminal `no_candidate`: what the scan surfaced
142
+ * (`searched`), how many were already-delivered duplicates, how many were
143
+ * filtered by the run's own rules, and how many were actually attempted.
144
+ * Emitted as a JSON log so a Mini App can show "当天候选均已投稿" instead of
145
+ * treating the slot as a system failure.
146
+ */
147
+ export declare function candidateExhaustionDiagnostic(outcome: Extract<TargetOutcome, {
148
+ kind: 'no_candidate';
149
+ }>): CandidateExhaustionDiagnostic | null;
150
+ export declare function isDuplicateOnlyDeadEnd(outcome: TargetOutcome): boolean;
122
151
  /**
123
152
  * Expanded, still-bounded candidate scan bound for one fallback stage
124
153
  * (§schedule-recovery). Stage 0 is the primary pass; stage N scans
@@ -45,6 +45,8 @@ exports.EXECUTION_MODES = void 0;
45
45
  exports.withDeliveryMode = withDeliveryMode;
46
46
  exports.shouldTerminaliseAbortedSlot = shouldTerminaliseAbortedSlot;
47
47
  exports.notifyScheduleFailure = notifyScheduleFailure;
48
+ exports.candidateExhaustionDiagnostic = candidateExhaustionDiagnostic;
49
+ exports.isDuplicateOnlyDeadEnd = isDuplicateOnlyDeadEnd;
48
50
  exports.fallbackScanLimit = fallbackScanLimit;
49
51
  exports.createSchedulerRuntime = createSchedulerRuntime;
50
52
  exports.runWithTimeout = runWithTimeout;
@@ -201,6 +203,44 @@ function buildProxyUrl(network) {
201
203
  const auth = proxy.username ? `${proxy.username}:${proxy.password ?? ''}@` : '';
202
204
  return `${protocol}://${auth}${proxy.host}:${proxy.port}`;
203
205
  }
206
+ /**
207
+ * Structured diagnostic for a terminal `no_candidate`: what the scan surfaced
208
+ * (`searched`), how many were already-delivered duplicates, how many were
209
+ * filtered by the run's own rules, and how many were actually attempted.
210
+ * Emitted as a JSON log so a Mini App can show "当天候选均已投稿" instead of
211
+ * treating the slot as a system failure.
212
+ */
213
+ function candidateExhaustionDiagnostic(outcome) {
214
+ const scan = outcome.scan;
215
+ if (!scan)
216
+ return null;
217
+ const skipped = scan.skipped;
218
+ const duplicates = skipped.filter((s) => s.code === 'duplicate').length;
219
+ const filtered = skipped.filter((s) => s.code !== 'duplicate' && s.code !== 'unavailable').length;
220
+ const reason = isDuplicateOnlyDeadEnd(outcome)
221
+ ? 'duplicate_exhausted'
222
+ : filtered > 0
223
+ ? 'filter_exhausted'
224
+ : 'no_candidate';
225
+ return {
226
+ stage: 'candidate_selection',
227
+ result: 'no_candidate',
228
+ reason,
229
+ searched: skipped.length,
230
+ duplicates,
231
+ filtered,
232
+ attempted: scan.attempted,
233
+ };
234
+ }
235
+ function isDuplicateOnlyDeadEnd(outcome) {
236
+ if (outcome.kind !== 'no_candidate' || !outcome.scan)
237
+ return false;
238
+ const s = outcome.scan;
239
+ return (s.attempted === 0 &&
240
+ s.outages.length === 0 &&
241
+ s.skipped.length > 0 &&
242
+ s.skipped.every((skip) => skip.code === 'duplicate'));
243
+ }
204
244
  /**
205
245
  * Expanded, still-bounded candidate scan bound for one fallback stage
206
246
  * (§schedule-recovery). Stage 0 is the primary pass; stage N scans
@@ -536,13 +576,26 @@ async function createSchedulerRuntime(configPathArg) {
536
576
  // an exhausted occurrence reports the true cause — never a generic
537
577
  // "target did not complete".
538
578
  if (!scheduleSlot.manualRequestId &&
539
- (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') &&
579
+ (outcome.kind === 'duplicate' ||
580
+ (outcome.kind === 'no_candidate' && !isDuplicateOnlyDeadEnd(outcome))) &&
540
581
  coordinator.cellFallbackStage(scheduleSlot.slotId, target.id) < maxFallbackStages - 1) {
541
582
  coordinator.advanceFallback(scheduleSlot.slotId, target.id, outcome.kind === 'duplicate'
542
583
  ? `duplicate candidates (stage ${coordinator.cellFallbackStage(scheduleSlot.slotId, target.id)})`
543
584
  : outcome.reason ?? 'no eligible candidate', maxFallbackStages);
544
585
  return;
545
586
  }
587
+ if (outcome.kind === 'no_candidate') {
588
+ const diag = candidateExhaustionDiagnostic(outcome);
589
+ if (diag) {
590
+ logger_1.logger.info('candidate_exhaustion', {
591
+ schedule_id: scheduleSlot.scheduleId,
592
+ slot_id: scheduleSlot.slotId,
593
+ bot_id: scheduleSlot.scheduleId?.split('-')[0],
594
+ target_id: target.id,
595
+ ...diag,
596
+ });
597
+ }
598
+ }
546
599
  coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
547
600
  notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
548
601
  if (scheduleSlot.manualRequestId && (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate' ||
@@ -26,8 +26,11 @@ class DownloadExecutor {
26
26
  itemId: item?.id,
27
27
  ...(contextProvider?.(item, index) ?? {}),
28
28
  };
29
+ const itemId = item?.id ?? item?.pixivId ?? String(item);
30
+ logger_1.logger.info('download_started', { pixiv_id: itemId, attempt, stage: 'download' });
29
31
  try {
30
32
  const res = await task(item, index);
33
+ logger_1.logger.info('download_completed', { pixiv_id: itemId, attempt, stage: 'download' });
31
34
  results[index] = res;
32
35
  break;
33
36
  }
@@ -35,21 +38,27 @@ class DownloadExecutor {
35
38
  onError?.(error, item, index, attempt);
36
39
  const decision = recovery.decide(error, effectiveContext);
37
40
  onDecision?.(decision, { item, index, attempt, error });
41
+ const status = error?.status;
42
+ const errMsg = error instanceof Error ? error.message : String(error);
38
43
  if (decision.action === 'skip') {
39
- logger_1.logger.warn(`Skipping item at index ${index} after attempt ${attempt}${decision.reason ? `: ${decision.reason}` : ''}`);
44
+ logger_1.logger.warn(status ? `download_http_failed` : `download_failed`, {
45
+ pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, error: errMsg,
46
+ });
40
47
  break;
41
48
  }
42
49
  if (decision.action === 'fail') {
50
+ logger_1.logger.error(`download_failed`, { pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, error: errMsg });
43
51
  throw error instanceof Error ? error : new Error(String(error));
44
52
  }
45
53
  // retry/backoff
46
54
  const delayMs = decision.delayMs ?? 0;
55
+ logger_1.logger.warn(`download_retry`, { pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, retry_delay_ms: delayMs, error: errMsg });
47
56
  if (delayMs > 0) {
48
57
  await new Promise((resolve) => setTimeout(resolve, delayMs));
49
58
  }
50
59
  attempt += 1;
51
60
  if (attempt > (decision.maxAttempts ?? maxAttempts)) {
52
- logger_1.logger.error(`Max attempts reached for item at index ${index}; failing`);
61
+ logger_1.logger.error(`download_failed`, { pixiv_id: itemId, attempt, stage: 'download', http_status: status ?? null, error: errMsg });
53
62
  throw error instanceof Error ? error : new Error(String(error));
54
63
  }
55
64
  }
@@ -8,6 +8,8 @@ const pixiv_utils_1 = require("../../utils/pixiv-utils");
8
8
  const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
9
9
  const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
10
10
  const target_label_1 = require("../../utils/target-label");
11
+ const observability_1 = require("../../observability");
12
+ const context_1 = require("../../observability/context");
11
13
  const DownloadPlanner_1 = require("../plan/DownloadPlanner");
12
14
  const deliveryContext_1 = require("./deliveryContext");
13
15
  class IllustrationTargetHandler {
@@ -159,6 +161,13 @@ class IllustrationTargetHandler {
159
161
  classifyError(error, displayTag, mode, target) {
160
162
  const message = error instanceof Error ? error.message : String(error);
161
163
  const scan = this.scan ?? undefined;
164
+ const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
165
+ (0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)(target, this.execution, {
166
+ message,
167
+ stage: 'pixiv_download',
168
+ ...cls,
169
+ trace_id: this.execution?.slotId,
170
+ }), cls);
162
171
  // A hard job-level outage is named as such and is never recorded as a
163
172
  // no-candidate business outcome, whatever its message happens to look like.
164
173
  const outage = (0, TargetOutcome_1.classifyJobLevelOutage)(error);
@@ -166,6 +175,8 @@ class IllustrationTargetHandler {
166
175
  logger_1.logger.error(`Illustration ${mode === 'ranking' ? 'ranking' : 'tag'} ${displayTag} failed`, {
167
176
  error: message,
168
177
  errorType: error instanceof Error ? error.constructor.name : typeof error,
178
+ error_type: cls.error_type,
179
+ retryable: cls.retryable,
169
180
  ...(outage ? { jobLevelOutage: outage } : {}),
170
181
  });
171
182
  if (outage) {
@@ -508,9 +519,13 @@ class IllustrationTargetHandler {
508
519
  if (endpoint) {
509
520
  errorMessage = `${errorMessage} [URL: ${endpoint}]`;
510
521
  }
522
+ const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
523
+ (0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)({ type: 'illustration' }, this.execution, { message: errorMessage, stage: 'pixiv_download', ...cls, trace_id: this.execution?.slotId }), cls);
511
524
  logger_1.logger.error(message, {
512
525
  error: errorMessage,
513
526
  errorType: error instanceof Error ? error.constructor.name : typeof error,
527
+ error_type: cls.error_type,
528
+ retryable: cls.retryable,
514
529
  stack: error instanceof Error ? error.stack : undefined,
515
530
  });
516
531
  }
@@ -8,6 +8,8 @@ const pixiv_utils_1 = require("../../utils/pixiv-utils");
8
8
  const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
9
9
  const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
10
10
  const target_label_1 = require("../../utils/target-label");
11
+ const observability_1 = require("../../observability");
12
+ const context_1 = require("../../observability/context");
11
13
  const DownloadPlanner_1 = require("../plan/DownloadPlanner");
12
14
  const deliveryContext_1 = require("./deliveryContext");
13
15
  class NovelTargetHandler {
@@ -109,7 +111,7 @@ class NovelTargetHandler {
109
111
  return this.summarize();
110
112
  }
111
113
  catch (error) {
112
- return this.classifyError(error, displayTag, mode);
114
+ return this.classifyError(error, displayTag, mode, target);
113
115
  }
114
116
  }
115
117
  /**
@@ -165,15 +167,19 @@ class NovelTargetHandler {
165
167
  ...(scan ? { scan } : {}),
166
168
  };
167
169
  }
168
- classifyError(error, displayTag, mode) {
170
+ classifyError(error, displayTag, mode, target) {
169
171
  const message = error instanceof Error ? error.message : String(error);
170
172
  const scan = this.scan ?? undefined;
173
+ const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
174
+ (0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)(target, this.execution, { message, stage: 'pixiv_download', ...cls, trace_id: this.execution?.slotId }), cls);
171
175
  // A hard job-level outage is named as such and is never recorded as a
172
176
  // no-candidate business outcome, whatever its message happens to look like.
173
177
  const outage = (0, TargetOutcome_1.classifyJobLevelOutage)(error);
174
178
  this.database.logExecution(displayTag, 'novel', 'failed', message);
175
179
  logger_1.logger.error(`Novel ${mode === 'ranking' ? 'ranking' : 'tag'} ${displayTag} failed`, {
176
180
  error: message,
181
+ error_type: cls.error_type,
182
+ retryable: cls.retryable,
177
183
  ...(outage ? { jobLevelOutage: outage } : {}),
178
184
  });
179
185
  if (outage) {
@@ -535,9 +541,13 @@ class NovelTargetHandler {
535
541
  if (endpoint) {
536
542
  errorMessage = `${errorMessage} [URL: ${endpoint}]`;
537
543
  }
544
+ const cls = (0, observability_1.classifySystemError)(error, error.status, 'pixiv_download');
545
+ (0, observability_1.recordSystemError)(this.database, (0, context_1.targetErrorContext)({ type: 'novel' }, this.execution, { message: errorMessage, stage: 'pixiv_download', ...cls, trace_id: this.execution?.slotId }), cls);
538
546
  logger_1.logger.error(message, {
539
547
  error: errorMessage,
540
548
  errorType: error instanceof Error ? error.constructor.name : typeof error,
549
+ error_type: cls.error_type,
550
+ retryable: cls.retryable,
541
551
  stack: error instanceof Error ? error.stack : undefined,
542
552
  });
543
553
  }
@@ -112,5 +112,16 @@ export interface IDatabase {
112
112
  * Update file path in database
113
113
  */
114
114
  updateFilePath(pixivId: string, type: 'illustration' | 'novel', oldPath: string, newPath: string): number;
115
+ /**
116
+ * Durable system-error ledger (observability; never throws).
117
+ */
118
+ readonly systemErrors: {
119
+ record(input: import('../observability/types').SystemErrorInput): void;
120
+ list(opts?: Record<string, unknown>): import('../observability/types').SystemErrorRow[];
121
+ markResolved(id: number): {
122
+ changes: number;
123
+ };
124
+ countSince(botId: string | null, hours: number): number;
125
+ };
115
126
  }
116
127
  //# sourceMappingURL=IDatabase.d.ts.map
package/dist/logger.d.ts CHANGED
@@ -1,18 +1,42 @@
1
1
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+ export type LogFormat = 'text' | 'json';
3
+ export interface LogContext {
4
+ service?: string;
5
+ component?: string;
6
+ bot_id?: string;
7
+ schedule_id?: string;
8
+ slot_id?: string;
9
+ pixiv_id?: string;
10
+ stage?: string;
11
+ trace_id?: string;
12
+ [key: string]: unknown;
13
+ }
2
14
  interface LogMeta {
3
15
  [key: string]: unknown;
4
16
  }
5
17
  declare class Logger {
6
18
  private static readonly levelOrder;
7
19
  private threshold;
20
+ private format;
8
21
  private logPath;
9
22
  setLevel(level: LogLevel): void;
23
+ setFormat(format: LogFormat): void;
24
+ /** Run `fn` with a log context that is merged into every log line emitted inside. */
25
+ runWithContext<T>(ctx: LogContext, fn: () => T): T;
10
26
  setLogPath(path: string): void;
11
27
  debug(message: string, meta?: LogMeta): void;
12
28
  info(message: string, meta?: LogMeta): void;
13
29
  warn(message: string, meta?: LogMeta): void;
14
30
  error(message: string, meta?: LogMeta): void;
15
31
  private write;
32
+ /**
33
+ * Rotate the current log file when it exceeds the size cap:
34
+ * rename to <name>-<ts>.log.gz, then prune archived logs older than
35
+ * retention days. Run inside write (low-traffic scheduler, acceptable;
36
+ * use a background job if this ever becomes a high-rate service).
37
+ */
38
+ private rotateIfNeeded;
39
+ private pruneArchives;
16
40
  }
17
41
  export declare const logger: Logger;
18
42
  export {};
package/dist/logger.js CHANGED
@@ -3,6 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.logger = void 0;
4
4
  const fs_1 = require("fs");
5
5
  const path_1 = require("path");
6
+ const node_async_hooks_1 = require("node:async_hooks");
7
+ const node_zlib_1 = require("node:zlib");
8
+ const ctxStore = new node_async_hooks_1.AsyncLocalStorage();
9
+ function defaultMaxBytes() {
10
+ const parsed = Number.parseInt(process.env.PIXIV_LOG_MAX_BYTES ?? '', 10);
11
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 20 * 1024 * 1024;
12
+ }
13
+ function retentionDays() {
14
+ const parsed = Number.parseInt(process.env.PIXIV_LOG_RETENTION_DAYS ?? '', 10);
15
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
16
+ }
6
17
  class Logger {
7
18
  static levelOrder = {
8
19
  debug: 10,
@@ -11,13 +22,20 @@ class Logger {
11
22
  error: 40,
12
23
  };
13
24
  threshold = 'info';
25
+ format = process.env.PIXIV_LOG_FORMAT === 'json' ? 'json' : 'text';
14
26
  logPath = null;
15
27
  setLevel(level) {
16
28
  this.threshold = level;
17
29
  }
30
+ setFormat(format) {
31
+ this.format = format;
32
+ }
33
+ /** Run `fn` with a log context that is merged into every log line emitted inside. */
34
+ runWithContext(ctx, fn) {
35
+ return ctxStore.run(ctx, fn);
36
+ }
18
37
  setLogPath(path) {
19
38
  this.logPath = path;
20
- // Ensure log directory exists
21
39
  const logDir = (0, path_1.join)(path, '..');
22
40
  if (!(0, fs_1.existsSync)(logDir)) {
23
41
  (0, fs_1.mkdirSync)(logDir, { recursive: true });
@@ -36,13 +54,19 @@ class Logger {
36
54
  this.write('error', message, meta);
37
55
  }
38
56
  write(level, message, meta) {
39
- if (Logger.levelOrder[level] < Logger.levelOrder[this.threshold]) {
57
+ if (Logger.levelOrder[level] < Logger.levelOrder[this.threshold])
40
58
  return;
41
- }
42
- const timestamp = new Date().toISOString();
43
- const payload = meta ? `${message} ${JSON.stringify(meta)}` : message;
44
- const logLine = `[${timestamp}] [${level.toUpperCase()}] ${payload}`;
45
- // Output to console
59
+ const ctx = ctxStore.getStore();
60
+ const record = {
61
+ timestamp: new Date().toISOString(),
62
+ level,
63
+ message,
64
+ ...(ctx ?? {}),
65
+ ...(meta ?? {}),
66
+ };
67
+ const logLine = this.format === 'json'
68
+ ? JSON.stringify(record)
69
+ : `[${record.timestamp}] [${level.toUpperCase()}] ${message}${meta && Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''}`;
46
70
  switch (level) {
47
71
  case 'debug':
48
72
  console.debug(logLine);
@@ -56,20 +80,73 @@ class Logger {
56
80
  case 'error':
57
81
  console.error(logLine);
58
82
  break;
59
- default:
60
- console.log(logLine);
83
+ default: console.log(logLine);
61
84
  }
62
- // Write to file if log path is set
63
85
  if (this.logPath) {
64
86
  try {
87
+ this.rotateIfNeeded();
65
88
  (0, fs_1.appendFileSync)(this.logPath, logLine + '\n', 'utf-8');
66
89
  }
67
90
  catch (error) {
68
- // Silently fail if file write fails to avoid breaking the application
69
91
  console.error('Failed to write to log file', error);
70
92
  }
71
93
  }
72
94
  }
95
+ /**
96
+ * Rotate the current log file when it exceeds the size cap:
97
+ * rename to <name>-<ts>.log.gz, then prune archived logs older than
98
+ * retention days. Run inside write (low-traffic scheduler, acceptable;
99
+ * use a background job if this ever becomes a high-rate service).
100
+ */
101
+ rotateIfNeeded() {
102
+ if (!this.logPath)
103
+ return;
104
+ let size = 0;
105
+ try {
106
+ if ((0, fs_1.existsSync)(this.logPath))
107
+ size = (0, fs_1.statSync)(this.logPath).size;
108
+ }
109
+ catch {
110
+ return;
111
+ }
112
+ if (size < defaultMaxBytes())
113
+ return;
114
+ try {
115
+ const dir = (0, path_1.join)(this.logPath, '..');
116
+ const base = (0, path_1.basename)(this.logPath);
117
+ const name = base.replace(/\.log$/, '');
118
+ const stamped = (0, path_1.join)(dir, `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
119
+ (0, fs_1.renameSync)(this.logPath, stamped);
120
+ (0, fs_1.writeFileSync)(`${stamped}.gz`, (0, node_zlib_1.gzipSync)((0, fs_1.readFileSync)(stamped)));
121
+ (0, fs_1.unlinkSync)(stamped);
122
+ this.pruneArchives(dir, `${name}-`, retentionDays());
123
+ }
124
+ catch (error) {
125
+ console.error('Failed to rotate log file', error);
126
+ }
127
+ }
128
+ pruneArchives(dir, prefix, days) {
129
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
130
+ let entries;
131
+ try {
132
+ entries = (0, fs_1.readdirSync)(dir);
133
+ }
134
+ catch {
135
+ return;
136
+ }
137
+ for (const entry of entries) {
138
+ if (!entry.startsWith(prefix) || !entry.endsWith('.log.gz'))
139
+ continue;
140
+ try {
141
+ const p = (0, path_1.join)(dir, entry);
142
+ if ((0, fs_1.statSync)(p).mtimeMs < cutoff)
143
+ (0, fs_1.unlinkSync)(p);
144
+ }
145
+ catch {
146
+ // best effort
147
+ }
148
+ }
149
+ }
73
150
  }
74
151
  exports.logger = new Logger();
75
152
  //# sourceMappingURL=logger.js.map
@@ -0,0 +1,7 @@
1
+ import { SystemErrorClassification } from './types';
2
+ /**
3
+ * Map an error (message / HTTP status / constructor name) to a stable
4
+ * download/system error class and whether it is safe to retry.
5
+ */
6
+ export declare function classifySystemError(error: unknown, httpStatus?: number | null, stage?: string, messageHint?: string): SystemErrorClassification;
7
+ //# sourceMappingURL=classify.d.ts.map
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifySystemError = classifySystemError;
4
+ const KNOWN_SUBTYPES = {
5
+ pixiv_network_5xx: 'NETWORK_TIMEOUT',
6
+ pixiv_network_4xx: 'NETWORK_TIMEOUT',
7
+ };
8
+ /**
9
+ * Map an error (message / HTTP status / constructor name) to a stable
10
+ * download/system error class and whether it is safe to retry.
11
+ */
12
+ function classifySystemError(error, httpStatus, stage, messageHint) {
13
+ const message = messageHint ?? (error instanceof Error ? error.message : String(error ?? ''));
14
+ const name = error instanceof Error ? error.constructor.name : typeof error;
15
+ const status = typeof httpStatus === 'number' ? httpStatus : null;
16
+ if (name === 'ConfigError' || /does not configure|unsupported .*target|invalid config/i.test(message)) {
17
+ return { error_type: 'CONFIG_ERROR', retryable: false };
18
+ }
19
+ if (status === 429 || /429|rate.?limit|cooldown/i.test(message)) {
20
+ return { error_type: 'PIXIV_RATE_LIMITED', retryable: true };
21
+ }
22
+ if (status === 401 || status === 403 || /login|auth|token|forbidden|invalid.*refresh|expired.*token/i.test(message)) {
23
+ const cdn = /cdn|i\.pximg|img\.pixiv/i.test(message);
24
+ return cdn
25
+ ? { error_type: 'PIXIV_CDN_FORBIDDEN', retryable: false }
26
+ : { error_type: 'PIXIV_AUTH_FAILED', retryable: status === 401 };
27
+ }
28
+ if (status === 404 || /404|not found|does not exist/i.test(message)) {
29
+ return { error_type: 'PIXIV_NOT_FOUND', retryable: false };
30
+ }
31
+ if (/timeout|timed out|abort|aborted|econn|enotfound|etimedout|network|socket|refused|unavailable|ECONN/i.test(message) || name === 'AbortError') {
32
+ return { error_type: 'NETWORK_TIMEOUT', retryable: true };
33
+ }
34
+ if (/corrupt|invalid image|unexpected eof|truncated/i.test(message)) {
35
+ return { error_type: 'DOWNLOAD_CORRUPTED', retryable: true };
36
+ }
37
+ if (stage === 'image_process' || /convert|processing|\bimage process/i.test(message)) {
38
+ return { error_type: 'IMAGE_PROCESS_FAILED', retryable: stage === 'image_process' };
39
+ }
40
+ if (/telegram|sendMessage|upload/i.test(message)) {
41
+ return { error_type: 'TELEGRAM_UPLOAD_FAILED', retryable: false };
42
+ }
43
+ if (typeof status === 'number' && status >= 500) {
44
+ return { error_type: 'NETWORK_TIMEOUT', retryable: true };
45
+ }
46
+ if (name in KNOWN_SUBTYPES) {
47
+ return { error_type: KNOWN_SUBTYPES[name], retryable: true };
48
+ }
49
+ return { error_type: 'INTERNAL_ERROR', retryable: true };
50
+ }
51
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1,10 @@
1
+ import { TargetConfig } from '../config';
2
+ import { TargetExecutionContext } from '../scheduler/WorkIdentity';
3
+ import { SystemErrorInput } from './types';
4
+ /**
5
+ * Build the standard structured error fields for a target-cell handler from
6
+ * the cell identity plus the current stage/error. Nothing here throws.
7
+ * bot_id / schedule_id derive from the slot identity (`bot1-daily@...`).
8
+ */
9
+ export declare function targetErrorContext(target: TargetConfig, execution: TargetExecutionContext | null, extra: Partial<SystemErrorInput>): SystemErrorInput;
10
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.targetErrorContext = targetErrorContext;
4
+ /**
5
+ * Build the standard structured error fields for a target-cell handler from
6
+ * the cell identity plus the current stage/error. Nothing here throws.
7
+ * bot_id / schedule_id derive from the slot identity (`bot1-daily@...`).
8
+ */
9
+ function targetErrorContext(target, execution, extra) {
10
+ const slotId = execution?.slotId;
11
+ const scheduleId = slotId ? slotId.split('@')[0] : undefined;
12
+ const botId = scheduleId ? scheduleId.split('-')[0] : undefined;
13
+ return {
14
+ service: 'pixivflow',
15
+ component: target.type === 'novel' ? 'novel_target' : 'illustration_target',
16
+ schedule_id: scheduleId,
17
+ bot_id: botId,
18
+ slot_id: slotId,
19
+ ...extra,
20
+ };
21
+ }
22
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1,7 @@
1
+ import { IDatabase } from '../interfaces/IDatabase';
2
+ import { SystemErrorClassification, SystemErrorInput } from './types';
3
+ export { classifySystemError } from './classify';
4
+ export * from './types';
5
+ /** Record a structured system error with taxonomy; never throws. */
6
+ export declare function recordSystemError(database: Pick<IDatabase, 'systemErrors'>, input: SystemErrorInput, classified?: SystemErrorClassification | null): void;
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.classifySystemError = void 0;
18
+ exports.recordSystemError = recordSystemError;
19
+ const logger_1 = require("../logger");
20
+ const classify_1 = require("./classify");
21
+ var classify_2 = require("./classify");
22
+ Object.defineProperty(exports, "classifySystemError", { enumerable: true, get: function () { return classify_2.classifySystemError; } });
23
+ __exportStar(require("./types"), exports);
24
+ /** Record a structured system error with taxonomy; never throws. */
25
+ function recordSystemError(database, input, classified = null) {
26
+ try {
27
+ const cls = classified ?? (0, classify_1.classifySystemError)(new Error(input.message), input.http_status ?? null, input.stage);
28
+ database.systemErrors.record({ ...input, ...cls });
29
+ logger_1.logger.error(`system_error recorded`, { error_type: cls.error_type, retryable: cls.retryable, ...input });
30
+ }
31
+ catch (error) {
32
+ // Observability must never break the business path.
33
+ console.error('Failed to persist system error', error);
34
+ }
35
+ }
36
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,23 @@
1
+ export type SystemErrorType = 'PIXIV_AUTH_FAILED' | 'PIXIV_RATE_LIMITED' | 'PIXIV_NOT_FOUND' | 'PIXIV_CDN_FORBIDDEN' | 'NETWORK_TIMEOUT' | 'DOWNLOAD_CORRUPTED' | 'IMAGE_PROCESS_FAILED' | 'TELEGRAM_UPLOAD_FAILED' | 'CONFIG_ERROR' | 'INTERNAL_ERROR';
2
+ export interface SystemErrorClassification {
3
+ error_type: SystemErrorType;
4
+ retryable: boolean;
5
+ }
6
+ export interface SystemErrorInput extends Partial<SystemErrorClassification> {
7
+ service?: string;
8
+ component?: string;
9
+ bot_id?: string;
10
+ schedule_id?: string;
11
+ slot_id?: string;
12
+ pixiv_id?: string;
13
+ stage?: string;
14
+ message: string;
15
+ http_status?: number | null;
16
+ trace_id?: string;
17
+ }
18
+ export interface SystemErrorRow extends SystemErrorInput {
19
+ id: number;
20
+ created_at: string;
21
+ resolved_at: string | null;
22
+ }
23
+ //# sourceMappingURL=types.d.ts.map