pixivflow 2.19.5 → 2.20.1

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 (31) hide show
  1. package/dist/commands/SchedulerCommand.js +36 -0
  2. package/dist/commands/SchedulerRunOnceCommand.d.ts +2 -3
  3. package/dist/commands/SchedulerRunOnceCommand.js +2 -3
  4. package/dist/commands/scheduler-runtime.js +17 -1
  5. package/dist/config/types.d.ts +7 -0
  6. package/dist/config/validation.js +10 -0
  7. package/dist/delivery/DeliveryService.d.ts +21 -1
  8. package/dist/delivery/DeliveryService.js +2 -2
  9. package/dist/delivery/HttpMultipartDelivery.js +33 -8
  10. package/dist/delivery/OutboxWorker.d.ts +2 -0
  11. package/dist/delivery/OutboxWorker.js +3 -0
  12. package/dist/delivery/types.d.ts +24 -0
  13. package/dist/download/handlers/IllustrationTargetHandler.d.ts +0 -1
  14. package/dist/download/handlers/IllustrationTargetHandler.js +2 -13
  15. package/dist/download/handlers/NovelTargetHandler.js +2 -0
  16. package/dist/download/handlers/deliveryContext.d.ts +16 -0
  17. package/dist/download/handlers/deliveryContext.js +32 -0
  18. package/dist/notification/NotificationPolicy.d.ts +12 -0
  19. package/dist/notification/NotificationPolicy.js +86 -0
  20. package/dist/package.json +1 -1
  21. package/dist/scheduler/MultiScheduleManager.js +2 -0
  22. package/dist/scheduler/ScheduleTriggerServer.d.ts +58 -1
  23. package/dist/scheduler/ScheduleTriggerServer.js +207 -40
  24. package/dist/scheduler/SlotCoordinator.d.ts +80 -2
  25. package/dist/scheduler/SlotCoordinator.js +145 -3
  26. package/dist/storage/DatabaseMigration.js +11 -0
  27. package/dist/storage/repositories/SlotRepository.d.ts +8 -0
  28. package/dist/storage/repositories/SlotRepository.js +6 -2
  29. package/dist/version.js +1 -1
  30. package/dist/webui/package.json +1 -1
  31. package/package.json +1 -1
@@ -157,6 +157,42 @@ class SchedulerCommand extends Command_1.BaseCommand {
157
157
  };
158
158
  },
159
159
  drainOutbox: () => runtime.drainOutbox(),
