pixivflow 2.20.4 → 2.21.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 (33) hide show
  1. package/README.en.md +180 -53
  2. package/README.md +113 -59
  3. package/dist/commands/SchedulerCommand.js +66 -0
  4. package/dist/commands/SchedulerIdleLifecycle.d.ts +17 -0
  5. package/dist/commands/SchedulerIdleLifecycle.js +3 -1
  6. package/dist/commands/scheduler-runtime.d.ts +8 -0
  7. package/dist/commands/scheduler-runtime.js +121 -94
  8. package/dist/config/types.d.ts +26 -0
  9. package/dist/config/validation.js +21 -0
  10. package/dist/delivery/types.d.ts +15 -0
  11. package/dist/notification/NotificationPolicy.d.ts +2 -0
  12. package/dist/notification/NotificationPolicy.js +18 -1
  13. package/dist/package.json +1 -1
  14. package/dist/scheduler/MultiScheduleManager.d.ts +12 -1
  15. package/dist/scheduler/MultiScheduleManager.js +46 -50
  16. package/dist/scheduler/RecoveryPolicy.d.ts +45 -0
  17. package/dist/scheduler/RecoveryPolicy.js +63 -0
  18. package/dist/scheduler/ResourceAdmission.d.ts +68 -0
  19. package/dist/scheduler/ResourceAdmission.js +83 -0
  20. package/dist/scheduler/ScheduleTriggerServer.d.ts +16 -0
  21. package/dist/scheduler/ScheduleTriggerServer.js +55 -0
  22. package/dist/scheduler/Scheduler.d.ts +19 -2
  23. package/dist/scheduler/Scheduler.js +12 -3
  24. package/dist/scheduler/SlotCoordinator.d.ts +20 -0
  25. package/dist/scheduler/SlotCoordinator.js +44 -2
  26. package/dist/scheduler/TargetOutcome.d.ts +31 -0
  27. package/dist/scheduler/TargetOutcome.js +114 -0
  28. package/dist/storage/DatabaseMigration.js +17 -0
  29. package/dist/storage/repositories/SlotRepository.d.ts +29 -0
  30. package/dist/storage/repositories/SlotRepository.js +28 -2
  31. package/dist/version.js +1 -1
  32. package/dist/webui/package.json +1 -1
  33. package/package.json +2 -2
@@ -4,6 +4,7 @@ exports.SlotCoordinator = exports.SLOT_HEARTBEAT_MS = exports.SLOT_LEASE_TTL_MS
4
4
  exports.timezoneForSchedule = timezoneForSchedule;
5
5
  const logger_1 = require("../logger");
6
6
  const OccurrenceResolver_1 = require("./OccurrenceResolver");
7
+ const TargetOutcome_1 = require("./TargetOutcome");
7
8
  const WorkIdentity_1 = require("./WorkIdentity");
8
9
  /**
9
10
  * Execution-lease TTL and heartbeat cadence.
@@ -129,6 +130,8 @@ class SlotCoordinator {
129
130
  slotName: slot.slotName,
130
131
  manualRequestId: slot.manualRequestId ?? null,
131
132
  correlationId: slot.correlationId ?? null,
133
+ recoveryRequestId: slot.recoveryRequestId ?? null,
134
+ recoveryMode: slot.recoveryMode ?? null,
132
135
  });
133
136
  if (created) {
134
137
  // Freeze membership: materialize one cell per target id from the snapshot.
@@ -267,12 +270,15 @@ class SlotCoordinator {
267
270
  case 'no_candidate':
268
271
  // Only terminal if the cell never locked a work; a locked work whose
269
272
  // delivery is still pending must not be collapsed to no_candidate.
270
- if (!cell.workId)
273
+ if (!cell.workId) {
271
274
  this.safeTransition(slotId, targetId, 'no_candidate', outcome.reason);
275
+ this.persistTerminalReason(slotId, targetId, outcome);
276
+ }
272
277
  return;
273
278
  case 'duplicate':
274
279
  this.database.slots.lockCellWork(slotId, targetId, outcome.workId, cell.workType ?? 'unknown');
275
280
  this.safeTransition(slotId, targetId, 'duplicate', outcome.reason);
281
+ this.persistTerminalReason(slotId, targetId, outcome);
276
282
  return;
277
283
  case 'failed':
278
284
  if (outcome.retryable) {
@@ -282,7 +288,27 @@ class SlotCoordinator {
282
288
  return;
283
289
  }
284
290
  this.safeTransition(slotId, targetId, 'failed', outcome.error);
291
+ this.persistTerminalReason(slotId, targetId, outcome);
292
+ return;
293
+ }
294
+ }
295
+ /**
296
+ * Persist the normalized terminal reason (§terminal-reason) for a cell that
297
+ * just reached a terminal state. Never unwinds the run and never leaks raw
298
+ * error internals: only the stable code + business message are stored.
299
+ */
300
+ persistTerminalReason(slotId, targetId, outcome) {
301
+ try {
302
+ const reason = (0, TargetOutcome_1.terminalReasonFor)(outcome);
303
+ if (!reason)
285
304
  return;
305
+ this.database.slots.setCellTerminalReason(slotId, targetId, reason.code, reason.message);
306
+ }
307
+ catch (error) {
308
+ logger_1.logger.debug('Failed to persist terminal reason', {
309
+ slot: slotId, target: targetId,
310
+ error: error instanceof Error ? error.message : String(error),
311
+ });
286
312
  }
287
313
  }
