pixivflow 2.22.1 → 2.24.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 (60) 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/FileService.d.ts +10 -0
  5. package/dist/download/FileService.js +6 -0
  6. package/dist/download/NovelDownloader.js +56 -0
  7. package/dist/download/exec/DownloadExecutor.js +11 -2
  8. package/dist/download/handlers/IllustrationTargetHandler.js +15 -0
  9. package/dist/download/handlers/NovelTargetHandler.js +12 -2
  10. package/dist/download/novelMarkers.d.ts +54 -0
  11. package/dist/download/novelMarkers.js +122 -0
  12. package/dist/interfaces/IDatabase.d.ts +11 -0
  13. package/dist/interfaces/IFileService.d.ts +4 -0
  14. package/dist/logger.d.ts +24 -0
  15. package/dist/logger.js +88 -11
  16. package/dist/notification/NotificationPolicy.js +18 -5
  17. package/dist/observability/classify.d.ts +7 -0
  18. package/dist/observability/classify.js +51 -0
  19. package/dist/observability/context.d.ts +10 -0
  20. package/dist/observability/context.js +22 -0
  21. package/dist/observability/index.d.ts +7 -0
  22. package/dist/observability/index.js +36 -0
  23. package/dist/observability/types.d.ts +23 -0
  24. package/dist/observability/types.js +3 -0
  25. package/dist/package.json +1 -1
  26. package/dist/scheduler/RecoveryOutcome.d.ts +24 -0
  27. package/dist/scheduler/RecoveryOutcome.js +44 -0
  28. package/dist/scheduler/ScheduleTriggerServer.d.ts +4 -0
  29. package/dist/scheduler/SlotBusinessStatus.d.ts +32 -0
  30. package/dist/scheduler/SlotBusinessStatus.js +43 -0
  31. package/dist/scheduler/SlotCoordinator.d.ts +7 -0
  32. package/dist/scheduler/SlotCoordinator.js +13 -0
  33. package/dist/storage/Database.d.ts +4 -0
  34. package/dist/storage/Database.js +7 -0
  35. package/dist/storage/DatabaseMigration.js +21 -0
  36. package/dist/storage/repositories/SystemErrorRepository.d.ts +19 -0
  37. package/dist/storage/repositories/SystemErrorRepository.js +92 -0
  38. package/dist/version.js +1 -1
  39. package/dist/webui/package.json +1 -1
  40. package/dist/webui/routes/admin-logs.d.ts +3 -0
  41. package/dist/webui/routes/admin-logs.js +9 -0
  42. package/dist/webui/routes/handlers/admin-logs-handlers.d.ts +6 -0
  43. package/dist/webui/routes/handlers/admin-logs-handlers.js +106 -0
  44. package/dist/webui/routes/handlers/system-errors-handlers.d.ts +6 -0
  45. package/dist/webui/routes/handlers/system-errors-handlers.js +59 -0
  46. package/dist/webui/routes/system-errors.d.ts +3 -0
  47. package/dist/webui/routes/system-errors.js +9 -0
  48. package/dist/webui/server/server-routes.js +5 -0
  49. package/node_modules/@redtidev/pixiv-client/dist/app-api/novels.d.ts.map +1 -1
  50. package/node_modules/@redtidev/pixiv-client/dist/app-api/novels.js +18 -2
  51. package/node_modules/@redtidev/pixiv-client/dist/app-api/novels.js.map +1 -1
  52. package/node_modules/@redtidev/pixiv-client/dist/index.d.ts +1 -1
  53. package/node_modules/@redtidev/pixiv-client/dist/index.d.ts.map +1 -1
  54. package/node_modules/@redtidev/pixiv-client/dist/index.js.map +1 -1
  55. package/node_modules/@redtidev/pixiv-client/dist/models/index.d.ts +40 -4
  56. package/node_modules/@redtidev/pixiv-client/dist/models/index.d.ts.map +1 -1
  57. package/node_modules/@redtidev/pixiv-client/src/app-api/novels.ts +28 -2
  58. package/node_modules/@redtidev/pixiv-client/src/index.ts +3 -0
  59. package/node_modules/@redtidev/pixiv-client/src/models/index.ts +34 -4
  60. package/package.json +2 -2
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
@@ -50,12 +50,18 @@ class NotificationPolicy {
50
50
  };