160
+ refetch: async (targetId, requestId, correlationId) => {
161
+ const cfg = resolveConfig();
162
+ const plans = (cfg.schedules ?? []).filter((plan) => plan.enabled !== false && (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).some((target) => target.id === targetId));
163
+ if (plans.length === 0)
164
+ throw new Error('unknown target');
165
+ if (plans.length !== 1)
166
+ throw new Error('ambiguous target');
167
+ const plan = plans[0];
168
+ const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
169
+ const now = new Date();
170
+ const date = new Intl.DateTimeFormat('en-CA', {
171
+ timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
172
+ }).format(now);
173
+ const slot = {
174
+ slotId: `${plan.id}@manual-${requestId.toLowerCase()}`,
175
+ scheduleId: plan.id,
176
+ occurrenceAt: now.getTime(),
177
+ occurrenceDate: date,
178
+ occurrenceLabel: 'manual',
179
+ timezone: plan.timezone ?? 'UTC',
180
+ triggerSource: 'manual',
181
+ slotName: '审核群重抓',
182
+ slotDate: date,
183
+ manualRequestId: requestId,
184
+ correlationId: correlationId || undefined,
185
+ };
186
+ const existing = runtime.database.slots.getSlot(slot.slotId);
187
+ if (existing && (existing.scheduleId !== plan.id || existing.targetIds.length !== 1 || existing.targetIds[0] !== targetId)) {
188
+ throw new Error('ambiguous target');
189
+ }
190
+ const prepared = coordinator.prepare(slot, plan, [target]);
191
+ if (prepared.alreadyCompleted)
192
+ return { slotId: slot.slotId, disposition: 'already_completed' };
193
+ const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
194
+ return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
195
+ },
160
196
  status: (scheduleId) => {
161
197
  const cfg = resolveConfig();
162
198
  const plan = findPlan(cfg, scheduleId);
@@ -2,9 +2,8 @@
2
2
  * Scheduler run-once command
3
3
  *
4
4
  * Runs every enabled schedule's download plan exactly once, then exits.
5
- * This is the backend for the review-group "重抓/换一张" refetch button:
6
- * a second scheduler daemon would never exit (and would double-fire cron),
7
- * so refetch needs a bounded one-shot invocation instead.
5
+ * This is the local CLI path. Remote review-group refetch uses the authenticated
6
+ * trigger server and a durable manual Slot so a sleeping worker can recover it.
8
7
  */
9
8
  import { BaseCommand } from './Command';
10
9
  import { CommandCategory } from './metadata';
@@ -3,9 +3,8 @@
3
3
  * Scheduler run-once command
4
4
  *
5
5
  * Runs every enabled schedule's download plan exactly once, then exits.
6
- * This is the backend for the review-group "重抓/换一张" refetch button:
7
- * a second scheduler daemon would never exit (and would double-fire cron),
8
- * so refetch needs a bounded one-shot invocation instead.
6
+ * This is the local CLI path. Remote review-group refetch uses the authenticated
7
+ * trigger server and a durable manual Slot so a sleeping worker can recover it.
9
8
  */
10
9
  Object.defineProperty(exports, "__esModule", { value: true });
11
10
  exports.SchedulerRunOnceCommand = void 0;
@@ -438,10 +438,26 @@ async function createSchedulerRuntime(configPathArg) {
438
438
  if (!target.id)
439
439
  return;
440
440
  options.onTargetOutcome?.(target.id, outcome);
441
- if (!scheduleSlot)
441
+ if (!scheduleSlot) {
442
+ // No durable slot (run-once CLI): nothing to converge or report.
442
443
  return;
444
+ }
443
445
  coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
444
446
  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
+ }
460
+ }
445
461
  });
446
462
  if (scheduleSlot) {
447
463
  downloadManager.slotContext = {
@@ -517,6 +517,13 @@ export interface HttpMultipartDeliveryConfig {
517
517
  url: string;
518
518
  /** Optional JSON endpoint used for no-match operational notifications. */
519
519
  notificationUrl?: string;
520
+ /**
521
+ * Optional JSON endpoint for remote manual replacement ("重抓") terminal
522
+ * verdicts (no_alternative / failed). Must be http(s); if unset, manual
523
+ * outcome reports are skipped (the work itself is still durable). Reuses
524
+ * `headers` for auth, so no extra credential is needed.
525
+ */
526
+ refetchOutcomeUrl?: string;
520
527
  method?: 'POST' | 'PUT';
521
528
  /** 支持 ${ENV_NAME} 环境变量插值 */
522
529
  headers?: Record<string, string>;
@@ -302,6 +302,16 @@ function validateConfig(config, location, databasePath) {
302
302
  errors.push(`${prefix}.notificationUrl: Must be a valid HTTP or HTTPS URL`);
303
303
  }
304
304
  }
305
+ if (delivery.refetchOutcomeUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.refetchOutcomeUrl)) {
306
+ try {
307
+ const url = new URL(delivery.refetchOutcomeUrl);
308
+ if (!['http:', 'https:'].includes(url.protocol))
309
+ throw new Error('unsupported protocol');
310
+ }
311
+ catch {
312
+ errors.push(`${prefix}.refetchOutcomeUrl: Must be a valid HTTP or HTTPS URL`);
313
+ }
314
+ }
305
315
  if (delivery.readinessUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.readinessUrl)) {
306
316
  try {
307
317
  const url = new URL(delivery.readinessUrl);
@@ -19,6 +19,26 @@ export interface EnqueueResult {
19
19
  /** True when this call created a brand new delivery row. */
20
20
  created: boolean;
21
21
  }
22
+ /**
23
+ * Machine-readable terminal verdict of a remote manual replacement ("重抓"),
24
+ * reported back to the requester (TelePost) through the durable outbox.
25
+ * Never contains credentials; ids are the opaque request UUID and Pixiv work ids.
26
+ */
27
+ export interface RefetchOutcomePayload {
28
+ requestId: string;
29
+ /** 'no_alternative' | 'failed' (replacement success rides the submission). */
30
+ disposition: 'no_alternative' | 'failed';
31
+ reason?: string;
32
+ workId?: string;
33
+ /** Bounded candidate-scan bookkeeping for diagnostics (spec-compatible). */
34
+ scanned?: number;
35
+ skipped?: {
36
+ total: number;
37
+ duplicate: number;
38
+ invalid: number;
39
+ unavailable: number;
40
+ };
41
+ }
22
42
  export declare class DeliveryService {
23
43
  private readonly database;
24
44
  constructor(database: Database);
@@ -76,7 +96,7 @@ export declare class DeliveryService {
76
96
  created: boolean;
77
97
  };
78
98
  /** Enqueue a durable notification (retried independently; never affects content). */
79
- enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string): void;
99
+ enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string, refetchOutcome?: RefetchOutcomePayload): void;
80
100
  private guardCell;
81
101
  private contextFrom;
82
102
  }
