pixivflow 2.20.2 → 2.20.4

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.
@@ -111,6 +111,14 @@ export interface SchedulerRuntime {
111
111
  close(): void;
112
112
  }
113
113
  export declare function notifyScheduleFailure(config: StandaloneConfig, database: Database, schedule: ScheduleConfig, failure: JobFailure): Promise<void>;
114
+ /**
115
+ * Expanded, still-bounded candidate scan bound for one fallback stage
116
+ * (§schedule-recovery). Stage 0 is the primary pass; stage N scans
117
+ * `base * (N + 1)` candidates, hard-capped so a fallback can never become an
118
+ * unbounded scan of the whole tag. `undefined` (no declared bound) stays
119
+ * `undefined`: the handler's own default applies, not a fabricated number.
120
+ */
121
+ export declare function fallbackScanLimit(base: number | undefined, stage: number): number | undefined;
114
122
  export declare function createSchedulerRuntime(configPathArg?: string): Promise<SchedulerRuntime>;
115
123
  /**
116
124
  * Watchdog for a single plan run (used by `run-once`; the daemon's cron runs
@@ -45,6 +45,7 @@ exports.EXECUTION_MODES = void 0;
45
45
  exports.withDeliveryMode = withDeliveryMode;
46
46
  exports.shouldTerminaliseAbortedSlot = shouldTerminaliseAbortedSlot;
47
47
  exports.notifyScheduleFailure = notifyScheduleFailure;
48
+ exports.fallbackScanLimit = fallbackScanLimit;
48
49
  exports.createSchedulerRuntime = createSchedulerRuntime;
49
50
  exports.runWithTimeout = runWithTimeout;
50
51
  const config_1 = require("../config");
@@ -199,6 +200,16 @@ function buildProxyUrl(network) {
199
200
  const auth = proxy.username ? `${proxy.username}:${proxy.password ?? ''}@` : '';
200
201
  return `${protocol}://${auth}${proxy.host}:${proxy.port}`;
201
202
  }
203
+ /**
204
+ * Expanded, still-bounded candidate scan bound for one fallback stage
205
+ * (§schedule-recovery). Stage 0 is the primary pass; stage N scans
206
+ * `base * (N + 1)` candidates, hard-capped so a fallback can never become an
207
+ * unbounded scan of the whole tag. `undefined` (no declared bound) stays
208
+ * `undefined`: the handler's own default applies, not a fabricated number.
209
+ */
210
+ function fallbackScanLimit(base, stage) {
211
+ return base === undefined ? undefined : Math.min(Math.max(base, 1) * (stage + 1), 100);
212
+ }
202
213
  async function createSchedulerRuntime(configPathArg) {
203
214
  logger_1.logger.info('PixivFlow runtime starting', { component: 'pixivflow', version: version_1.BUILD.version, commit: version_1.BUILD.commit });
204
215
  // Keep TODAY/YESTERDAY placeholders intact. They are resolved afresh for
@@ -241,6 +252,7 @@ async function createSchedulerRuntime(configPathArg) {
241
252
  const deliveryDispatcher = new DeliveryDispatcher_1.DeliveryDispatcher(config.delivery, buildProxyUrl(config.network));
242
253
  const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
243
254
  const outboxWorker = new OutboxWorker_1.OutboxWorker(database, deliveryDispatcher, {
255
+ beforeDrain: () => notificationPolicy.reconcileScheduleSummaries(),
244
256
  retryBaseMs: config.delivery?.outboxRetryBaseMs,
245
257
  retryMaxMs: config.delivery?.outboxRetryMaxMs,
246
258
  // A confirmed ACK settles the owning Slot cell (submitted / duplicate /
@@ -445,21 +457,54 @@ async function createSchedulerRuntime(configPathArg) {
445
457
  targets: runTargets.map((t) => t.id),
446
458
  });
447
459
  }
448
- const downloadManager = new DownloadManager_1.DownloadManager(scopedConfig, pixivClient, database, fileService);
449
- if (targetExecutionContexts)
450
- downloadManager.setTargetExecutionContexts(targetExecutionContexts);
451
- if (options.excludedWorkIds)
452
- downloadManager.setProcessedWorkIds(options.excludedWorkIds);
453
- activeDownloadManager = downloadManager;
454
- await downloadManager.initialise();
455
460
  const scheduleSlot = slotCtx; // stable for callbacks; null for ad-hoc runs
461
+ // Bounded candidate fallback budget (§schedule-recovery): a missing required
462
+ // target must exhaust its recovery stages before the occurrence may roll up
463
+ // as a degraded (partial) terminal result.
464
+ const maxFallbackStages = Math.max(0, Math.min(10, Number(runtimeConfig.download?.maxFallbackStages ?? 3)));
465
+ const boostScanLimit = fallbackScanLimit;
466
+ const boostedTargets = (list) => list.map((t) => {
467
+ const stage = scheduleSlot && t.id ? coordinator.cellFallbackStage(scheduleSlot.slotId, t.id) : 0;
468
+ return stage === 0 ? t : {
469
+ ...t,
470
+ candidateScanLimit: boostScanLimit(t.candidateScanLimit ?? runtimeConfig.download?.candidateScanLimit, stage),
471
+ };
472
+ });
473
+ const buildManager = (list) => {
474
+ const scoped = {
475
+ ...runtimeConfig,
476
+ targets: withDeliveryMode(list.map((t) => ({
477
+ ...t,
478
+ delivery: t.delivery
479
+ ? { ...t.delivery, slotContext: slotCtx ?? undefined, executionContext }
480
+ : t.delivery,
481
+ })), options.deliveryMode),
482
+ };
483
+ const manager = new DownloadManager_1.DownloadManager(scoped, pixivClient, database, fileService);
484
+ if (targetExecutionContexts)
485
+ manager.setTargetExecutionContexts(targetExecutionContexts);
486
+ if (options.excludedWorkIds)
487
+ manager.setProcessedWorkIds(options.excludedWorkIds);
488
+ manager.setTargetOutcomeHook(outcomeHook);
489
+ if (scheduleSlot) {
490
+ manager.slotContext = {
491
+ slotId: scheduleSlot.slotId,
492
+ scheduleId: scheduleSlot.scheduleId,
493
+ occurrenceAtIso: new Date(scheduleSlot.occurrenceAt).toISOString(),
494
+ triggerSource: scheduleSlot.triggerSource,
495
+ slotName: scheduleSlot.slotName,
496
+ slotDate: scheduleSlot.slotDate,
497
+ };
498
+ }
499
+ return manager;
500
+ };
456
501
  // TYPED outcome -> explicit FSM transition. No message regex, no
457
502
  // "no throw => submitted". Only a confirmed ACK yields 'submitted'.
458
503
  //
459
504
  // Registered for EVERY run, not just scheduled ones: the batch runner
460
505
  // (execute-slot) runs without a Slot and still has to report a
461
506
  // machine-readable per-target result to its caller.
462
- downloadManager.setTargetOutcomeHook((target, outcome) => {
507
+ const outcomeHook = (target, outcome) => {
463
508
  if (!target.id)
464
509
  return;
465
510
  options.onTargetOutcome?.(target.id, outcome);
@@ -467,6 +512,21 @@ async function createSchedulerRuntime(configPathArg) {
467
512
  // No durable slot (run-once CLI): nothing to converge or report.
468
513
  return;
469
514
  }
515
+ // A scheduled (non-manual) target with nothing to submit advances to its
516
+ // next bounded fallback stage instead of terminalising: the cell returns
517
+ // to `pending` and the next pass re-selects it with expanded scan bounds.
518
+ // The FINAL stage (stage == maxFallbackStages - 1) does NOT advance: its
519
+ // real terminal outcome (no_candidate / duplicate / failed) is applied, so
520
+ // an exhausted occurrence reports the true cause — never a generic
521
+ // "target did not complete".
522
+ if (!scheduleSlot.manualRequestId &&
523
+ (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') &&
524
+ coordinator.cellFallbackStage(scheduleSlot.slotId, target.id) < maxFallbackStages - 1) {
525
+ coordinator.advanceFallback(scheduleSlot.slotId, target.id, outcome.kind === 'duplicate'
526
+ ? `duplicate candidates (stage ${coordinator.cellFallbackStage(scheduleSlot.slotId, target.id)})`
527
+ : outcome.reason ?? 'no eligible candidate', maxFallbackStages);
528
+ return;
529
+ }
470
530
  coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
471
531
  notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
472
532
  if (scheduleSlot.manualRequestId && (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate' ||
@@ -475,17 +535,10 @@ async function createSchedulerRuntime(configPathArg) {
475
535
  // fallback for retry exhaustion, timeout, and outbox dead-letter.
476
536
  notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, scheduleSlot.manualRequestId, outcome);
477
537
  }
478
- });
479
- if (scheduleSlot) {
480
- downloadManager.slotContext = {
481
- slotId: scheduleSlot.slotId,
482
- scheduleId: scheduleSlot.scheduleId,
483
- occurrenceAtIso: new Date(scheduleSlot.occurrenceAt).toISOString(),
484
- triggerSource: scheduleSlot.triggerSource,
485
- slotName: scheduleSlot.slotName,
486
- slotDate: scheduleSlot.slotDate,
487
- };
488
- }
538
+ };
539
+ let downloadManager = buildManager(boostedTargets(runTargets));
540
+ activeDownloadManager = downloadManager;
541
+ await downloadManager.initialise();
489
542
  // Apply initial delay if configured
490
543
  if (runtimeConfig.initialDelay && runtimeConfig.initialDelay > 0) {
491
544
  logger_1.logger.info(`Waiting ${runtimeConfig.initialDelay}ms before starting download...`, {
@@ -507,7 +560,25 @@ async function createSchedulerRuntime(configPathArg) {
507
560
  if (slotCtx && varReleaseLease)
508
561
  releaseLease = varReleaseLease;
509
562
  try {
510
- await downloadManager.runAllTargets();
563
+ // Candidate fallback passes (§schedule-recovery): after each pass, cells
564
+ // still mid-fallback (advanced stages) are re-selected with expanded,
565
+ // still-bounded scan limits. Successful sibling cells are NEVER re-run:
566
+ // pendingTargets only returns unconverged cells of this slot.
567
+ for (let pass = 0;; pass += 1) {
568
+ await downloadManager.runAllTargets();
569
+ if (!slotCtx || pass >= maxFallbackStages - 1)
570
+ break;
571
+ const pendingFallback = coordinator
572
+ .pendingTargets(slotCtx.slotId, targets)
573
+ .filter((p) => p.cell &&
574
+ p.cell.fallback_stage > 0 &&
575
+ p.cell.fallback_stage < maxFallbackStages);
576
+ if (pendingFallback.length === 0)
577
+ break;
578
+ downloadManager = buildManager(boostedTargets(pendingFallback.map((p) => p.target)));
579
+ activeDownloadManager = downloadManager;
580
+ await downloadManager.initialise();
581
+ }
511
582
  }
512
583
  catch (error) {
513
584
  if (!slotCtx || !(error instanceof Error) || !/^All \d+ target\(s\) failed\./.test(error.message)) {
@@ -550,17 +621,22 @@ async function createSchedulerRuntime(configPathArg) {
550
621
  for (const target of targets)
551
622
  if (target.id)
552
623
  notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
553
- notificationPolicy.sendSlotSummary(slotCtx, schedule, summary.cells.map((c) => {
554
- const t = targets.find((x) => x.id === c.targetId);
555
- return {
556
- targetId: c.targetId,
557
- label: c.targetId,
558
- workType: t?.type ?? 'unknown',
559
- status: c.status,
560
- workId: c.workId,
561
- error: c.error ?? null,
562
- };
563
- }));
624
+ // Terminal summaries are for SCHEDULED occurrences (P0-B). Remote manual
625
+ // replacements already report through their own verdict channel
626
+ // (refetchOutcomeUrl); a schedule summary would double-notify the group.
627
+ if (!slotCtx.manualRequestId) {
628
+ notificationPolicy.sendSlotSummary(slotCtx, schedule, summary.cells.map((c) => {
629
+ const t = targets.find((x) => x.id === c.targetId);
630
+ return {
631
+ targetId: c.targetId,
632
+ label: c.targetId,
633
+ workType: t?.type ?? 'unknown',
634
+ status: c.status,
635
+ workId: c.workId,
636
+ error: c.error ?? null,
637
+ };
638
+ }));
639
+ }
564
640
  releaseLease();
565
641
  }
566
642
  if (!slotCtx && allTargetsFailed)
@@ -524,6 +524,12 @@ export interface HttpMultipartDeliveryConfig {
524
524
  * `headers` for auth, so no extra credential is needed.
525
525
  */
526
526
  refetchOutcomeUrl?: string;
527
+ /**
528
+ * Optional JSON endpoint for TERMINAL SCHEDULE occurrence summaries
529
+ * (success/partial/failed) — delivered by the durable outbox to TelePost,
530
+ * which relays a user/operator-visible message. Reuses `headers` for auth.
531
+ */
532
+ scheduleOutcomeUrl?: string;
527
533
  method?: 'POST' | 'PUT';
528
534
  /** 支持 ${ENV_NAME} 环境变量插值 */
529
535
  headers?: Record<string, string>;
@@ -683,6 +689,12 @@ export interface StandaloneConfig {
683
689
  * Default: 5 (clamped to 1..100)
684
690
  */
685
691
  candidateScanLimit?: number;
692
+ /**
693
+ * Bounded candidate-fallback budget for SCHEDULED occurrences
694
+ * (§schedule-recovery). 0 disables fallback (no_candidate/duplicate
695
+ * terminalises immediately). Default: 3.
696
+ */
697
+ maxFallbackStages?: number;
686
698
  };
687
699
  }
688
700
  //# sourceMappingURL=types.d.ts.map
@@ -312,6 +312,16 @@ function validateConfig(config, location, databasePath) {
312
312
  errors.push(`${prefix}.refetchOutcomeUrl: Must be a valid HTTP or HTTPS URL`);
313
313
  }
314
314
  }
315
+ if (delivery.scheduleOutcomeUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.scheduleOutcomeUrl)) {
316
+ try {
317
+ const url = new URL(delivery.scheduleOutcomeUrl);
318
+ if (!['http:', 'https:'].includes(url.protocol))
319
+ throw new Error('unsupported protocol');
320
+ }
321
+ catch {
322
+ errors.push(`${prefix}.scheduleOutcomeUrl: Must be a valid HTTP or HTTPS URL`);
323
+ }
324
+ }
315
325
  if (delivery.readinessUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.readinessUrl)) {
316
326
  try {
317
327
  const url = new URL(delivery.readinessUrl);
@@ -53,8 +53,13 @@ class DeliveryDispatcher {
53
53
  if (target.type !== 'httpMultipart') {
54
54
  throw new errors_1.ConfigError(`Unsupported delivery target type: ${target.type}`);
55
55
  }
56
- if (!(request.refetchOutcome ? target.refetchOutcomeUrl : target.notificationUrl)?.trim()) {
57
- throw new errors_1.ConfigError(`Delivery target does not configure ${request.refetchOutcome ? 'refetchOutcomeUrl' : 'notificationUrl'}: ${name}`);
56
+ const urlKey = request.refetchOutcome
57
+ ? 'refetchOutcomeUrl'
58
+ : request.scheduleOutcome
59
+ ? 'scheduleOutcomeUrl'
60
+ : 'notificationUrl';
61
+ if (!target[urlKey]?.trim()) {
62
+ throw new errors_1.ConfigError(`Delivery target does not configure ${urlKey}: ${name}`);
58
63
  }
59
64
  return new HttpMultipartDelivery_1.HttpMultipartDelivery(target, this.proxyUrl).notifyOnce(request);
60
65
  }
@@ -39,6 +39,19 @@ export interface RefetchOutcomePayload {
39
39
  unavailable: number;
40
40
  };
41
41
  }
42
+ /** Terminal SCHEDULE occurrence verdict (success/partial/failed), reported via
43
+ * the durable outbox to TelePost, which relays the user-visible summary. */
44
+ export interface ScheduleOutcomePayload {
45
+ scheduleId: string;
46
+ slotId: string;
47
+ status: 'success' | 'partial' | 'failed';
48
+ targets?: Array<{
49
+ targetId: string;
50
+ workType: string;
51
+ status: string;
52
+ workId?: string | null;
53
+ }>;
54
+ }
42
55
  export declare class DeliveryService {
43
56
  private readonly database;
44
57
  constructor(database: Database);
@@ -96,7 +109,7 @@ export declare class DeliveryService {
96
109
  created: boolean;
97
110
  };
98
111
  /** Enqueue a durable notification (retried independently; never affects content). */
99
- enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string, refetchOutcome?: RefetchOutcomePayload): void;
112
+ enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string, refetchOutcome?: RefetchOutcomePayload, scheduleOutcome?: ScheduleOutcomePayload): void;
100
113
  private guardCell;
101
114
  private contextFrom;
102
115
  }
@@ -175,12 +175,16 @@ class DeliveryService {
175
175
  return { deliveryId: row.id, created };
176
176
  }
177
177
  /** Enqueue a durable notification (retried independently; never affects content). */
178
- enqueueNotification(deliveryTarget, text, idempotencyKey, refetchOutcome) {
178
+ enqueueNotification(deliveryTarget, text, idempotencyKey, refetchOutcome, scheduleOutcome) {
179
179
  this.database.outbox.enqueue({
180
180
  kind: 'notification',
181
181
  deliveryTarget,
182
182
  idempotencyKey,
183
- payload: refetchOutcome ? { text, refetchOutcome } : { text },
183
+ payload: refetchOutcome
184
+ ? { text, refetchOutcome }
185
+ : scheduleOutcome
186
+ ? { text, scheduleOutcome }
187
+ : { text },
184
188
  });
185
189
  }
186
190
  guardCell(slotId, targetId, next) {
@@ -134,18 +134,23 @@ class HttpMultipartDelivery {
134
134
  /** Single notification attempt; the outbox owns retries. */
135
135
  async notifyOnce(request) {
136
136
  const outcome = request.refetchOutcome;
137
- const url = (outcome ? this.config.refetchOutcomeUrl?.trim() : this.config.notificationUrl?.trim());
137
+ const scheduleOutcome = request.scheduleOutcome;
138
+ const url = (outcome ? this.config.refetchOutcomeUrl?.trim()
139
+ : scheduleOutcome ? this.config.scheduleOutcomeUrl?.trim()
140
+ : this.config.notificationUrl?.trim());
138
141
  if (!url) {
139
142
  throw new Error(outcome
140
143
  ? 'HTTP delivery refetchOutcomeUrl is not configured'
141
- : 'HTTP delivery notificationUrl is not configured');
144
+ : scheduleOutcome
145
+ ? 'HTTP delivery scheduleOutcomeUrl is not configured'
146
+ : 'HTTP delivery notificationUrl is not configured');
142
147
  }
143
148
  const headers = {
144
149
  ...this.resolveHeaders(this.config.headers ?? {}),
145
150
  'Content-Type': 'application/json',
146
151
  };
147
- // Refetch verdicts are machine-readable JSON (the requester's review state
148
- // machine consumes disposition, not prose). Plain notifications remain
152
+ // Refetch verdicts and schedule outcomes are machine-readable JSON for
153
+ // TelePost's state machines/relays. Plain notifications remain
149
154
  // {text, idempotency_key}.
150
155
  const body = outcome
151
156
  ? {
@@ -156,7 +161,19 @@ class HttpMultipartDelivery {
156
161
  scanned: outcome.scanned,
157
162
  skipped: outcome.skipped,
158
163
  }
159
- : { text: request.text, idempotency_key: request.idempotencyKey };
164
+ : scheduleOutcome
165
+ ? {
166
+ schedule_id: scheduleOutcome.scheduleId,
167
+ slot_id: scheduleOutcome.slotId,
168
+ status: scheduleOutcome.status,
169
+ targets: (scheduleOutcome.targets ?? []).map((t) => ({
170
+ target_id: t.targetId,
171
+ work_type: t.workType,
172
+ status: t.status,
173
+ work_id: t.workId ?? null,
174
+ })),
175
+ }
176
+ : { text: request.text, idempotency_key: request.idempotencyKey };
160
177
  const options = {
161
178
  method: 'POST',
162
179
  headers,
@@ -3,6 +3,8 @@ import { OutboxRow } from '../storage/repositories/OutboxRepository';
3
3
  import { DeliveryDispatcher } from './DeliveryDispatcher';
4
4
  import { DeliveryAck } from './DeliveryAck';
5
5
  export interface OutboxWorkerOptions {
6
+ /** Reconcile durable notification intents before consuming due rows. */
7
+ beforeDrain?: () => void;
6
8
  /** How often to scan for due rows. */
7
9
  pollIntervalMs?: number;
8
10
  /** Row lease duration while one attempt is in flight. */
@@ -31,6 +33,8 @@ export interface NotificationPayload {
31
33
  text: string;
32
34
  /** Optional structured remote-manual-replacement verdict (refetch outcome). */
33
35
  refetchOutcome?: unknown;
36
+ /** Optional structured terminal SCHEDULE occurrence verdict. */
37
+ scheduleOutcome?: unknown;
34
38
  }
35
39
  /** Exponential backoff with jitter, capped. */
36
40
  export declare function backoffDelayMs(attempt: number, base: number, max: number): number;
@@ -43,6 +47,7 @@ export declare function backoffDelayMs(attempt: number, base: number, max: numbe
43
47
  export declare class OutboxWorker {
44
48
  private readonly database;
45
49
  private readonly dispatcher;
50
+ private readonly options;
46
51
  private timer;
47
52
  private running;
48
53
  private stopped;
@@ -23,6 +23,7 @@ function backoffDelayMs(attempt, base, max) {
23
23
  class OutboxWorker {
24
24
  database;
25
25
  dispatcher;
26
+ options;
26
27
  timer = null;
27
28
  running = false;
28
29
  stopped = false;
@@ -37,6 +38,7 @@ class OutboxWorker {
37
38
  constructor(database, dispatcher, options = {}) {
38
39
  this.database = database;
39
40
  this.dispatcher = dispatcher;
41
+ this.options = options;
40
42
  this.pollIntervalMs = options.pollIntervalMs ?? 10_000;
41
43
  this.leaseMs = options.leaseMs ?? 120_000;
42
44
  this.batchSize = options.batchSize ?? 4;
@@ -73,6 +75,7 @@ class OutboxWorker {
73
75
  let retried = 0;
74
76
  let dead = 0;
75
77
  for (let round = 0; round < maxRounds; round++) {
78
+ this.options.beforeDrain?.();
76
79
  const rows = this.database.outbox.claimDue(this.owner, this.leaseMs, this.batchSize);
77
80
  if (rows.length === 0)
78
81
  break;
@@ -104,6 +107,7 @@ class OutboxWorker {
104
107
  return;
105
108
  this.running = true;
106
109
  try {
110
+ this.options.beforeDrain?.();
107
111
  const rows = this.database.outbox.claimDue(this.owner, this.leaseMs, this.batchSize);
108
112
  for (const row of rows) {
109
113
  if (this.stopped) {
@@ -214,6 +218,9 @@ class OutboxWorker {
214
218
  refetchOutcome: payload.refetchOutcome !== undefined
215
219
  ? payload.refetchOutcome
216
220
  : undefined,
221
+ scheduleOutcome: payload.scheduleOutcome !== undefined
222
+ ? payload.scheduleOutcome
223
+ : undefined,
217
224
  });
218
225
  }
219
226
  else {
@@ -113,6 +113,22 @@ export interface DeliveryNotificationRequest {
113
113
  unavailable: number;
114
114
  };
115
115
  };
116
+ /**
117
+ * Structured terminal schedule verdict for a SCHEDULED occurrence. When
118
+ * present the delivery posts JSON to `scheduleOutcomeUrl` instead of
119
+ * `notificationUrl` (auth reuses `headers`).
120
+ */
121
+ scheduleOutcome?: {
122
+ scheduleId: string;
123
+ slotId: string;
124
+ status: 'success' | 'partial' | 'failed';
125
+ targets?: Array<{
126
+ targetId: string;
127
+ workType: string;
128
+ status: string;
129
+ workId?: string | null;
130
+ }>;
131
+ };
116
132
  }
117
133
  export interface DeliveryProvider {
118
134
  deliver(request: DeliveryRequest): Promise<DeliveryResult>;
@@ -45,6 +45,14 @@ export declare class NotificationPolicy {
45
45
  workId: string | null;
46
46
  error: string | null;
47
47
  }>): void;
48
+ /**
49
+ * Delivery targets whose HTTP target declares the given outcome URL.
50
+ * Schedule summaries require `scheduleOutcomeUrl`; manual refetch outcomes
51
+ * use `refetchOutcomeUrl`; generic notifications use `notificationUrl`.
52
+ */
53
+ private targetsWithUrl;
54
+ /** The durable cells remain the notification intent across crashes and late ACKs. */
55
+ reconcileScheduleSummaries(): void;
48
56
  private send;
49
57
  /**
50
58
  * Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NotificationPolicy = void 0;
4
4
  const DeliveryService_1 = require("../delivery/DeliveryService");
5
+ const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
5
6
  const logger_1 = require("../logger");
6
7
  /**
7
8
  * Central policy for all operational notifications. Handlers/scheduler do not
@@ -76,8 +77,11 @@ class NotificationPolicy {
76
77
  }
77
78
  /** One consolidated summary per slot, delivered to every notifying target's endpoint. */
78
79
  sendSlotSummary(slot, schedule, rows) {
79
- const notifiable = this.notifiableTargets();
80
- if (notifiable.size === 0 || rows.length === 0)
80
+ if (slot.manualRequestId || rows.length === 0 || rows.some((r) => !['submitted', 'no_candidate', 'duplicate', 'failed'].includes(r.status)))
81
+ return;
82
+ const memberIds = new Set(rows.map((r) => r.targetId));
83
+ const targets = this.targetsWithUrl('scheduleOutcomeUrl', memberIds);
84
+ if (targets.size === 0)
81
85
  return;
82
86
  const icon = (s) => s === 'submitted' ? '✅' : s === 'no_candidate' ? '⚠️' : s === 'duplicate' ? '♱' : s === 'delivery_pending' ? '🕓' : '❌';
83
87
  const lines = rows.map((r) => `${icon(r.status)} ${r.label}(${r.workType === 'novel' ? '小说' : '插画'})` +
@@ -91,9 +95,60 @@ class NotificationPolicy {
91
95
  ...lines,
92
96
  `结果:${submitted === rows.length ? 'success' : submitted > 0 ? 'partial' : 'failed'}(${submitted}/${rows.length} 已确认投递)`,
93
97
  ].join('\n');
98
+ const outcomeStatus = submitted === rows.length ? 'success' : submitted > 0 ? 'partial' : 'failed';
94
99
  const service = new DeliveryService_1.DeliveryService(this.database);
95
- for (const name of notifiable) {
96
- service.enqueueNotification(name, text, NotificationPolicy.keys.summary(slot.slotId));
100
+ for (const [index, name] of [...targets].entries()) {
101
+ service.enqueueNotification(name, text, NotificationPolicy.keys.summary(slot.slotId) + (index === 0 ? '' : `:${name}`), undefined, {
102
+ scheduleId: schedule.id,
103
+ slotId: slot.slotId,
104
+ status: outcomeStatus,
105
+ targets: rows.map((r) => ({
106
+ targetId: r.targetId,
107
+ workType: r.workType,
108
+ status: r.status,
109
+ workId: r.workId,
110
+ })),
111
+ });
112
+ }
113
+ }
114
+ /**
115
+ * Delivery targets whose HTTP target declares the given outcome URL.
116
+ * Schedule summaries require `scheduleOutcomeUrl`; manual refetch outcomes
117
+ * use `refetchOutcomeUrl`; generic notifications use `notificationUrl`.
118
+ */
119
+ targetsWithUrl(urlKey, memberIds) {
120
+ const result = new Set();
121
+ for (const target of this.config.targets ?? []) {
122
+ if (memberIds && !memberIds.has(target.id ?? ''))
123
+ continue;
124
+ const deliveryTarget = target.delivery?.target;
125
+ if (!deliveryTarget)
126
+ continue;
127
+ const delivery = this.config.delivery?.targets?.[deliveryTarget];
128
+ if (delivery?.type === 'httpMultipart' && delivery[urlKey]?.trim()) {
129
+ result.add(deliveryTarget);
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+ /** The durable cells remain the notification intent across crashes and late ACKs. */
135
+ reconcileScheduleSummaries() {
136
+ for (const record of this.database.slots.getUnreportedTerminalSchedules()) {
137
+ const slot = {
138
+ slotId: record.id, scheduleId: record.scheduleId,
139
+ occurrenceAt: record.occurrenceAt ?? 0, occurrenceDate: record.occurrenceDate,
140
+ occurrenceLabel: record.occurrenceLabel, timezone: record.timezone,
141
+ triggerSource: 'http', slotName: record.slotName, slotDate: record.slotDate,
142
+ };
143
+ const schedule = this.config.schedules?.find((item) => item.id === record.scheduleId)
144
+ ?? { id: record.scheduleId };
145
+ const targets = this.config.targets.filter((target) => record.targetIds.includes(target.id ?? ''));
146
+ const summary = new SlotCoordinator_1.SlotCoordinator(this.database).finish(slot, schedule, targets);
147
+ this.sendSlotSummary(slot, schedule, summary.cells.map((cell) => ({
148
+ ...cell, label: cell.targetId,
149
+ workType: targets.find((target) => target.id === cell.targetId)?.type ?? 'unknown',
150
+ error: cell.error ?? null,
151
+ })));
97
152
  }
98
153
  }
99
154
  send(targetName, key, text) {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.20.2",
4
+ "version": "2.20.4",
5
5
  "private": true
6
6
  }
@@ -252,7 +252,20 @@ export declare class SlotCoordinator {
252
252
  claimRunLease(slotId: string, owner: string, leaseMs: number): boolean;
253
253
  heartbeatLease(slotId: string, owner: string, leaseMs: number): void;
254
254
  releaseRunLease(slotId: string, owner: string): void;
255
- /** Roll cell results up into the slot status (one place computes the aggregate). */
255
+ /**
256
+ * Current durable fallback stage of a cell (0 = primary selection pass).
257
+ * Mirrors the repository accessor so scheduler-runtime reads one surface.
258
+ */
259
+ cellFallbackStage(slotId: string, targetId: string): number;
260
+ /**
261
+ * Advance a recoverable candidate cell to its next durable fallback stage
262
+ * (§schedule-recovery). no_candidate/duplicate from a non-final stage MUST
263
+ * NOT terminalize: pendingTargets then re-selects the same target with
264
+ * expanded, still-bounded scan parameters. Returns the new stage, or the
265
+ * previous stage when the budget was already exhausted (callers then let the
266
+ * terminal outcome apply).
267
+ */
268
+ advanceFallback(slotId: string, targetId: string, reason: string, maxStages: number): number;
256
269
  finish(slot: SlotContext, schedule: ScheduleConfig, targets: TargetConfig[]): SlotRunSummary;
257
270
  /**
258
271
  * Project the terminal slot row into the one structured outcome record. Every
@@ -355,7 +355,31 @@ class SlotCoordinator {
355
355
  releaseRunLease(slotId, owner) {
356
356
  this.database.slots.releaseSlotLease(slotId, owner);
357
357
  }
358
- /** Roll cell results up into the slot status (one place computes the aggregate). */
358
+ /**
359
+ * Current durable fallback stage of a cell (0 = primary selection pass).
360
+ * Mirrors the repository accessor so scheduler-runtime reads one surface.
361
+ */
362
+ cellFallbackStage(slotId, targetId) {
363
+ return this.database.slots.cellFallbackStage(slotId, targetId);
364
+ }
365
+ /**
366
+ * Advance a recoverable candidate cell to its next durable fallback stage
367
+ * (§schedule-recovery). no_candidate/duplicate from a non-final stage MUST
368
+ * NOT terminalize: pendingTargets then re-selects the same target with
369
+ * expanded, still-bounded scan parameters. Returns the new stage, or the
370
+ * previous stage when the budget was already exhausted (callers then let the
371
+ * terminal outcome apply).
372
+ */
373
+ advanceFallback(slotId, targetId, reason, maxStages) {
374
+ const current = this.database.slots.cellFallbackStage(slotId, targetId);
375
+ if (current >= maxStages)
376
+ return current;
377
+ const next = this.database.slots.bumpFallbackStage(slotId, targetId, reason);
378
+ logger_1.logger.info('Candidate fallback advanced', {
379
+ slot: slotId, target: targetId, stage: next, reason: String(reason).slice(0, 160),
380
+ });
381
+ return next;
382
+ }
359
383
  finish(slot, schedule, targets) {
360
384
  const membership = this.database.slots.getSlotTargetIds(slot.slotId);
361
385
  const ids = membership.length > 0 ? membership : targets.map((t) => t.id).filter(Boolean);
@@ -124,6 +124,7 @@ class DatabaseMigration {
124
124
  work_type TEXT,
125
125
  status TEXT NOT NULL DEFAULT 'pending',
126
126
  attempt_count INTEGER NOT NULL DEFAULT 0,
127
+ fallback_stage INTEGER NOT NULL DEFAULT 0,
127
128
  last_error TEXT,
128
129
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
129
130
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -244,6 +245,10 @@ class DatabaseMigration {
244
245
  if (!slotCols.includes(col))
245
246
  columnAlters.push(sql);
246
247
  }
248
+ const itemCols = this.db.prepare(`PRAGMA table_info(schedule_slot_items)`).all().map((c) => c.name);
249
+ if (!itemCols.includes('fallback_stage')) {
250
+ columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN fallback_stage INTEGER NOT NULL DEFAULT 0`);
251
+ }
247
252
  // Create indexes for better query performance
248
253
  const indexes = [
249
254
  `CREATE INDEX IF NOT EXISTS idx_downloads_pixiv_id_type ON downloads(pixiv_id, type)`,
@@ -38,6 +38,12 @@ export interface SlotItemRecord {
38
38
  workType: string | null;
39
39
  status: CellStatus;
40
40
  attemptCount: number;
41
+ /**
42
+ * Bounded candidate-fallback depth for this cell (§schedule-recovery). 0 means
43
+ * the primary selection pass; each advance re-selects the SAME target with
44
+ * expanded scan bounds. Durable so a crash-resume re-enters at the same stage.
45
+ */
46
+ fallback_stage: number;
41
47
  lastError: string | null;
42
48
  createdAt: string;
43
49
  updatedAt: string;
@@ -83,6 +89,8 @@ export declare class SlotRepository extends BaseRepository {
83
89
  /** Target ids materialized for a slot (stable membership; falls back to []). */
84
90
  getSlotTargetIds(id: string): string[];
85
91
  getRecentSlots(limit?: number): SlotRecord[];
92
+ /** Completed cells whose summary enqueue or aggregate rollup was interrupted. */
93
+ getUnreportedTerminalSchedules(): SlotRecord[];
86
94
  markSlotStatus(id: string, status: SlotStatus, error?: string): void;
87
95
  /** All cells for a slot (one per target). */
88
96
  getCells(slotId: string): SlotItemRecord[];
@@ -131,6 +139,9 @@ export declare class SlotRepository extends BaseRepository {
131
139
  releaseCellWork(slotId: string, targetId: string, workId: string): boolean;
132
140
  /** Explicit operator action: forget the locked work so a re-run picks another candidate. */
133
141
  clearCellWork(slotId: string, targetId: string): void;
142
+ /** Durable candidate-fallback bookkeeping (§schedule-recovery). */
143
+ bumpFallbackStage(slotId: string, targetId: string, reason: string): number;
144
+ cellFallbackStage(slotId: string, targetId: string): number;
134
145
  setCellStatus(slotId: string, targetId: string, status: CellStatus, error?: string): void;
135
146
  /**
136
147
  * Transition a cell with FSM validation. Never downgrades a confirmed cell;
@@ -71,6 +71,18 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
71
71
  .all(limit);
72
72
  return rows.map((r) => this.toSlot(r));
73
73
  }
74
+ /** Completed cells whose summary enqueue or aggregate rollup was interrupted. */
75
+ getUnreportedTerminalSchedules() {
76
+ const rows = this.db.prepare(`SELECT s.* FROM schedule_slots s
77
+ WHERE s.manual_request_id IS NULL AND s.status != 'expired'
78
+ AND EXISTS (SELECT 1 FROM schedule_slot_items c WHERE c.slot_id = s.id)
79
+ AND NOT EXISTS (SELECT 1 FROM schedule_slot_items c WHERE c.slot_id = s.id
80
+ AND c.status NOT IN ('submitted', 'no_candidate', 'duplicate', 'failed'))
81
+ AND (s.status IN ('pending', 'running') OR NOT EXISTS (
82
+ SELECT 1 FROM outbox o WHERE o.idempotency_key = 'notification:' || s.id || ':summary'
83
+ ))`).all();
84
+ return rows.map((row) => this.toSlot(row));
85
+ }
74
86
  markSlotStatus(id, status, error) {
75
87
  const stamp = status === 'running' ? 'started_at' : status === 'success' || status === 'partial' || status === 'failed' ? 'completed_at' : null;
76
88
  const sets = ['status = @status', 'last_error = @error'];
@@ -198,6 +210,28 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
198
210
  WHERE slot_id = @slotId AND target_id = @targetId`)
199
211
  .run({ slotId, targetId });
200
212
  }
213
+ /** Durable candidate-fallback bookkeeping (§schedule-recovery). */
214
+ bumpFallbackStage(slotId, targetId, reason) {
215
+ const item = this.db
216
+ .prepare(`SELECT fallback_stage FROM schedule_slot_items WHERE slot_id = ? AND target_id = ?`)
217
+ .get(slotId, targetId);
218
+ const stage = Number(item?.fallback_stage ?? 0);
219
+ this.db
220
+ .prepare(`UPDATE schedule_slot_items
221
+ SET fallback_stage = ?, last_error = ?,
222
+ status = 'pending',
223
+ updated_at = CURRENT_TIMESTAMP,
224
+ completed_at = NULL
225
+ WHERE slot_id = ? AND target_id = ?`)
226
+ .run(stage + 1, String(reason).slice(0, 400), slotId, targetId);
227
+ return stage + 1;
228
+ }
229
+ cellFallbackStage(slotId, targetId) {
230
+ const item = this.db
231
+ .prepare(`SELECT fallback_stage FROM schedule_slot_items WHERE slot_id = ? AND target_id = ?`)
232
+ .get(slotId, targetId);
233
+ return Number(item?.fallback_stage ?? 0);
234
+ }
201
235
  setCellStatus(slotId, targetId, status, error) {
202
236
  const terminal = status === 'submitted' ||
203
237
  status === 'no_candidate' ||
@@ -379,6 +413,7 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
379
413
  workType: row.work_type,
380
414
  status: row.status,
381
415
  attemptCount: row.attempt_count,
416
+ fallback_stage: Number(row.fallback_stage ?? 0),
382
417
  lastError: row.last_error,
383
418
  createdAt: row.created_at,
384
419
  updatedAt: row.updated_at,
@@ -261,6 +261,20 @@ class ConfigValidator {
261
261
  });
262
262
  }
263
263
  }
264
+ if (delivery.scheduleOutcomeUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.scheduleOutcomeUrl)) {
265
+ try {
266
+ const url = new URL(delivery.scheduleOutcomeUrl);
267
+ if (!['http:', 'https:'].includes(url.protocol))
268
+ throw new Error('unsupported protocol');
269
+ }
270
+ catch {
271
+ errors.push({
272
+ code: 'CONFIG_VALIDATION_DELIVERY_SCHEDULE_OUTCOME_URL_INVALID',
273
+ field: `${prefix}.scheduleOutcomeUrl`,
274
+ message: `Delivery target '${name}': scheduleOutcomeUrl must be valid HTTP or HTTPS`,
275
+ });
276
+ }
277
+ }
264
278
  if (delivery.readinessUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.readinessUrl)) {
265
279
  try {
266
280
  const url = new URL(delivery.readinessUrl);
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.2', commit: 'cdaedd018e4d' };
5
+ exports.BUILD = { version: '2.20.4', commit: '5bb73436b670' };
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.2",
4
+ "version": "2.20.4",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixivflow",
3
- "version": "2.20.2",
3
+ "version": "2.20.4",
4
4
  "description": "🎨 智能的 Pixiv 自动化下载工具 - 支持批量下载插画和小说、定时任务、Docker部署 | Intelligent Pixiv Automation Downloader with batch download, scheduler, and Docker support",
5
5
  "repository": {
6
6
  "type": "git",