288
314
  /** Promote a delivery_pending cell to submitted from a confirmed ACK. */
@@ -393,7 +419,19 @@ class SlotCoordinator {
393
419
  continue;
394
420
  if (cell.status === 'pending' || cell.status === 'selected') {
395
421
  // Ran but never reached a terminal state (target threw before delivery).
396
- this.database.slots.setCellStatus(slot.slotId, targetId, 'failed', cell.lastError ?? 'target did not complete');
422
+ // Persist a normalized reason so the operator sees a first-level cause,
423
+ // not a bare "target did not complete".
424
+ const error = cell.lastError ?? 'target did not complete';
425
+ this.database.slots.setCellStatus(slot.slotId, targetId, 'failed', error);
426
+ try {
427
+ this.database.slots.setCellTerminalReason(slot.slotId, targetId, 'internal_error', TargetOutcome_1.TERMINAL_REASON_MESSAGES.internal_error);
428
+ }
429
+ catch (reasonError) {
430
+ logger_1.logger.debug('Failed to persist terminal reason in rollup', {
431
+ slot: slot.slotId, target: targetId,
432
+ error: reasonError instanceof Error ? reasonError.message : String(reasonError),
433
+ });
434
+ }
397
435
  }
398
436
  }
399
437
  const status = this.database.slots.deriveSlotStatus(slot.slotId);
@@ -403,6 +441,8 @@ class SlotCoordinator {
403
441
  status: c.status,
404
442
  workId: c.workId,
405
443
  error: c.lastError,
444
+ terminalReasonCode: c.terminalReasonCode,
445
+ terminalReasonMessage: c.terminalReasonMessage,
406
446
  }));
407
447
  // Rolled up AFTER the slot row carries its terminal status/completed_at, so
408
448
  // the outcome reports the durable timestamps rather than a fresh clock read.
@@ -450,6 +490,8 @@ class SlotCoordinator {
450
490
  status: cell.status,
451
491
  work_id: cell.workId,
452
492
  error: cell.lastError,
493
+ terminal_reason_code: cell.terminalReasonCode,
494
+ terminal_reason_message: cell.terminalReasonMessage,
453
495
  };
454
496
  });
455
497
  // A fully-submitted slot has no non-submitted cell at all, and reporting
