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.
- package/README.en.md +180 -53
- package/README.md +113 -59
- package/dist/commands/SchedulerCommand.js +66 -0
- package/dist/commands/SchedulerIdleLifecycle.d.ts +17 -0
- package/dist/commands/SchedulerIdleLifecycle.js +3 -1
- package/dist/commands/scheduler-runtime.d.ts +8 -0
- package/dist/commands/scheduler-runtime.js +121 -94
- package/dist/config/types.d.ts +26 -0
- package/dist/config/validation.js +21 -0
- package/dist/delivery/types.d.ts +15 -0
- package/dist/notification/NotificationPolicy.d.ts +2 -0
- package/dist/notification/NotificationPolicy.js +18 -1
- package/dist/package.json +1 -1
- package/dist/scheduler/MultiScheduleManager.d.ts +12 -1
- package/dist/scheduler/MultiScheduleManager.js +46 -50
- package/dist/scheduler/RecoveryPolicy.d.ts +45 -0
- package/dist/scheduler/RecoveryPolicy.js +63 -0
- package/dist/scheduler/ResourceAdmission.d.ts +68 -0
- package/dist/scheduler/ResourceAdmission.js +83 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +16 -0
- package/dist/scheduler/ScheduleTriggerServer.js +55 -0
- package/dist/scheduler/Scheduler.d.ts +19 -2
- package/dist/scheduler/Scheduler.js +12 -3
- package/dist/scheduler/SlotCoordinator.d.ts +20 -0
- package/dist/scheduler/SlotCoordinator.js +44 -2
- package/dist/scheduler/TargetOutcome.d.ts +31 -0
- package/dist/scheduler/TargetOutcome.js +114 -0
- package/dist/storage/DatabaseMigration.js +17 -0
- package/dist/storage/repositories/SlotRepository.d.ts +29 -0
- package/dist/storage/repositories/SlotRepository.js +28 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- 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
|
-
|
|
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.
|
|
5
|
+
exports.BUILD = { version: '2.21.0', commit: '56d4043e026e' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixivflow",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "🎨
|
|
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"
|