pixivflow 2.20.0 → 2.20.2

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.
@@ -166,6 +166,14 @@ class SchedulerCommand extends Command_1.BaseCommand {
166
166
  throw new Error('ambiguous target');
167
167
  const plan = plans[0];
168
168
  const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
169
+ const deliveryName = target.delivery?.target;
170
+ const delivery = deliveryName ? cfg.delivery?.targets?.[deliveryName] : undefined;
171
+ if (delivery?.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim()) {
172
+ throw new Error('refetch outcome endpoint not configured');
173
+ }
174
+ if ((target.delivery?.fields?.refetch_request_id ?? delivery.fields?.refetch_request_id) !== '{{refetchRequestId}}') {
175
+ throw new Error('refetch_request_id delivery field not configured');
176
+ }
169
177
  const now = new Date();
170
178
  const date = new Intl.DateTimeFormat('en-CA', {
171
179
  timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
@@ -193,6 +201,13 @@ class SchedulerCommand extends Command_1.BaseCommand {
193
201
  const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
194
202
  return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
195
203
  },
204
+ refetchStatus: (targetId, requestId) => {
205
+ const slot = runtime.database.slots.findManualSlot(requestId, targetId);
206
+ const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
207
+ return slot && cell
208
+ ? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
209
+ : null;
210
+ },
196
211
  status: (scheduleId) => {
197
212
  const cfg = resolveConfig();
198
213
  const plan = findPlan(cfg, scheduleId);
@@ -239,6 +239,7 @@ async function createSchedulerRuntime(configPathArg) {
239
239
  // Independently-pumped durable outbox (content + notifications). Started in
240
240
  // the long-running scheduler daemon; run-once drains explicitly before exit.
241
241
  const deliveryDispatcher = new DeliveryDispatcher_1.DeliveryDispatcher(config.delivery, buildProxyUrl(config.network));
242
+ const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
242
243
  const outboxWorker = new OutboxWorker_1.OutboxWorker(database, deliveryDispatcher, {
243
244
  retryBaseMs: config.delivery?.outboxRetryBaseMs,
244
245
  retryMaxMs: config.delivery?.outboxRetryMaxMs,
@@ -246,9 +247,22 @@ async function createSchedulerRuntime(configPathArg) {
246
247
  // failed); see settleDeliveryTerminal for the invariant it enforces.
247
248
  onDeliveryTerminal: (deliveryId, ack) => {
248
249
  (0, settleDeliveryTerminal_1.settleDeliveryTerminal)(database, deliveryId, ack);
250
+ const delivery = database.deliveries.getById(deliveryId);
251
+ if (delivery?.slotId && delivery.targetId)
252
+ notificationPolicy.noteTerminalRefetchCell(delivery.slotId, delivery.targetId);
253
+ },
254
+ onDead: (row, error) => {
255
+ if (!row.deliveryId)
256
+ return;
257
+ const delivery = database.deliveries.getById(row.deliveryId);
258
+ if (!delivery?.slotId || !delivery.targetId)
259
+ return;
260
+ new SlotCoordinator_1.SlotCoordinator(database).applyOutcome(delivery.slotId, delivery.targetId, {
261
+ kind: 'failed', retryable: false, error,
262
+ });
263
+ notificationPolicy.noteTerminalRefetchCell(delivery.slotId, delivery.targetId);
249
264
  },
250
265
  });
251
- const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
252
266
  const runJob = async (snapshot, schedule, options = {}) => {
253
267
  const { onlyTarget, adhoc = false, slot: providedSlot } = options;
254
268
  // A scheduled run is cron by default; an HTTP/manual trigger passes its own.
@@ -363,6 +377,14 @@ async function createSchedulerRuntime(configPathArg) {
363
377
  cancelled = true;
364
378
  slotAbandoned = true;
365
379
  clearInterval(heartbeat);
380
+ for (const target of targets) {
381
+ if (!target.id)
382
+ continue;
383
+ if (database.slots.getCell(activeSlot.slotId, target.id)?.status === 'delivery_pending')
384
+ continue;
385
+ coordinator.applyOutcome(activeSlot.slotId, target.id, { kind: 'failed', retryable: false, error: reason });
386
+ notificationPolicy.noteTerminalRefetchCell(activeSlot.slotId, target.id);
387
+ }
366
388
  database.slots.markSlotStatus(activeSlot.slotId, 'failed', `abandoned after scheduler timeout; no delivery (${reason})`);
367
389
  coordinator.releaseRunLease(activeSlot.slotId, runOwner);
368
390
  activeLeaseHooks = null;
@@ -384,6 +406,9 @@ async function createSchedulerRuntime(configPathArg) {
384
406
  if (slotCtx && runTargets.length === 0) {
385
407
  logger_1.logger.info('All slot cells already complete', { slot: slotCtx.slotId });
386
408
  coordinator.finish(slotCtx, schedule, targets);
409
+ for (const target of targets)
410
+ if (target.id)
411
+ notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
387
412
  // Release LAST: the slot must stay owned until its aggregate state has
388
413
  // been rolled up, otherwise a concurrent trigger could claim and re-run it
389
414
  // against a half-finished ledger.
@@ -444,19 +469,11 @@ async function createSchedulerRuntime(configPathArg) {
444
469
  }
445
470
  coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
446
471
  notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
447
- // A remote manual replacement ("重抓") must report its terminal verdict
448
- // back to the reviewer. Only terminal outcomes are reported: a candidate
449
- // the scan skipped is not a verdict, and a durable delivery intent
450
- // ('delivery_pending' / later 'submitted') is reported through the
451
- // replacement submission itself (the caller correlates on requestId).
452
- const manualRequestId = scheduleSlot.manualRequestId;
453
- if (manualRequestId) {
454
- const terminal = outcome.kind === 'no_candidate' ||
455
- outcome.kind === 'duplicate' ||
456
- (outcome.kind === 'failed' && !outcome.retryable);
457
- if (terminal) {
458
- notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, manualRequestId, outcome);
459
- }
472
+ if (scheduleSlot.manualRequestId && (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate' ||
473
+ (outcome.kind === 'failed' && !outcome.retryable))) {
474
+ // Preserve scan bookkeeping; later durable-cell reporting is the
475
+ // fallback for retry exhaustion, timeout, and outbox dead-letter.
476
+ notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, scheduleSlot.manualRequestId, outcome);
460
477
  }
461
478
  });
462
479
  if (scheduleSlot) {
@@ -509,6 +526,9 @@ async function createSchedulerRuntime(configPathArg) {
509
526
  // non-terminal so recovery resumes the same occurrence after restart.
510
527
  if (slotCtx && shouldTerminaliseAbortedSlot(activeAbortOrigin, slotAbandoned)) {
511
528
  coordinator.finish(slotCtx, schedule, targets);
529
+ for (const target of targets)
530
+ if (target.id)
531
+ notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
512
532
  }
513
533
  // Hand the lease back either way: a terminal Slot cannot be re-dispatched,
514
534
  // and a non-terminal one (shutdown) must not wait for its TTL to expire.
@@ -527,6 +547,9 @@ async function createSchedulerRuntime(configPathArg) {
527
547
  const duration = Math.round((Date.now() - startTime) / 1000);
528
548
  if (slotCtx && !slotAbandoned) {
529
549
  const summary = coordinator.finish(slotCtx, schedule, targets);
550
+ for (const target of targets)
551
+ if (target.id)
552
+ notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
530
553
  notificationPolicy.sendSlotSummary(slotCtx, schedule, summary.cells.map((c) => {
531
554
  const t = targets.find((x) => x.id === c.targetId);
532
555
  return {
@@ -53,8 +53,8 @@ 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 (!target.notificationUrl?.trim()) {
57
- throw new errors_1.ConfigError(`Delivery target does not configure notificationUrl: ${name}`);
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}`);
58
58
  }
59
59
  return new HttpMultipartDelivery_1.HttpMultipartDelivery(target, this.proxyUrl).notifyOnce(request);
60
60
  }
@@ -161,6 +161,7 @@ class HttpMultipartDelivery {
161
161
  method: 'POST',
162
162
  headers,
163
163
  body: JSON.stringify(body),
164
+ signal: AbortSignal.timeout(5 * 60_000),
164
165
  };
165
166
  if (this.dispatcher)
166
167
  options.dispatcher = this.dispatcher;
@@ -195,6 +196,7 @@ class HttpMultipartDelivery {
195
196
  body: multipart.body,
196
197
  headers,
197
198
  duplex: 'half',
199
+ signal: AbortSignal.timeout(5 * 60_000),
198
200
  };
199
201
  if (this.dispatcher)
200
202
  options.dispatcher = this.dispatcher;
@@ -255,6 +257,9 @@ class HttpMultipartDelivery {
255
257
  return Object.fromEntries(Object.entries(fields).map(([name, value]) => {
256
258
  const values = Array.isArray(value) ? value : [value];
257
259
  const rendered = values.map((item) => renderDeliveryTemplate(String(item), variables));
260
+ if (name === 'refetch_request_id' && rendered.some((item) => /\{\{[^{}]+\}\}/.test(item))) {
261
+ throw new Error('Unresolved refetch_request_id template');
262
+ }
258
263
  switch (this.config.arrayFormat ?? 'comma') {
259
264
  case 'repeat':
260
265
  return [name, rendered];
@@ -411,6 +416,10 @@ function buildTemplateVariables(request) {
411
416
  // key) converges remotely as idempotent_replay instead of being mistaken for
412
417
  // a historical duplicate or, worse, double-posting.
413
418
  idempotencyKey: c.idempotencyKey ?? '',
419
+ // Remote manual replacement ("重抓") request UUID; empty for scheduled runs.
420
+ // Carried through the delivery payload context (extraContext) so the
421
+ // receiving service can correlate the review with its refetch attempt.
422
+ refetchRequestId: c.refetchRequestId ?? '',
414
423
  };
415
424
  }
416
425
  /**
@@ -48,6 +48,12 @@ export interface DeliveryContext {
48
48
  bookmarkCount?: number;
49
49
  /** Pixiv view count — rendered as {{viewCount}}. */
50
50
  viewCount?: number;
51
+ /**
52
+ * Request UUID of the remote manual replacement ("重抓") that produced this
53
+ * delivery; EMPTY for scheduled/original runs. The receiving service uses it
54
+ * to correlate the review with its durable refetch attempt.
55
+ */
56
+ refetchRequestId?: string;
51
57
  /**
52
58
  * Generic schedule-execution provenance. Attached to scheduled runs only
53
59
  * (absent for ad-hoc/manual runs). Delivery-agnostic: any adapter may surface
@@ -56,6 +56,8 @@ export declare class NotificationPolicy {
56
56
  * never enqueue a second verdict for the same logical attempt, and helpers
57
57
  * that already returned remain idempotent.
58
58
  */
59
- noteRefetchOutcome(slot: SlotContext, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
59
+ noteRefetchOutcome(slot: Pick<SlotContext, 'slotId'>, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
60
+ /** Report the durable terminal cell, including failures finalized after retry exhaustion. */
61
+ noteTerminalRefetchCell(slotId: string, targetId: string): void;
60
62
  }
61
63
  //# sourceMappingURL=NotificationPolicy.d.ts.map
@@ -172,6 +172,25 @@ class NotificationPolicy {
172
172
  });
173
173
  }
174
174
  }
175
+ /** Report the durable terminal cell, including failures finalized after retry exhaustion. */
176
+ noteTerminalRefetchCell(slotId, targetId) {
177
+ const slot = this.database.slots.getSlot(slotId);
178
+ const cell = this.database.slots.getCell(slotId, targetId);
179
+ if (!slot?.manualRequestId || !cell)
180
+ return;
181
+ const target = this.config.targets.find((item) => item.id === targetId);
182
+ if (!target)
183
+ return;
184
+ const outcome = cell.status === 'no_candidate'
185
+ ? { kind: 'no_candidate', reason: cell.lastError ?? 'no eligible candidate' }
186
+ : cell.status === 'duplicate'
187
+ ? { kind: 'duplicate', workId: cell.workId ?? '', reason: cell.lastError ?? 'historical duplicate' }
188
+ : cell.status === 'failed'
189
+ ? { kind: 'failed', retryable: false, error: cell.lastError ?? slot.lastError ?? 'manual refetch failed' }
190
+ : null;
191
+ if (outcome)
192
+ this.noteRefetchOutcome({ slotId }, { id: slot.scheduleId }, target, slot.manualRequestId, outcome);
193
+ }
175
194
  }
176
195
  exports.NotificationPolicy = NotificationPolicy;
177
196
  /** Fold a CandidateScanSummary into the refetch-outcome bookkeeping (bounded). */
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.20.0",
4
+ "version": "2.20.2",
5
5
  "private": true
6
6
  }
@@ -105,6 +105,12 @@ export interface TriggerHandlers {
105
105
  slotId: string;
106
106
  disposition: string;
107
107
  }>;
108
+ refetchStatus?(targetId: string, requestId: string): {
109
+ requestId: string;
110
+ slotId: string;
111
+ state: string;
112
+ slotStatus: string;
113
+ } | null;
108
114
  }
109
115
  export declare class ScheduleTriggerServer {
110
116
  private readonly token;
@@ -194,6 +194,19 @@ class ScheduleTriggerServer {
194
194
  res.status(status).json({ status: 'error', error: status === 500 ? 'refetch admission failed' : message });
195
195
  }
196
196
  });
197
+ app.get('/internal/targets/:targetId/refetch/:requestId', this.refetchAuth, (req, res) => {
198
+ const { targetId, requestId } = req.params;
199
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)) {
200
+ res.status(400).json({ status: 'error', error: 'requestId must be a UUID' });
201
+ return;
202
+ }
203
+ const status = this.handlers.refetchStatus?.(targetId, requestId);
204
+ if (!status) {
205
+ res.status(404).json({ status: 'error', error: 'manual refetch not found' });
206
+ return;
207
+ }
208
+ res.json(status);
209
+ });
197
210
  // Convergence endpoint: after a machine stop/start, an operator or an
198
211
  // external watcher can ask the process to flush due deliveries/notifications
199
212
  // without running candidate selection. Deployment-agnostic (no platform refs).
@@ -168,7 +168,7 @@ class SlotCoordinator {
168
168
  const cell = this.database.slots.getCell(slotId, target.id);
169
169
  if (!cell)
170
170
  continue;
171
- if (cell.status === 'submitted' || cell.status === 'no_candidate')
171
+ if (cell.status === 'submitted' || cell.status === 'no_candidate' || cell.status === 'duplicate' || cell.status === 'failed')
172
172
  continue;
173
173
  // A cell whose work already has a durable delivery intent is NOT the
174
174
  // scheduler's to re-run: the OutboxWorker retries the SAME work to a
@@ -53,6 +53,8 @@ export interface SlotItemRecord {
53
53
  * the same row instead of emitting a second work for the same slot/target.
54
54
  */
55
55
  export declare class SlotRepository extends BaseRepository {
56
+ /** Exact manual request/target lookup for authenticated convergence checks. */
57
+ findManualSlot(requestId: string, targetId: string): SlotRecord | null;
56
58
  /**
57
59
  * Fetch an existing slot or create it. On creation the schedule's target
58
60
  * membership is snapshotted (target_ids); a later config reload never mutates
@@ -12,6 +12,11 @@ const BaseRepository_1 = require("./BaseRepository");
12
12
  * the same row instead of emitting a second work for the same slot/target.
13
13
  */
14
14
  class SlotRepository extends BaseRepository_1.BaseRepository {
15
+ /** Exact manual request/target lookup for authenticated convergence checks. */
16
+ findManualSlot(requestId, targetId) {
17
+ const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE manual_request_id = ?`).all(requestId);
18
+ return rows.map((row) => this.toSlot(row)).find((slot) => slot.targetIds.includes(targetId)) ?? null;
19
+ }
15
20
  /**
16
21
  * Fetch an existing slot or create it. On creation the schedule's target
17
22
  * membership is snapshotted (target_ids); a later config reload never mutates
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.0', commit: '2229fb7b45a6' };
5
+ exports.BUILD = { version: '2.20.2', commit: 'cdaedd018e4d' };
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.0",
4
+ "version": "2.20.2",
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.0",
3
+ "version": "2.20.2",
4
4
  "description": "🎨 智能的 Pixiv 自动化下载工具 - 支持批量下载插画和小说、定时任务、Docker部署 | Intelligent Pixiv Automation Downloader with batch download, scheduler, and Docker support",
5
5
  "repository": {
6
6
  "type": "git",