@@ -175,12 +175,12 @@ 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) {
178
+ enqueueNotification(deliveryTarget, text, idempotencyKey, refetchOutcome) {
179
179
  this.database.outbox.enqueue({
180
180
  kind: 'notification',
181
181
  deliveryTarget,
182
182
  idempotencyKey,
183
- payload: { text },
183
+ payload: refetchOutcome ? { text, refetchOutcome } : { text },
184
184
  });
185
185
  }
186
186
  guardCell(slotId, targetId, next) {
@@ -133,33 +133,54 @@ class HttpMultipartDelivery {
133
133
  }
134
134
  /** Single notification attempt; the outbox owns retries. */
135
135
  async notifyOnce(request) {
136
- const url = this.config.notificationUrl?.trim();
137
- if (!url)
138
- throw new Error('HTTP delivery notificationUrl is not configured');
136
+ const outcome = request.refetchOutcome;
137
+ const url = (outcome ? this.config.refetchOutcomeUrl?.trim() : this.config.notificationUrl?.trim());
138
+ if (!url) {
139
+ throw new Error(outcome
140
+ ? 'HTTP delivery refetchOutcomeUrl is not configured'
141
+ : 'HTTP delivery notificationUrl is not configured');
142
+ }
139
143
  const headers = {
140
144
  ...this.resolveHeaders(this.config.headers ?? {}),
141
145
  'Content-Type': 'application/json',
142
146
  };
147
+ // Refetch verdicts are machine-readable JSON (the requester's review state
148
+ // machine consumes disposition, not prose). Plain notifications remain
149
+ // {text, idempotency_key}.
150
+ const body = outcome
151
+ ? {
152
+ request_id: outcome.requestId,
153
+ disposition: outcome.disposition,
154
+ reason: outcome.reason,
155
+ work_id: outcome.workId,
156
+ scanned: outcome.scanned,
157
+ skipped: outcome.skipped,
158
+ }
159
+ : { text: request.text, idempotency_key: request.idempotencyKey };
143
160
  const options = {
144
161
  method: 'POST',
145
162
  headers,
146
- body: JSON.stringify({ text: request.text, idempotency_key: request.idempotencyKey }),
163
+ body: JSON.stringify(body),
147
164
  };
148
165
  if (this.dispatcher)
149
166
  options.dispatcher = this.dispatcher;
150
167
  const response = await fetch(this.interpolateEnvironment(url), options);
151
168
  const text = await response.text();
152
- let body = text;
169
+ let parsed = text;
153
170
  if (text) {
154
171
  try {
155
- body = JSON.parse(text);
172
+ parsed = JSON.parse(text);
156
173
  }
157
174
  catch { /* plain ok */ }
158
175
  }
159
176
  if (!response.ok)
160
177
  throw new Error(`notification endpoint returned HTTP ${response.status}`);
161
- logger_1.logger.info('HTTP delivery notification sent', { url: (0, redact_1.redactUrl)(url), status: response.status });
162
- return { status: response.status, body };
178
+ logger_1.logger.info('HTTP delivery notification sent', {
179
+ url: (0, redact_1.redactUrl)(url),
180
+ status: response.status,
181
+ hasRefetchOutcome: Boolean(outcome),
182
+ });
183
+ return { status: response.status, body: parsed };
163
184
  }
164
185
  async attempt(request) {
165
186
  const fields = this.resolveFields({ ...(this.config.fields ?? {}), ...(request.fields ?? {}) }, request);
@@ -390,6 +411,10 @@ function buildTemplateVariables(request) {
390
411
  // key) converges remotely as idempotent_replay instead of being mistaken for
391
412
  // a historical duplicate or, worse, double-posting.
392
413
  idempotencyKey: c.idempotencyKey ?? '',
414
+ // Remote manual replacement ("重抓") request UUID; empty for scheduled runs.
415
+ // Carried through the delivery payload context (extraContext) so the
416
+ // receiving service can correlate the review with its refetch attempt.
417
+ refetchRequestId: c.refetchRequestId ?? '',
393
418
  };
394
419
  }
395
420
  /**
@@ -29,6 +29,8 @@ export interface DeliveryPayload {
29
29
  /** Notification side-effect payload. */
30
30
  export interface NotificationPayload {
31
31
  text: string;
32
+ /** Optional structured remote-manual-replacement verdict (refetch outcome). */
33
+ refetchOutcome?: unknown;
32
34
  }
33
35
  /** Exponential backoff with jitter, capped. */
34
36
  export declare function backoffDelayMs(attempt: number, base: number, max: number): number;
@@ -211,6 +211,9 @@ class OutboxWorker {
211
211
  await this.dispatcher.notify(row.deliveryTarget, {
212
212
  text: payload.text,
213
213
  idempotencyKey: row.idempotencyKey ?? row.id,
214
+ refetchOutcome: payload.refetchOutcome !== undefined
215
+ ? payload.refetchOutcome
216
+ : undefined,
214
217
  });
215
218
  }
216
219
  else {
@@ -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
@@ -89,6 +95,24 @@ export interface DeliveryResult {
89
95
  export interface DeliveryNotificationRequest {
90
96
  text: string;
91
97
  idempotencyKey: string;
98
+ /**
99
+ * Optional structured remote-manual-replacement verdict. When present the
100
+ * delivery sends a JSON body to the target's `refetchOutcomeUrl` instead of
101
+ * a plain text notification to `notificationUrl` (auth reuses `headers`).
102
+ */
103
+ refetchOutcome?: {
104
+ requestId: string;
105
+ disposition: 'no_alternative' | 'failed';
106
+ reason?: string;
107
+ workId?: string;
108
+ scanned?: number;
109
+ skipped?: {
110
+ total: number;
111
+ duplicate: number;
112
+ invalid: number;
113
+ unavailable: number;
114
+ };
115
+ };
92
116
  }
93
117
  export interface DeliveryProvider {
94
118
  deliver(request: DeliveryRequest): Promise<DeliveryResult>;
@@ -110,6 +110,5 @@ export declare class IllustrationTargetHandler {
110
110
  * second concurrent worker lose this race instead of double-submitting.
111
111
  */
112
112
  private recordArtifactOutcome;
113
- private executionContextFields;
114
113
  }
115
114
  //# sourceMappingURL=IllustrationTargetHandler.d.ts.map
@@ -9,6 +9,7 @@ const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
9
9
  const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
10
10
  const target_label_1 = require("../../utils/target-label");
11
11
  const DownloadPlanner_1 = require("../plan/DownloadPlanner");
12
+ const deliveryContext_1 = require("./deliveryContext");
12
13
  class IllustrationTargetHandler {
13
14
  client;
14
15
  database;
@@ -681,7 +682,7 @@ class IllustrationTargetHandler {
681
682
  const res = this.deliveryService.enqueue(artifact, target, {
682
683
  slotId,
683
684
  fields: target.delivery?.fields,
684
- extraContext: this.executionContextFields(target),
685
+ extraContext: (0, deliveryContext_1.deliveryContextFields)(target),
685
686
  });
686
687
  if (res.duplicate) {
687
688
  return {
@@ -701,18 +702,6 @@ class IllustrationTargetHandler {
701
702
  });
702
703
  return { kind: 'selected', workId: artifact.pixivId, workType: artifact.type };
703
704
  }
704
- executionContextFields(target) {
705
- const ec = target.delivery;
706
- return {
707
- scheduleId: ec?.executionContext?.scheduleId,
708
- executionId: ec?.executionContext?.slotId,
709
- occurrenceAt: ec?.executionContext?.occurrenceAtIso,
710
- triggerSource: ec?.executionContext?.triggerSource,
711
- slotId: ec?.slotContext?.slotId ?? ec?.executionContext?.slotId,
712
- slotName: ec?.slotContext?.slotName ?? ec?.executionContext?.slotName,
713
- slotDate: ec?.slotContext?.slotDate ?? ec?.executionContext?.slotDate,
714
- };
715
- }
716
705
  }
717
706
  exports.IllustrationTargetHandler = IllustrationTargetHandler;
718
707
  //# sourceMappingURL=IllustrationTargetHandler.js.map
@@ -9,6 +9,7 @@ const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
9
9
  const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
10
10
  const target_label_1 = require("../../utils/target-label");
11
11
  const DownloadPlanner_1 = require("../plan/DownloadPlanner");
12
+ const deliveryContext_1 = require("./deliveryContext");
12
13
  class NovelTargetHandler {
13
14
  client;
14
15
  database;
@@ -702,6 +703,7 @@ class NovelTargetHandler {
702
703
  const res = this.deliveryService.enqueue(artifact, target, {
703
704
  slotId,
704
705
  fields: target.delivery?.fields,
706
+ extraContext: (0, deliveryContext_1.deliveryContextFields)(target),
705
707
  });
706
708
  if (res.duplicate) {
707
709
  return {
@@ -0,0 +1,16 @@
1
+ import { TargetConfig } from '../../config';
2
+ /**
3
+ * Delivery payload context for one target run, merging schedule/slot provenance
4
+ * with the optional remote manual replacement identity.
5
+ *
6
+ * Every key is ALWAYS present (empty string when absent) so delivery templates
7
+ * never render a literal `{{placeholder}}` into the submitted payload — a
8
+ * scheduled run and a manual refetch share the same field templates.
9
+ *
10
+ * `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
11
+ * that produced this delivery; it is empty for scheduled occurrences. The
12
+ * receiving service (TelePost) correlates the incoming review with the refetch
13
+ * attempt on this id.
14
+ */
15
+ export declare function deliveryContextFields(target: TargetConfig): Record<string, unknown>;
16
+ //# sourceMappingURL=deliveryContext.d.ts.map
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deliveryContextFields = deliveryContextFields;
4
+ /**
5
+ * Delivery payload context for one target run, merging schedule/slot provenance
6
+ * with the optional remote manual replacement identity.
7
+ *
8
+ * Every key is ALWAYS present (empty string when absent) so delivery templates
9
+ * never render a literal `{{placeholder}}` into the submitted payload — a
10
+ * scheduled run and a manual refetch share the same field templates.
11
+ *
12
+ * `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
13
+ * that produced this delivery; it is empty for scheduled occurrences. The
14
+ * receiving service (TelePost) correlates the incoming review with the refetch
15
+ * attempt on this id.
16
+ */
17
+ function deliveryContextFields(target) {
18
+ const ec = target.delivery;
19
+ const slotContext = ec?.slotContext;
20
+ const executionContext = ec?.executionContext;
21
+ return {
22
+ scheduleId: executionContext?.scheduleId ?? '',
23
+ executionId: executionContext?.slotId ?? '',
24
+ occurrenceAt: executionContext?.occurrenceAtIso ?? '',
25
+ triggerSource: executionContext?.triggerSource ?? '',
26
+ slotId: slotContext?.slotId ?? executionContext?.slotId ?? '',
27
+ slotName: slotContext?.slotName ?? executionContext?.slotName ?? '',
28
+ slotDate: slotContext?.slotDate ?? executionContext?.slotDate ?? '',
29
+ refetchRequestId: slotContext?.manualRequestId ?? '',
30
+ };
31
+ }
32
+ //# sourceMappingURL=deliveryContext.js.map
@@ -33,6 +33,7 @@ export declare class NotificationPolicy {
33
33
  hardFail: (slotId: string, targetId: string) => string;
34
34
  summary: (slotId: string) => string;
35
35
  dead: (slotId: string, targetId: string) => string;
36
+ refetchOutcome: (slotId: string, targetId: string) => string;
36
37
  };
37
38
  noteOutcome(slotId: string, slot: SlotContext, schedule: ScheduleConfig, target: TargetConfig, outcome: TargetOutcome): void;
38
39
  /** One consolidated summary per slot, delivered to every notifying target's endpoint. */
@@ -45,5 +46,16 @@ export declare class NotificationPolicy {
45
46
  error: string | null;
46
47
  }>): void;
47
48
  private send;
49
+ /**
50
+ * Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
51
+ * the requester through the durable outbox. Only terminal outcomes are
52
+ * reported (no_candidate / non-retryable failed); a successful replacement is
53
+ * correlated by the submission itself (its payload carries the request id).
54
+ *
55
+ * The key is anchored to the manual SLOT, so a slot recovered and re-run can
56
+ * never enqueue a second verdict for the same logical attempt, and helpers
57
+ * that already returned remain idempotent.
58
+ */
59
+ noteRefetchOutcome(slot: SlotContext, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
48
60
  }
49
61
  //# sourceMappingURL=NotificationPolicy.d.ts.map
@@ -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 logger_1 = require("../logger");
5
6
  /**
6
7
  * Central policy for all operational notifications. Handlers/scheduler do not
7
8
  * decide how or when to notify — they emit domain outcomes and this policy
@@ -44,6 +45,7 @@ class NotificationPolicy {
44
45
  hardFail: (slotId, targetId) => `notification:${slotId}:${targetId}:failed`,
45
46
  summary: (slotId) => `notification:${slotId}:summary`,
46
47
  dead: (slotId, targetId) => `notification:${slotId}:${targetId}:delivery-dead`,
48
+ refetchOutcome: (slotId, targetId) => `refetch-outcome:${slotId}:${targetId}`,
47
49
  };
48
50
  noteOutcome(slotId, slot, schedule, target, outcome) {
49
51
  const name = this.targetName(target);
@@ -104,6 +106,90 @@ class NotificationPolicy {
104
106
  console.warn('notification enqueue failed', { targetName, key, error: error.message });
105
107
  }
106
108
  }
109
+ /**
110
+ * Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
111
+ * the requester through the durable outbox. Only terminal outcomes are
112
+ * reported (no_candidate / non-retryable failed); a successful replacement is
113
+ * correlated by the submission itself (its payload carries the request id).
114
+ *
115
+ * The key is anchored to the manual SLOT, so a slot recovered and re-run can
116
+ * never enqueue a second verdict for the same logical attempt, and helpers
117
+ * that already returned remain idempotent.
118
+ */
119
+ noteRefetchOutcome(slot, _schedule, target, requestId, outcome) {
120
+ const name = this.targetName(target);
121
+ if (!name)
122
+ return;
123
+ const deliveryTarget = this.config.delivery?.targets?.[name];
124
+ if (!deliveryTarget || deliveryTarget.type !== 'httpMultipart')
125
+ return;
126
+ if (!deliveryTarget.refetchOutcomeUrl?.trim()) {
127
+ // No endpoint configured: skip the report (content is still durable).
128
+ logger_1.logger.info('Refetch outcome not reported: refetchOutcomeUrl unset', {
129
+ slot: slot.slotId,
130
+ target: target.id,
131
+ requestId,
132
+ });
133
+ return;
134
+ }
135
+ let payload;
136
+ if (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') {
137
+ payload = {
138
+ requestId,
139
+ disposition: 'no_alternative',
140
+ reason: outcome.reason,
141
+ workId: outcome.kind === 'duplicate' ? outcome.workId : undefined,
142
+ ...scanCounts(outcome.scan),
143
+ };
144
+ }
145
+ else if (outcome.kind === 'failed' && !outcome.retryable) {
146
+ payload = {
147
+ requestId,
148
+ disposition: 'failed',
149
+ reason: outcome.error,
150
+ ...scanCounts(outcome.scan),
151
+ };
152
+ }
153
+ else {
154
+ return; // not terminal for refetch purposes
155
+ }
156
+ try {
157
+ new DeliveryService_1.DeliveryService(this.database).enqueueNotification(name, `refetch outcome: ${payload.disposition} (slot ${slot.slotId})`, NotificationPolicy.keys.refetchOutcome(slot.slotId, target.id ?? target.type), payload);
158
+ logger_1.logger.info('Refetch outcome enqueued for report', {
159
+ slot: slot.slotId,
160
+ target: target.id,
161
+ requestId,
162
+ disposition: payload.disposition,
163
+ });
164
+ }
165
+ catch (error) {
166
+ // A failed VERDICT report never changes the terminal content state; it is
167
+ // retried by the operator/resume path, and never unwinds the run.
168
+ logger_1.logger.warn('Refetch outcome report enqueue failed', {
169
+ slot: slot.slotId,
170
+ target: target.id,
171
+ error: error instanceof Error ? error.message : String(error),
172
+ });
173
+ }
174
+ }
107
175
  }
108
176
  exports.NotificationPolicy = NotificationPolicy;
177
+ /** Fold a CandidateScanSummary into the refetch-outcome bookkeeping (bounded). */
178
+ function scanCounts(scan) {
179
+ if (!scan)
180
+ return {};
181
+ const skipped = scan.skipped ?? [];
182
+ const duplicate = skipped.filter((s) => s.code === 'duplicate').length;
183
+ const unavailable = skipped.filter((s) => s.code === 'unavailable').length;
184
+ const invalid = skipped.filter((s) => ['deleted', 'access_denied', 'unsupported_media', 'invalid_metadata', 'filtered'].includes(s.code)).length;
185
+ return {
186
+ scanned: scan.attempted,
187
+ skipped: {
188
+ total: skipped.length,
189
+ duplicate,
190
+ invalid,
191
+ unavailable,
192
+ },
193
+ };
194
+ }
109
195
  //# sourceMappingURL=NotificationPolicy.js.map
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.19.5",
4
+ "version": "2.20.1",
5
5
  "private": true
6
6
  }
@@ -178,6 +178,8 @@ class MultiScheduleManager {
178
178
  triggerSource: asTriggerSource(slot.triggerSource),
179
179
  slotName: slot.slotName || slot.occurrenceLabel,
180
180
  slotDate: slot.slotDate || slot.occurrenceDate,
181
+ manualRequestId: slot.manualRequestId ?? undefined,
182
+ correlationId: slot.correlationId ?? undefined,
181
183
  };
182
184
  const admitted = this.triggerSchedule(slot.scheduleId, {
183
185
  triggerSource: context.triggerSource,