@@ -253,4 +253,35 @@ export declare function mergeScanSummaries(first: CandidateScanSummary | null, s
253
253
  export declare function classifyCandidateFailure(error: unknown, workId: string): CandidateFailure;
254
254
  /** Hard job-level outage for a directly-thrown error, or null. */
255
255
  export declare function classifyJobLevelOutage(error: unknown): JobLevelOutage | null;
256
+ /**
257
+ * Normalized terminal failure taxonomy (§terminal-reason).
258
+ *
259
+ * These codes are what operators actually see: the FIRST-LEVEL cause of a
260
+ * terminal cell. Recovery exhaustion is an execution state, never a root
261
+ * cause — a cell that exhausted its fallback stages because every candidate
262
+ * was a duplicate reports `duplicate_exhausted`, not `recovery_exhausted`.
263
+ * Queue waiting is not a terminal reason at all.
264
+ *
265
+ * Codes are kept deliberately small (no giant taxonomy): each maps to one
266
+ * real failure path in this codebase.
267
+ */
268
+ export type TerminalReasonCode =
269
+ /** The candidate scan was empty — nothing was found in the day's ranked pool. */
270
+ 'no_candidate'
271
+ /** Every candidate the scan saw was already delivered/pending for this target. */
272
+ | 'duplicate_exhausted'
273
+ /** Candidates existed but none passed the target's own filters. */
274
+ | 'filter_exhausted' | 'download_timeout' | 'download_failed' | 'rate_limited' | 'auth_failed' | 'remote_http_error' | 'delivery_failed' | 'telegram_failed' | 'network_error' | 'execution_timeout' | 'configuration_error' | 'internal_error';
275
+ export interface TerminalReason {
276
+ code: TerminalReasonCode;
277
+ /** Business-language message an operator/reviewer reads directly. */
278
+ message: string;
279
+ }
280
+ /** User-facing business messages — never stack traces, paths, SQL or tokens. */
281
+ export declare const TERMINAL_REASON_MESSAGES: Record<TerminalReasonCode, string>;
282
+ /**
283
+ * Map a typed terminal TargetOutcome onto the normalized reason. Returns null
284
+ * for non-terminal outcomes (they have no terminal reason yet).
285
+ */
286
+ export declare function terminalReasonFor(outcome: TargetOutcome): TerminalReason | null;
256
287
  //# sourceMappingURL=TargetOutcome.d.ts.map
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TERMINAL_REASON_MESSAGES = void 0;
3
4
  exports.emptyCandidateScan = emptyCandidateScan;
4
5
  exports.isTerminalOutcome = isTerminalOutcome;
5
6
  exports.isSelectedAttempt = isSelectedAttempt;
@@ -10,6 +11,7 @@ exports.hasTransientFailure = hasTransientFailure;
10
11
  exports.mergeScanSummaries = mergeScanSummaries;
11
12
  exports.classifyCandidateFailure = classifyCandidateFailure;
12
13
  exports.classifyJobLevelOutage = classifyJobLevelOutage;
14
+ exports.terminalReasonFor = terminalReasonFor;
13
15
  /**
14
16
  * A scan that attempted nothing. The real pipeline always reports its own scan;
15
17
  * this is for callers/tests that have no candidate-level information, so they
@@ -224,4 +226,116 @@ function classifyJobLevelOutage(error) {
224
226
  const failure = classifyCandidateFailure(error, '');
225
227
  return failure.scope === 'job' ? failure.outage : null;
226
228
  }
229
+ /** User-facing business messages — never stack traces, paths, SQL or tokens. */
230
+ exports.TERMINAL_REASON_MESSAGES = {
231
+ no_candidate: '没有找到合适的新作品',
232
+ duplicate_exhausted: '候选作品均已投稿过',
233
+ filter_exhausted: '没有符合筛选条件的新作品',
234
+ download_timeout: '图片下载超时',
235
+ download_failed: '图片下载失败',
236
+ rate_limited: 'Pixiv 请求频率受限',
237
+ auth_failed: 'Pixiv 登录已失效,需要重新登录',
238
+ remote_http_error: 'Pixiv 服务器返回错误',
239
+ delivery_failed: '投稿投递失败',
240
+ telegram_failed: 'Telegram 发送失败',
241
+ network_error: '网络异常',
242
+ execution_timeout: '执行超时',
243
+ configuration_error: '配置错误',
244
+ internal_error: '内部错误',
245
+ };
246
+ /**
247
+ * Map a typed terminal TargetOutcome onto the normalized reason. Returns null
248
+ * for non-terminal outcomes (they have no terminal reason yet).
249
+ */
250
+ function terminalReasonFor(outcome) {
251
+ switch (outcome.kind) {
252
+ case 'submitted':
253
+ case 'stored':
254
+ case 'delivery_pending':
255
+ return null;
256
+ case 'no_candidate':
257
+ return reasonForNoCandidate(outcome);
258
+ case 'duplicate':
259
+ return {
260
+ code: 'duplicate_exhausted',
261
+ message: exports.TERMINAL_REASON_MESSAGES.duplicate_exhausted,
262
+ };
263
+ case 'failed':
264
+ return classifyFailedReason(outcome.error, outcome.scan);
265
+ }
266
+ }
267
+ /**
268
+ * Root cause for a `no_candidate` target, read from the scan's skip codes
269
+ * rather than from exhaustion bookkeeping: all duplicates ⇒
270
+ * `duplicate_exhausted`; otherwise the candidates were present but unusable
271
+ * (filter/deleted/denied/wrong media) ⇒ `filter_exhausted`; a scan that saw
272
+ * nothing at all ⇒ `no_candidate`.
273
+ */
274
+ function reasonForNoCandidate(outcome) {
275
+ const skipped = outcome.scan?.skipped ?? [];
276
+ if (skipped.length > 0) {
277
+ const duplicates = skipped.filter((s) => s.code === 'duplicate').length;
278
+ if (duplicates === skipped.length) {
279
+ return { code: 'duplicate_exhausted', message: exports.TERMINAL_REASON_MESSAGES.duplicate_exhausted };
280
+ }
281
+ if (duplicates >= skipped.length / 2 || skipped.every((s) => s.code !== 'unavailable')) {
282
+ // Predominantly duplicates, or every skip was a hard work-level verdict:
283
+ // the pool contained works but none were usable for this target.
284
+ return { code: 'filter_exhausted', message: exports.TERMINAL_REASON_MESSAGES.filter_exhausted };
285
+ }
286
+ }
287
+ return { code: 'no_candidate', message: exports.TERMINAL_REASON_MESSAGES.no_candidate };
288
+ }
289
+ /**
290
+ * Classify a terminal `failed` reason from the error text + scan bookkeeping.
291
+ * Job-level outages from the scan win first (they describe the whole run), then
292
+ * message-shape heuristics; anything unclassifiable becomes `internal_error`
293
+ * rather than leaking raw error text to the review group.
294
+ */
295
+ function classifyFailedReason(message, scan) {
296
+ const outage = scan?.outages?.[0];
297
+ if (outage === 'pixiv_auth_failure') {
298
+ return { code: 'auth_failed', message: exports.TERMINAL_REASON_MESSAGES.auth_failed };
299
+ }
300
+ if (outage === 'delivery_unavailable') {
301
+ return { code: 'delivery_failed', message: exports.TERMINAL_REASON_MESSAGES.delivery_failed };
302
+ }
303
+ if (outage === 'network_outage' || outage === 'database_unavailable') {
304
+ return { code: 'network_error', message: exports.TERMINAL_REASON_MESSAGES.network_error };
305
+ }
306
+ const text = String(message ?? '').slice(0, 600);
307
+ if (/\b429\b|rate ?limit/i.test(text)) {
308
+ return { code: 'rate_limited', message: exports.TERMINAL_REASON_MESSAGES.rate_limited };
309
+ }
310
+ if (/\b401\b|unauthorized|invalid grant|invalid refresh token|authentication failed|登录/i.test(text)) {
311
+ return { code: 'auth_failed', message: exports.TERMINAL_REASON_MESSAGES.auth_failed };
312
+ }
313
+ if (/timeout|timed ?out|timedout/i.test(text)) {
314
+ return /download|image|url|fetch/i.test(text)
315
+ ? { code: 'download_timeout', message: exports.TERMINAL_REASON_MESSAGES.download_timeout }
316
+ : { code: 'execution_timeout', message: exports.TERMINAL_REASON_MESSAGES.execution_timeout };
317
+ }
318
+ if (/502|503|504|bad gateway|service unavailable|server error|5\d\d/i.test(text)) {
319
+ return { code: 'remote_http_error', message: exports.TERMINAL_REASON_MESSAGES.remote_http_error };
320
+ }
321
+ if (/telegram/i.test(text)) {
322
+ return { code: 'telegram_failed', message: exports.TERMINAL_REASON_MESSAGES.telegram_failed };
323
+ }
324
+ // Configuration faults are reported as such even when they surface inside a
325
+ // delivery/submission path ("delivery target not configured"): the operator
326
+ // fixes configuration, not the upstream.
327
+ if (/not configured|missing (?:config|configuration|setting)|invalid config/i.test(text)) {
328
+ return { code: 'configuration_error', message: exports.TERMINAL_REASON_MESSAGES.configuration_error };
329
+ }
330
+ if (/delivery|submit(?:t?ed)?|publish|post|outbox/i.test(text)) {
331
+ return { code: 'delivery_failed', message: exports.TERMINAL_REASON_MESSAGES.delivery_failed };
332
+ }
333
+ if (/econnrefused|econnreset|enotfound|etimedout|ehostunreach|enetunreach|socket hang up|network is unreachable|getaddrinfo/i.test(text)) {
334
+ return { code: 'network_error', message: exports.TERMINAL_REASON_MESSAGES.network_error };
335
+ }
336
+ if (/config|missing|invalid/i.test(text)) {
337
+ return { code: 'configuration_error', message: exports.TERMINAL_REASON_MESSAGES.configuration_error };
338
+ }
339
+ return { code: 'internal_error', message: exports.TERMINAL_REASON_MESSAGES.internal_error };
340
+ }
227
341
  //# sourceMappingURL=TargetOutcome.js.map
@@ -108,6 +108,11 @@ class DatabaseMigration {
108
108
  -- the terminal outcome can be reported back to the requester
109
109
  -- without this service learning anything about the review.
110
110
  correlation_id TEXT,
111
+ -- Manual recovery request UUID + policy preset ("重试一次/放宽条件重试").
112
+ -- A recovery slot re-runs the failed target(s) under a per-occurrence
113
+ -- policy and reports through the schedule-outcome channel.
114
+ recovery_request_id TEXT,
115
+ recovery_mode TEXT,
111
116
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
112
117
  started_at DATETIME,
113
118
  completed_at DATETIME,
@@ -126,6 +131,10 @@ class DatabaseMigration {
126
131
  attempt_count INTEGER NOT NULL DEFAULT 0,
127
132
  fallback_stage INTEGER NOT NULL DEFAULT 0,
128
133
  last_error TEXT,
134
+ -- Normalized terminal failure reason (§terminal-reason): stable reason
135
+ -- code plus a user-facing business message, durable across restarts.
136
+ terminal_reason_code TEXT,
137
+ terminal_reason_message TEXT,
129
138
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
130
139
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
131
140
  completed_at DATETIME,
@@ -239,6 +248,8 @@ class DatabaseMigration {
239
248
  heartbeat_at: 'ALTER TABLE schedule_slots ADD COLUMN heartbeat_at INTEGER',
240
249
  manual_request_id: 'ALTER TABLE schedule_slots ADD COLUMN manual_request_id TEXT',
241
250
  correlation_id: 'ALTER TABLE schedule_slots ADD COLUMN correlation_id TEXT',
251
+ recovery_request_id: 'ALTER TABLE schedule_slots ADD COLUMN recovery_request_id TEXT',
252
+ recovery_mode: 'ALTER TABLE schedule_slots ADD COLUMN recovery_mode TEXT',
242
253
  };
243
254
  const columnAlters = [];
244
255
  for (const [col, sql] of Object.entries(slotColumnMigrations)) {
@@ -249,6 +260,12 @@ class DatabaseMigration {
249
260
  if (!itemCols.includes('fallback_stage')) {
250
261
  columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN fallback_stage INTEGER NOT NULL DEFAULT 0`);
251
262
  }
263
+ if (!itemCols.includes('terminal_reason_code')) {
264
+ columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN terminal_reason_code TEXT`);
265
+ }
266
+ if (!itemCols.includes('terminal_reason_message')) {
267
+ columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN terminal_reason_message TEXT`);
268
+ }
252
269
  // Create indexes for better query performance
253
270
  const indexes = [
254
271
  `CREATE INDEX IF NOT EXISTS idx_downloads_pixiv_id_type ON downloads(pixiv_id, type)`,
@@ -29,6 +29,15 @@ export interface SlotRecord {
29
29
  manualRequestId: string | null;
30
30
  /** Opaque caller correlation (review chain / review id); null unless manual. */
31
31
  correlationId: string | null;
32
+ /**
33
+ * Request UUID of a remote MANUAL RECOVERY run (§manual-recovery); null for
34
+ * scheduled occurrences and review refetches. A recovery slot re-runs the
35
+ * failed target(s) under a per-occurrence recovery policy and reports its
36
+ * outcome through the schedule-outcome channel.
37
+ */
38
+ recoveryRequestId: string | null;
39
+ /** Recovery policy preset ('normal'|'relaxed'); null for non-recovery slots. */
40
+ recoveryMode: string | null;
32
41
  }
33
42
  export interface SlotItemRecord {
34
43
  id: number;
@@ -45,6 +54,14 @@ export interface SlotItemRecord {
45
54
  */
46
55
  fallback_stage: number;
47
56
  lastError: string | null;
57
+ /**
58
+ * Normalized terminal failure reason code (§terminal-reason) — one of the
59
+ * stable codes in TargetOutcome's failure taxonomy. Null for non-terminal
60
+ * cells or successful submissions.
61
+ */
62
+ terminalReasonCode: string | null;
63
+ /** User-facing business message for the terminal reason. */
64
+ terminalReasonMessage: string | null;
48
65
  createdAt: string;
49
66
  updatedAt: string;
50
67
  completedAt: string | null;
@@ -61,6 +78,8 @@ export interface SlotItemRecord {
61
78
  export declare class SlotRepository extends BaseRepository {
62
79
  /** Exact manual request/target lookup for authenticated convergence checks. */
63
80
  findManualSlot(requestId: string, targetId: string): SlotRecord | null;
81
+ /** Exact manual RECOVERY request/target lookup (§manual-recovery). */
82
+ findRecoverySlot(requestId: string, targetId: string): SlotRecord | null;
64
83
  /**
65
84
  * Fetch an existing slot or create it. On creation the schedule's target
66
85
  * membership is snapshotted (target_ids); a later config reload never mutates
@@ -81,6 +100,10 @@ export declare class SlotRepository extends BaseRepository {
81
100
  manualRequestId?: string | null;
82
101
  /** Opaque caller correlation recorded with a manual slot. */
83
102
  correlationId?: string | null;
103
+ /** Manual recovery request UUID (opens a `recover-` slot). */
104
+ recoveryRequestId?: string | null;
105
+ /** Manual recovery policy preset ('normal' | 'relaxed'). */
106
+ recoveryMode?: string | null;
84
107
  }): {
85
108
  slot: SlotRecord;
86
109
  created: boolean;
@@ -143,6 +166,12 @@ export declare class SlotRepository extends BaseRepository {
143
166
  bumpFallbackStage(slotId: string, targetId: string, reason: string): number;
144
167
  cellFallbackStage(slotId: string, targetId: string): number;
145
168
  setCellStatus(slotId: string, targetId: string, status: CellStatus, error?: string): void;
169
+ /**
170
+ * Persist the normalized terminal failure reason for a cell (§terminal-reason).
171
+ * Idempotent; a retried recovery/rollup only ever overwrites with the same or
172
+ * a later reasoned verdict.
173
+ */
174
+ setCellTerminalReason(slotId: string, targetId: string, code: string, message: string): void;
146
175
  /**
147
176
  * Transition a cell with FSM validation. Never downgrades a confirmed cell;
148
177
  * an illegal transition throws rather than silently corrupting state.
@@ -17,6 +17,11 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
17
17
  const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE manual_request_id = ?`).all(requestId);
18
18
  return rows.map((row) => this.toSlot(row)).find((slot) => slot.targetIds.includes(targetId)) ?? null;
19
19
  }
20
+ /** Exact manual RECOVERY request/target lookup (§manual-recovery). */
21
+ findRecoverySlot(requestId, targetId) {
22
+ const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE recovery_request_id = ?`).all(requestId);
23
+ return rows.map((row) => this.toSlot(row)).find((slot) => slot.targetIds.includes(targetId)) ?? null;
24
+ }
20
25
  /**
21
26
  * Fetch an existing slot or create it. On creation the schedule's target
22
27
  * membership is snapshotted (target_ids); a later config reload never mutates
@@ -26,10 +31,12 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
26
31
  getOrCreateSlot(id, data) {
27
32
  const insert = this.db.prepare(`INSERT INTO schedule_slots
28
33
  (id, schedule_id, occurrence_at, occurrence_date, occurrence_label, timezone, target_ids,
29
- status, trigger_source, slot_date, slot_name, manual_request_id, correlation_id)
34
+ status, trigger_source, slot_date, slot_name, manual_request_id, correlation_id,
35
+ recovery_request_id, recovery_mode)
30
36
  VALUES
31
37
  (@id, @scheduleId, @occurrenceAt, @occurrenceDate, @occurrenceLabel, @timezone, @targetIds,
32
- 'pending', @triggerSource, @slotDate, @slotName, @manualRequestId, @correlationId)
38
+ 'pending', @triggerSource, @slotDate, @slotName, @manualRequestId, @correlationId,
39
+ @recoveryRequestId, @recoveryMode)
33
40
  ON CONFLICT(id) DO NOTHING`);
34
41
  const info = insert.run({
35
42
  id,
@@ -44,6 +51,8 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
44
51
  slotName: data.slotName ?? '',
45
52
  manualRequestId: data.manualRequestId ?? null,
46
53
  correlationId: data.correlationId ?? null,
54
+ recoveryRequestId: data.recoveryRequestId ?? null,
55
+ recoveryMode: data.recoveryMode ?? null,
47
56
  });
48
57
  const created = info.changes > 0;
49
58
  return { slot: this.getSlot(id), created };
@@ -244,6 +253,19 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
244
253
  .prepare(`UPDATE schedule_slot_items SET ${sets.join(', ')} WHERE slot_id = @slotId AND target_id = @targetId`)
245
254
  .run({ slotId, targetId, status, error: error ?? null });
246
255
  }
256
+ /**
257
+ * Persist the normalized terminal failure reason for a cell (§terminal-reason).
258
+ * Idempotent; a retried recovery/rollup only ever overwrites with the same or
259
+ * a later reasoned verdict.
260
+ */
261
+ setCellTerminalReason(slotId, targetId, code, message) {
262
+ this.db
263
+ .prepare(`UPDATE schedule_slot_items
264
+ SET terminal_reason_code = @code, terminal_reason_message = @message,
265
+ updated_at = CURRENT_TIMESTAMP
266
+ WHERE slot_id = @slotId AND target_id = @targetId`)
267
+ .run({ slotId, targetId, code: code.slice(0, 64), message: message.slice(0, 400) });
268
+ }
247
269
  /**
248
270
  * Transition a cell with FSM validation. Never downgrades a confirmed cell;
249
271
  * an illegal transition throws rather than silently corrupting state.
@@ -402,6 +424,8 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
402
424
  heartbeatAt: row.heartbeat_at ?? null,
403
425
  manualRequestId: row.manual_request_id ?? null,
404
426
  correlationId: row.correlation_id ?? null,
427
+ recoveryRequestId: row.recovery_request_id ?? null,
428
+ recoveryMode: row.recovery_mode ?? null,
405
429
  };
406
430
  }
407
431
  toItem(row) {
@@ -415,6 +439,8 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
415
439
  attemptCount: row.attempt_count,
416
440
  fallback_stage: Number(row.fallback_stage ?? 0),
417
441
  lastError: row.last_error,
442
+ terminalReasonCode: row.terminal_reason_code ?? null,
443
+ terminalReasonMessage: row.terminal_reason_message ?? null,
418
444
  createdAt: row.created_at,
419
445
  updatedAt: row.updated_at,
420
446
  completedAt: row.completed_at,
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BUILD = void 0;
4
4
  // GENERATED by scripts/write-version.js — do not edit manually.
5
- exports.BUILD = { version: '2.20.4', commit: '5bb73436b670' };
5
+ exports.BUILD = { version: '2.21.0', commit: '56d4043e026e' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "2.20.4",
4
+ "version": "2.21.0",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "2.20.4",
4
- "description": "🎨 智能的 Pixiv 自动化下载工具 - 支持批量下载插画和小说、定时任务、Docker部署 | Intelligent Pixiv Automation Downloader with batch download, scheduler, and Docker support",
3
+ "version": "2.21.0",
4
+ "description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/redtidev1918/PixivFlow.git"