51
51
  noteOutcome(slotId, slot, schedule, target, outcome) {
52
52
  const name = this.targetName(target);
53
- if (!name)
53
+ if (!name) {
54
+ logger_1.logger.debug('Notification skipped: target has no delivery target name', {
55
+ targetId: target.id ?? target.filterTag ?? target.tag ?? target.type,
56
+ });
54
57
  return;
58
+ }
55
59
  // No notifiable endpoint configured: drop the notification instead of
56
60
  // enqueuing it against a submission target that will reject it forever.
57
- if (!this.notifiableTargets().has(name))
61
+ if (!this.notifiableTargets().has(name)) {
62
+ logger_1.logger.debug(`Notification skipped for ${name}: no notifiable endpoint configured`, { targetName: name });
58
63
  return;
64
+ }
59
65
  const label = target.id || target.filterTag || target.tag || target.type;
60
66
  if (outcome.kind === 'no_candidate' && target.noMatchPolicy?.notify === true) {
61
67
  this.send(name, NotificationPolicy.keys.noMatch(slotId, target.id ?? label), [
@@ -77,8 +83,16 @@ class NotificationPolicy {
77
83
  }
78
84
  /** One consolidated summary per slot, delivered to every notifying target's endpoint. */
79
85
  sendSlotSummary(slot, schedule, rows) {
80
- if (slot.manualRequestId || rows.length === 0 || rows.some((r) => !['submitted', 'no_candidate', 'duplicate', 'failed'].includes(r.status)))
86
+ if (slot.manualRequestId || rows.length === 0)
81
87
  return;
88
+ const nonSummaryRows = rows.filter((r) => !['submitted', 'no_candidate', 'duplicate', 'failed'].includes(r.status));
89
+ if (nonSummaryRows.length) {
90
+ logger_1.logger.warn(`Slot summary skipped: ${nonSummaryRows.length} row(s) with non-summarizable status`, {
91
+ slotId: slot.slotId,
92
+ targets: nonSummaryRows.map((r) => ({ targetId: r.targetId, status: r.status })),
93
+ });
94
+ return;
95
+ }
82
96
  const memberIds = new Set(rows.map((r) => r.targetId));
83
97
  const targets = this.targetsWithUrl('scheduleOutcomeUrl', memberIds);
84
98
  if (targets.size === 0)
@@ -174,8 +188,7 @@ class NotificationPolicy {
174
188
  }
175
189
  catch (error) {
176
190
  // Durable enqueue failure must not unwind the content run.
177
- // eslint-disable-next-line no-console
178
- console.warn('notification enqueue failed', { targetName, key, error: error.message });
191
+ logger_1.logger.warn('notification enqueue failed', { targetName, key, error: error.message });
179
192
  }
180
193
  }
181
194
  /**
@@ -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
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.22.1",
4
+ "version": "2.24.0",
5
5
  "private": true
6
6
  }
@@ -0,0 +1,24 @@
1
+ import type { SlotItemRecord } from '../storage/repositories/SlotRepository';
2
+ /**
3
+ * Business outcome for one manual recovery request (「放宽条件重试」).
4
+ *
5
+ * Deliberately NOT the raw execution cell status: a recovery that found only
6
+ * already-published works is a NORMAL end (`recovery_duplicate_only`), never an
7
+ * `internal_error`. Callers (ScheduleTriggerServer / Mini App) get the
8
+ * user-facing `message` and admin-facing `business_state` together so the UI
9
+ * never has to translate internal terms.
10
+ */
11
+ export type RecoveryOutcomeState = 'recovery_pending' | 'recovery_running' | 'recovery_success' | 'recovery_no_candidate' | 'recovery_duplicate_only' | 'recovery_failed';
12
+ export declare function recoveryUserMessage(state: RecoveryOutcomeState): string;
13
+ /**
14
+ * Map one durable recovery cell to a recovery business outcome.
15
+ *
16
+ * - terminal `submitted` (+ workId) → success
17
+ * - terminal `no_candidate` with terminal reason `duplicate_exhausted` →
18
+ * duplicate_only
19
+ * - terminal `no_candidate` otherwise → no_candidate
20
+ * - terminal `failed` → failed
21
+ * - non-terminal (`pending`/`selected`/`running`/`delivery_pending`) → running/pending
22
+ */
23
+ export declare function recoveryOutcomeFor(cell: SlotItemRecord | null): RecoveryOutcomeState;
24
+ //# sourceMappingURL=RecoveryOutcome.d.ts.map
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recoveryUserMessage = recoveryUserMessage;
4
+ exports.recoveryOutcomeFor = recoveryOutcomeFor;
5
+ const USER_MESSAGES = {
6
+ recovery_pending: '正在等待恢复任务...',
7
+ recovery_running: '正在尝试扩大搜索范围...',
8
+ recovery_success: '恢复成功,已找到并发布新作品。',
9
+ recovery_no_candidate: '扩大搜索范围后,仍未找到符合条件的新作品。\n任务已正常结束。',
10
+ recovery_duplicate_only: '扩大搜索范围后,找到的作品均已发布过。\n没有新的内容可发布。',
11
+ recovery_failed: '恢复任务执行失败。\n请查看日志或稍后重试。',
12
+ };
13
+ function recoveryUserMessage(state) {
14
+ return USER_MESSAGES[state];
15
+ }
16
+ /**
17
+ * Map one durable recovery cell to a recovery business outcome.
18
+ *
19
+ * - terminal `submitted` (+ workId) → success
20
+ * - terminal `no_candidate` with terminal reason `duplicate_exhausted` →
21
+ * duplicate_only
22
+ * - terminal `no_candidate` otherwise → no_candidate
23
+ * - terminal `failed` → failed
24
+ * - non-terminal (`pending`/`selected`/`running`/`delivery_pending`) → running/pending
25
+ */
26
+ function recoveryOutcomeFor(cell) {
27
+ if (!cell)
28
+ return 'recovery_pending';
29
+ if (cell.status === 'submitted')
30
+ return 'recovery_success';
31
+ if (cell.status === 'no_candidate') {
32
+ return cell.terminalReasonCode === 'duplicate_exhausted'
33
+ ? 'recovery_duplicate_only'
34
+ : 'recovery_no_candidate';
35
+ }
36
+ if (cell.status === 'duplicate')
37
+ return 'recovery_duplicate_only';
38
+ if (cell.status === 'failed')
39
+ return 'recovery_failed';
40
+ if (['pending', 'selected', 'artifact_ready', 'delivery_pending'].includes(cell.status))
41
+ return 'recovery_running';
42
+ return 'recovery_pending';
43
+ }
44
+ //# sourceMappingURL=RecoveryOutcome.js.map
@@ -126,6 +126,10 @@ export interface TriggerHandlers {
126
126
  slotId: string;
127
127
  state: string;
128
128
  slotStatus: string;
129
+ /** Recovery business outcome (recovery_success / no_candidate / duplicate_only / failed / running / pending). */
130
+ business_state?: string;
131
+ /** User-facing copy; never leaks internal error names. */
132
+ message?: string;
129
133
  } | null;
130
134
  }
131
135
  export declare class ScheduleTriggerServer {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Business outcome taxonomy for one schedule occurrence.
3
+ *
4
+ * RULES (intent):
5
+ * - "no content to publish" is a NORMAL endpoint, not a system failure.
6
+ * - System failures (pixiv/network/download/processing/delivery) must be kept
7
+ * disjoint from business no-content states so monitoring and Mini App never
8
+ * show "internal_error" for an empty but healthy run.
9
+ *
10
+ * This is a DERIVED verdict over the existing terminal cell ledger; it does not
11
+ * change the persisted slot phase machine (pending/running/success/partial/
12
+ * failed remain untouched for ledger compatibility).
13
+ */
14
+ export type SlotBusinessStatus = 'success' | 'partial_success' | 'no_candidate' | 'duplicate_only' | 'failed';
15
+ export interface SlotBusinessCounts {
16
+ total: number;
17
+ submitted: number;
18
+ no_match: number;
19
+ duplicate: number;
20
+ /** Count of no_candidate cells whose terminal reason is duplicate_exhausted. */
21
+ duplicate_exhausted?: number;
22
+ executor_failed: number;
23
+ delivery_failed: number;
24
+ }
25
+ /** Pure projection: durable cell counts -> one business verdict. No I/O. */
26
+ export declare function classifySlotBusinessStatus(counts: SlotBusinessCounts): SlotBusinessStatus;
27
+ /**
28
+ * User-facing (Mini App / notification) copy for a terminal business status.
29
+ * Internal error names (OperationCancelledError, internal_error) never leak.
30
+ */
31
+ export declare function userMessageForSlotBusinessStatus(status: SlotBusinessStatus): string;
32
+ //# sourceMappingURL=SlotBusinessStatus.d.ts.map
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifySlotBusinessStatus = classifySlotBusinessStatus;
4
+ exports.userMessageForSlotBusinessStatus = userMessageForSlotBusinessStatus;
5
+ /** Pure projection: durable cell counts -> one business verdict. No I/O. */
6
+ function classifySlotBusinessStatus(counts) {
7
+ const nonSubmitted = counts.total - counts.submitted;
8
+ const systemFailed = counts.executor_failed > 0 || counts.delivery_failed > 0;
9
+ const allNoContentAsDuplicate = counts.duplicate + (counts.duplicate_exhausted ?? 0) === nonSubmitted;
10
+ if (counts.submitted > 0 && nonSubmitted === 0)
11
+ return 'success';
12
+ if (counts.submitted > 0)
13
+ return 'partial_success';
14
+ if (nonSubmitted === 0)
15
+ return 'success'; // defensive: 0 targets / all submitted
16
+ if (!systemFailed) {
17
+ // Every non-submitted cell was a BUSINESS no-content verdict; all of them
18
+ // came from already-delivered candidates => duplicate_only.
19
+ if (allNoContentAsDuplicate)
20
+ return 'duplicate_only';
21
+ return 'no_candidate';
22
+ }
23
+ return 'failed';
24
+ }
25
+ /**
26
+ * User-facing (Mini App / notification) copy for a terminal business status.
27
+ * Internal error names (OperationCancelledError, internal_error) never leak.
28
+ */
29
+ function userMessageForSlotBusinessStatus(status) {
30
+ switch (status) {
31
+ case 'success':
32
+ return '任务完成,已发布本轮内容。';
33
+ case 'partial_success':
34
+ return '任务部分完成,部分内容已发布。';
35
+ case 'no_candidate':
36
+ return '本轮没有发现新的可发布作品。任务已正常完成。';
37
+ case 'duplicate_only':
38
+ return '本轮没有发现新的可发布作品。任务已正常完成。';
39
+ case 'failed':
40
+ return '本轮任务遇到系统异常,请稍后重试或检查日志。';
41
+ }
42
+ }
43
+ //# sourceMappingURL=SlotBusinessStatus.js.map
@@ -3,6 +3,7 @@ import { Database } from '../storage/Database';
3
3
  import { CellStatus, SlotItemRecord, SlotRecord, SlotStatus } from '../storage/repositories/SlotRepository';
4
4
  import { TriggerSource } from './OccurrenceResolver';
5
5
  import { TargetOutcome } from './TargetOutcome';
6
+ import { SlotBusinessStatus } from './SlotBusinessStatus';
6
7
  import { TargetExecutionContext, WorkBinding } from './WorkIdentity';
7
8
  /**
8
9
  * Execution-lease TTL and heartbeat cadence.
@@ -113,11 +114,17 @@ export interface ScheduleOutcomeCells {
113
114
  */
114
115
  export interface ScheduleOutcomeRecord {
115
116
  event: 'schedule.outcome';
117
+ /** Outcome taxonomy version; bump when business_status/reason gains codes. */
118
+ outcome_version: 1;
116
119
  schedule_id: string;
117
120
  slot_id: string;
118
121
  occurrence_at: string | undefined;
119
122
  occurrence_date: string;
120
123
  status: ScheduleOutcomeStatus;
124
+ /** Derived business verdict: success / partial_success / no_candidate / duplicate_only / failed. */
125
+ business_status: SlotBusinessStatus;
126
+ /** Monitoring gate: true only for system failures (business_status === 'failed'). */
127
+ alertable: boolean;
121
128
  /** From the slot row's own started_at/completed_at columns, when both exist. */
122
129
  duration_ms: number | undefined;
123
130
  cells: ScheduleOutcomeCells;
@@ -5,6 +5,7 @@ exports.timezoneForSchedule = timezoneForSchedule;
5
5
  const logger_1 = require("../logger");
6
6
  const OccurrenceResolver_1 = require("./OccurrenceResolver");
7
7
  const TargetOutcome_1 = require("./TargetOutcome");
8
+ const SlotBusinessStatus_1 = require("./SlotBusinessStatus");
8
9
  const WorkIdentity_1 = require("./WorkIdentity");
9
10
  /**
10
11
  * Execution-lease TTL and heartbeat cadence.
@@ -504,13 +505,25 @@ class SlotCoordinator {
504
505
  ? Math.max(0, completedMs - startedMs)
505
506
  : undefined;
506
507
  const occurrenceAt = slotRec?.occurrenceAt ?? slot.occurrenceAt;
508
+ const businessStatus = (0, SlotBusinessStatus_1.classifySlotBusinessStatus)({
509
+ total: cellRows.length,
510
+ submitted,
511
+ no_match,
512
+ duplicate,
513
+ duplicate_exhausted: targetsDetail.filter((t) => t.terminal_reason_code === 'duplicate_exhausted').length,
514
+ executor_failed,
515
+ delivery_failed,
516
+ });
507
517
  return {
508
518
  event: 'schedule.outcome',
519
+ outcome_version: 1,
509
520
  schedule_id: slotRec?.scheduleId ?? slot.scheduleId,
510
521
  slot_id: slot.slotId,
511
522
  occurrence_at: isoUtcOrUndefined(occurrenceAt),
512
523
  occurrence_date: slotRec?.occurrenceDate ?? slot.occurrenceDate,
513
524
  status: status,
525
+ business_status: businessStatus,
526
+ alertable: businessStatus === 'failed',
514
527
  duration_ms,
515
528
  cells: {
516
529
  total: cellRows.length,
@@ -4,6 +4,7 @@ import { DeliveryRepository } from './repositories/DeliveryRepository';
4
4
  import { OutboxRepository } from './repositories/OutboxRepository';
5
5
  import { MetadataRepository } from './repositories/MetadataRepository';
6
6
  import { SQLiteRateLimitStateStore } from './repositories/RateLimitStateRepository';
7
+ import { SystemErrorRepository } from './repositories/SystemErrorRepository';
7
8
  export interface AccessTokenStore {
8
9
  accessToken: string;
9
10
  expiresAt: number;
@@ -46,6 +47,7 @@ export declare class Database implements IDatabase {
46
47
  private outboxRepo;
47
48
  private metadataRepo;
48
49
  private rateLimitStateStore;
50
+ private systemErrorRepo;
49
51
  constructor(databasePath: string);
50
52
  migrate(): void;
51
53
  /** Absolute path of the SQLite file (used to locate sibling cache dirs). */
@@ -60,6 +62,8 @@ export declare class Database implements IDatabase {
60
62
  get metadata(): MetadataRepository;
61
63
  /** Persistent 429 gate state adapter for @redtidev/pixiv-client. */
62
64
  get rateLimitState(): SQLiteRateLimitStateStore;
65
+ /** Durable system-error ledger (observability; never throws). */
66
+ get systemErrors(): SystemErrorRepository;
63
67
  /** Raw transactional boundary for atomic multi-table intents. */
64
68
  transaction<T>(fn: () => T): T;
65
69
  /** Expose a prepared-statement helper if needed by services (pragmas etc). */
@@ -17,6 +17,7 @@ const DeliveryRepository_1 = require("./repositories/DeliveryRepository");
17
17
  const OutboxRepository_1 = require("./repositories/OutboxRepository");
18
18
  const MetadataRepository_1 = require("./repositories/MetadataRepository");
19
19
  const RateLimitStateRepository_1 = require("./repositories/RateLimitStateRepository");
20
+ const SystemErrorRepository_1 = require("./repositories/SystemErrorRepository");
20
21
  const NodeSqliteDriver_1 = require("./drivers/NodeSqliteDriver");
21
22
  class Database {
22
23
  databasePath;
@@ -33,6 +34,7 @@ class Database {
33
34
  outboxRepo;
34
35
  metadataRepo;
35
36
  rateLimitStateStore;
37
+ systemErrorRepo;
36
38
  constructor(databasePath) {
37
39
  this.databasePath = databasePath;
38
40
  try {
@@ -60,6 +62,7 @@ class Database {
60
62
  this.outboxRepo = new OutboxRepository_1.OutboxRepository(this.db);
61
63
  this.metadataRepo = new MetadataRepository_1.MetadataRepository(this.db);
62
64
  this.rateLimitStateStore = new RateLimitStateRepository_1.SQLiteRateLimitStateStore(this.db);
65
+ this.systemErrorRepo = new SystemErrorRepository_1.SystemErrorRepository(this.db);
63
66
  }
64
67
  catch (error) {
65
68
  throw new errors_1.DatabaseError(`Failed to initialize database at ${this.databasePath}`, error instanceof Error ? error : undefined);
@@ -95,6 +98,10 @@ class Database {
95
98
  get rateLimitState() {
96
99
  return this.rateLimitStateStore;
97
100
  }
101
+ /** Durable system-error ledger (observability; never throws). */
102
+ get systemErrors() {
103
+ return this.systemErrorRepo;
104
+ }
98
105
  /** Raw transactional boundary for atomic multi-table intents. */
99
106
  transaction(fn) {
100
107
  return this.db.transaction(fn)();