pixivflow 2.20.5 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.en.md +179 -56
  2. package/README.md +110 -57
  3. package/dist/commands/SchedulerCommand.js +63 -0
  4. package/dist/commands/SchedulerIdleLifecycle.d.ts +8 -0
  5. package/dist/commands/SchedulerIdleLifecycle.js +2 -1
  6. package/dist/commands/scheduler-runtime.js +17 -0
  7. package/dist/config/types.d.ts +26 -0
  8. package/dist/config/validation.js +21 -0
  9. package/dist/delivery/types.d.ts +15 -0
  10. package/dist/notification/NotificationPolicy.d.ts +2 -0
  11. package/dist/notification/NotificationPolicy.js +18 -1
  12. package/dist/package.json +1 -1
  13. package/dist/scheduler/MultiScheduleManager.d.ts +12 -1
  14. package/dist/scheduler/MultiScheduleManager.js +46 -50
  15. package/dist/scheduler/RecoveryPolicy.d.ts +45 -0
  16. package/dist/scheduler/RecoveryPolicy.js +63 -0
  17. package/dist/scheduler/ResourceAdmission.d.ts +68 -0
  18. package/dist/scheduler/ResourceAdmission.js +83 -0
  19. package/dist/scheduler/ScheduleTriggerServer.d.ts +16 -0
  20. package/dist/scheduler/ScheduleTriggerServer.js +55 -0
  21. package/dist/scheduler/Scheduler.d.ts +19 -2
  22. package/dist/scheduler/Scheduler.js +12 -3
  23. package/dist/scheduler/SlotCoordinator.d.ts +20 -0
  24. package/dist/scheduler/SlotCoordinator.js +44 -2
  25. package/dist/scheduler/TargetOutcome.d.ts +31 -0
  26. package/dist/scheduler/TargetOutcome.js +114 -0
  27. package/dist/storage/DatabaseMigration.js +17 -0
  28. package/dist/storage/repositories/SlotRepository.d.ts +29 -0
  29. package/dist/storage/repositories/SlotRepository.js +28 -2
  30. package/dist/version.js +1 -1
  31. package/dist/webui/package.json +1 -1
  32. package/package.json +2 -2
@@ -208,6 +208,65 @@ class SchedulerCommand extends Command_1.BaseCommand {
208
208
  ? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
209
209
  : null;
210
210
  },
211
+ /**
212
+ * Manual recovery of a FAILED target (§manual-recovery). Distinct
213
+ * from refetch: there is no review chain to replace and no
214
+ * replacement payload — the target is simply re-acquired under a
215
+ * server-defined policy preset, and its outcome is reported through
216
+ * the ordinary schedule-outcome channel so the automatic history is
217
+ * never rewritten.
218
+ */
219
+ recover: async (targetId, requestId, retryMode, correlationId) => {
220
+ const cfg = resolveConfig();
221
+ const plans = (cfg.schedules ?? []).filter((plan) => plan.enabled !== false && (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).some((target) => target.id === targetId));
222
+ if (plans.length === 0)
223
+ throw new Error('unknown target');
224
+ if (plans.length !== 1)
225
+ throw new Error('ambiguous target');
226
+ const plan = plans[0];
227
+ const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
228
+ const deliveryName = target.delivery?.target;
229
+ const delivery = deliveryName ? cfg.delivery?.targets?.[deliveryName] : undefined;
230
+ if (delivery?.type !== 'httpMultipart' || !delivery.scheduleOutcomeUrl?.trim()) {
231
+ throw new Error('schedule outcome endpoint not configured');
232
+ }
233
+ const now = new Date();
234
+ const date = new Intl.DateTimeFormat('en-CA', {
235
+ timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
236
+ }).format(now);
237
+ const slot = {
238
+ slotId: `${plan.id}@recover-${requestId.toLowerCase()}`,
239
+ scheduleId: plan.id,
240
+ occurrenceAt: now.getTime(),
241
+ occurrenceDate: date,
242
+ occurrenceLabel: 'manual',
243
+ timezone: plan.timezone ?? 'UTC',
244
+ triggerSource: 'manual',
245
+ slotName: retryMode === 'relaxed' ? '手动恢复(放宽条件重试)' : '手动恢复(再试一次)',
246
+ slotDate: date,
247
+ recoveryRequestId: requestId,
248
+ recoveryMode: retryMode,
249
+ correlationId: correlationId || undefined,
250
+ };
251
+ const existing = runtime.database.slots.getSlot(slot.slotId);
252
+ if (existing && (existing.scheduleId !== plan.id || existing.targetIds.length !== 1 || existing.targetIds[0] !== targetId)) {
253
+ throw new Error('ambiguous target');
254
+ }
255
+ const prepared = coordinator.prepare(slot, plan, [target]);
256
+ if (prepared.alreadyCompleted)
257
+ return { slotId: slot.slotId, disposition: 'already_completed' };
258
+ // Same resource admission as every other Pixiv-consuming work
259
+ // item: a busy account queues this run, it does not fail it.
260
+ const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
261
+ return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
262
+ },
263
+ recoverStatus: (targetId, requestId) => {
264
+ const slot = runtime.database.slots.findRecoverySlot(requestId, targetId);
265
+ const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
266
+ return slot && cell
267
+ ? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
268
+ : null;
269
+ },
211
270
  status: (scheduleId) => {
212
271
  const cfg = resolveConfig();
213
272
  const plan = findPlan(cfg, scheduleId);
@@ -258,6 +317,10 @@ class SchedulerCommand extends Command_1.BaseCommand {
258
317
  // Second belt: never exit underneath an in-flight slot execution
259
318
  // even if a durable row ever looks a beat early (§idle-inflight).
260
319
  activeExecutions: runtime.activeExecutionCount(),
320
+ // Third belt: work parked waiting for resource capacity is
321
+ // unfinished work (§resource-governance) — a queued run must not
322
+ // let the machine stop before it runs.
323
+ waitingForResource: manager.waitingWorkCount(),
261
324
  };
262
325
  },
263
326
  idleGraceMs: scheduling.idleGraceMs,
@@ -50,6 +50,14 @@ export interface IdleSnapshot {
50
50
  * durable slot/outbox rows.
51
51
  */
52
52
  activeExecutions: number;
53
+ /**
54
+ * Work items parked in the resource admission queue (§resource-governance).
55
+ * Waiting for capacity is UNFINISHED work, not idleness: a queued run has a
56
+ * durable slot row (counted above) but may not have reached `claimRunLease`
57
+ * yet in internal mode, so this explicit belt prevents the worker from
58
+ * shutting down while a run is still waiting to start.
59
+ */
60
+ waitingForResource: number;
53
61
  }
54
62
  /** True only when the worker has no work of any kind left. */
55
63
  export declare function isIdle(snapshot: IdleSnapshot): boolean;
@@ -39,7 +39,8 @@ function isIdle(snapshot) {
39
39
  return (snapshot.activeSlots === 0 &&
40
40
  snapshot.processingOutbox === 0 &&
41
41
  snapshot.pendingOutbox === 0 &&
42
- snapshot.activeExecutions === 0);
42
+ snapshot.activeExecutions === 0 &&
43
+ snapshot.waitingForResource === 0);
43
44
  }
44
45
  /** Default idle grace: long enough to merge two schedules ~10 minutes apart. */
45
46
  exports.DEFAULT_IDLE_GRACE_MS = 10 * 60 * 1000;
@@ -58,6 +58,7 @@ const DeliveryDispatcher_1 = require("../delivery/DeliveryDispatcher");
58
58
  const token_maintenance_1 = require("../utils/token-maintenance");
59
59
  const schedules_1 = require("../scheduler/schedules");
60
60
  const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
61
+ const RecoveryPolicy_1 = require("../scheduler/RecoveryPolicy");
61
62
  const DeliveryLedgerPort_1 = require("../delivery/DeliveryLedgerPort");
62
63
  const OutboxWorker_1 = require("../delivery/OutboxWorker");
63
64
  const settleDeliveryTerminal_1 = require("../delivery/settleDeliveryTerminal");
@@ -286,6 +287,20 @@ async function createSchedulerRuntime(configPathArg) {
286
287
  // "重抓/换一张" 只重跑产生该审核的那一个 target。
287
288
  targets = targets.filter((t) => t.id === onlyTarget);
288
289
  }
290
+ // Occurrence-scoped acquisition policy (§recovery-policy). A manual recovery
291
+ // slot carries a server-defined preset; `relaxed` widens only SOFT criteria
292
+ // (search range / candidate count / language window) for THIS occurrence.
293
+ // The global config is never written, so future schedules are unaffected.
294
+ const recoveryMode = providedSlot?.recoveryMode;
295
+ if (recoveryMode && recoveryMode !== 'normal') {
296
+ targets = targets.map((target) => (0, RecoveryPolicy_1.applyAcquisitionPolicy)(target, recoveryMode));
297
+ logger_1.logger.info('Manual recovery policy applied to this occurrence', {
298
+ scheduleId: schedule.id,
299
+ slot: providedSlot?.slotId,
300
+ recoveryMode,
301
+ targets: targets.map((t) => t.id),
302
+ });
303
+ }
289
304
  if (targets.length === 0) {
290
305
  logger_1.logger.warn('Scheduled plan has no selected targets; skipping', {
291
306
  scheduleId: schedule.id,
@@ -639,6 +654,8 @@ async function createSchedulerRuntime(configPathArg) {
639
654
  status: c.status,
640
655
  workId: c.workId,
641
656
  error: c.error ?? null,
657
+ terminal_reason_code: c.terminalReasonCode ?? null,
658
+ reason: c.terminalReasonMessage ?? null,
642
659
  };
643
660
  }));
644
661
  }
@@ -279,6 +279,25 @@ export interface PixivCredentialConfig {
279
279
  deviceToken: string;
280
280
  refreshToken: string;
281
281
  userAgent: string;
282
+ /**
283
+ * Stable internal identity of this credential profile (§resource-governance).
284
+ * It is the RESOURCE identity used for admission keys (`pixiv-account:<id>`),
285
+ * never a token/cookie and never a bot/schedule/target name. Default 'default'.
286
+ */
287
+ accountId?: string;
288
+ }
289
+ /**
290
+ * Operator-level resource capacity configuration (§resource-governance).
291
+ * Concurrency is scoped by the real constrained resource, not by bot/schedule/
292
+ * target. Current production resource: the single Pixiv account profile, with
293
+ * `maxConcurrency: 1` recommended. This is deployment config — never exposed
294
+ * to ordinary manager UI.
295
+ */
296
+ export interface ResourceGovernanceConfig {
297
+ /** Per-Pixiv-account-profile capacity. Keyed by `pixiv.accountId`. */
298
+ pixivAccounts?: Record<string, {
299
+ maxConcurrency?: number;
300
+ }>;
282
301
  }
283
302
  export interface NetworkConfig {
284
303
  /**
@@ -473,6 +492,13 @@ export interface SchedulerRuntimeConfig {
473
492
  * resumes from the same durable Slot/outbox rows. Default: 10800000ms (3h).
474
493
  */
475
494
  maxLifetimeMs?: number;
495
+ /**
496
+ * Operator-level resource capacity (§resource-governance). Concurrency is
497
+ * scoped by the real constrained resource (e.g. the shared Pixiv account),
498
+ * never by bot/schedule/target. Waiting for capacity is a normal state, not
499
+ * an execution failure. Not exposed to ordinary manager UI.
500
+ */
501
+ resourceGovernance?: ResourceGovernanceConfig;
476
502
  /**
477
503
  * External-mode HTTP trigger settings. Ignored in internal mode.
478
504
  */
@@ -473,6 +473,27 @@ function validateConfig(config, location, databasePath) {
473
473
  errors.push('schedulerRuntime.trigger.graceMinutes: Must be a positive integer (minutes)');
474
474
  }
475
475
  }
476
+ const rg = rt.resourceGovernance;
477
+ if (rg) {
478
+ if (rg.pixivAccounts !== undefined && (typeof rg.pixivAccounts !== 'object' || rg.pixivAccounts === null || Array.isArray(rg.pixivAccounts))) {
479
+ errors.push('schedulerRuntime.resourceGovernance.pixivAccounts: Must be an object keyed by pixiv account id');
480
+ }
481
+ else if (rg.pixivAccounts) {
482
+ for (const [accountId, profile] of Object.entries(rg.pixivAccounts)) {
483
+ if (!accountId.trim()) {
484
+ errors.push('schedulerRuntime.resourceGovernance.pixivAccounts: account ids must be non-empty');
485
+ }
486
+ if (profile === null || typeof profile !== 'object' || Array.isArray(profile)) {
487
+ errors.push(`schedulerRuntime.resourceGovernance.pixivAccounts.${accountId}: Must be an object`);
488
+ continue;
489
+ }
490
+ const max = profile.maxConcurrency;
491
+ if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 16)) {
492
+ errors.push(`schedulerRuntime.resourceGovernance.pixivAccounts.${accountId}.maxConcurrency: Must be an integer between 1 and 16`);
493
+ }
494
+ }
495
+ }
496
+ }
476
497
  }
477
498
  // Validate download config
478
499
  if (config.download) {
@@ -122,11 +122,26 @@ export interface DeliveryNotificationRequest {
122
122
  scheduleId: string;
123
123
  slotId: string;
124
124
  status: 'success' | 'partial' | 'failed';
125
+ /**
126
+ * Present when this terminal outcome belongs to a MANUAL RECOVERY run
127
+ * (§manual-recovery): the request id and the policy preset used. Lets the
128
+ * receiving service render "已恢复" instead of the daily summary.
129
+ */
130
+ recovery?: {
131
+ mode: 'normal' | 'relaxed';
132
+ requestId: string;
133
+ };
125
134
  targets?: Array<{
126
135
  targetId: string;
127
136
  workType: string;
128
137
  status: string;
129
138
  workId?: string | null;
139
+ /** Raw cell error (request-level observability; NOT user-safe text). */
140
+ error?: string | null;
141
+ /** Normalized terminal failure code (§terminal-reason). */
142
+ terminal_reason_code?: string | null;
143
+ /** User-facing business reason message for the failure. */
144
+ reason?: string | null;
130
145
  }>;
131
146
  };
132
147
  }
@@ -44,6 +44,8 @@ export declare class NotificationPolicy {
44
44
  status: string;
45
45
  workId: string | null;
46
46
  error: string | null;
47
+ terminal_reason_code?: string | null;
48
+ reason?: string | null;
47
49
  }>): void;
48
50
  /**
49
51
  * Delivery targets whose HTTP target declares the given outcome URL.
@@ -88,7 +88,13 @@ class NotificationPolicy {
88
88
  (r.workId ? ` #${r.workId}` : '') +
89
89
  (r.status === 'delivery_pending' ? ' 投递中' : '') +
90
90
  (r.status === 'no_candidate' ? ' 无候选' : '') +
91
- (r.status === 'failed' && r.error ? ` ${r.error.slice(0, 120)}` : ''));
91
+ // First-level cause, business language only: the durable normalized
92
+ // reason replaces any raw error text (§terminal-reason).
93
+ ((r.status === 'failed' || r.status === 'no_candidate' || r.status === 'duplicate') && r.reason
94
+ ? ` ${r.reason.slice(0, 80)}`
95
+ : r.status === 'failed' && r.error
96
+ ? ` ${r.error.slice(0, 80)}`
97
+ : ''));
92
98
  const submitted = rows.filter((r) => r.status === 'submitted').length;
93
99
  const text = [
94
100
  `${schedule.name?.trim() || schedule.id} · ${slot.occurrenceLabel}`,
@@ -102,11 +108,22 @@ class NotificationPolicy {
102
108
  scheduleId: schedule.id,
103
109
  slotId: slot.slotId,
104
110
  status: outcomeStatus,
111
+ ...(slot.recoveryRequestId
112
+ ? {
113
+ recovery: {
114
+ mode: slot.recoveryMode ?? 'normal',
115
+ requestId: slot.recoveryRequestId,
116
+ },
117
+ }
118
+ : {}),
105
119
  targets: rows.map((r) => ({
106
120
  targetId: r.targetId,
107
121
  workType: r.workType,
108
122
  status: r.status,
109
123
  workId: r.workId,
124
+ error: r.error,
125
+ terminal_reason_code: r.terminal_reason_code ?? null,
126
+ reason: r.reason ?? null,
110
127
  })),
111
128
  });
112
129
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.20.5",
4
+ "version": "2.21.0",
5
5
  "private": true
6
6
  }
@@ -34,7 +34,9 @@ export declare const RECOVERY_INTERVAL_MS: number;
34
34
  /**
35
35
  * Hosts many cron plans in one process. A validated config snapshot replaces
36
36
  * the complete cron table at once; invalid updates leave the previous table
37
- * running. All jobs share one bounded serial admission queue by default.
37
+ * running. All jobs that consume the same constrained resource (today: the
38
+ * shared Pixiv account) pass through ONE bounded resource admission; work for
39
+ * different resource identities runs concurrently.
38
40
  */
39
41
  export declare class MultiScheduleManager {
40
42
  private readonly options;
@@ -46,6 +48,15 @@ export declare class MultiScheduleManager {
46
48
  private watching;
47
49
  private readonly admission;
48
50
  constructor(options: MultiScheduleManagerOptions);
51
+ /**
52
+ * Deduplicated, log-safe observable: how many work items are parked waiting
53
+ * for any resource. Used by the idle lifecycle — waiting work is unfinished
54
+ * work and must prevent premature shutdown (§waiting-is-not-idle).
55
+ */
56
+ waitingWorkCount(): number;
57
+ /** The resource key a plan's work consumes (resource identity, never a bot/
58
+ * schedule/target name). All Pixiv-consuming work shares the account id. */
59
+ private resourceKeyForPlan;
49
60
  start(initialConfig?: StandaloneConfig): ConfigReloadResult;
50
61
  private startRecoveryLoop;
51
62
  /**
@@ -9,6 +9,7 @@ const node_fs_1 = require("node:fs");
9
9
  const cron_parser_1 = __importDefault(require("cron-parser"));
10
10
  const logger_1 = require("../logger");
11
11
  const Scheduler_1 = require("./Scheduler");
12
+ const ResourceAdmission_1 = require("./ResourceAdmission");
12
13
  const schedules_1 = require("./schedules");
13
14
  const OccurrenceResolver_1 = require("./OccurrenceResolver");
14
15
  /**
@@ -23,57 +24,26 @@ function asTriggerSource(value) {
23
24
  ? value
24
25
  : 'catchup';
25
26
  }
26
- class SerialJobAdmission {
27
- queueLimit;
28
- active = false;
29
- pendingIds = new Set();
30
- queue = [];
31
- constructor(queueLimit) {
32
- this.queueLimit = queueLimit;
33
- }
34
- setQueueLimit(queueLimit) {
35
- this.queueLimit = Math.max(0, queueLimit);
36
- }
37
- acquire(scheduleId) {
38
- if (!this.active) {
39
- this.active = true;
40
- return Promise.resolve(this.createLease());
41
- }
42
- // A slow task may span multiple cron ticks. Retain at most one pending run
43
- // for each plan so a temporary outage cannot create an unbounded backlog.
44
- if (this.pendingIds.has(scheduleId) || this.queue.length >= this.queueLimit) {
45
- return Promise.resolve(null);
46
- }
47
- this.pendingIds.add(scheduleId);
48
- return new Promise((resolve) => {
49
- this.queue.push({ scheduleId, resolve });
50
- });
51
- }
52
- createLease() {
53
- let released = false;
54
- return {
55
- release: () => {
56
- if (released)
57
- return;
58
- released = true;
59
- this.releaseNext();
60
- },
61
- };
62
- }
63
- releaseNext() {
64
- const next = this.queue.shift();
65
- if (!next) {
66
- this.active = false;
67
- return;
68
- }
69
- this.pendingIds.delete(next.scheduleId);
70
- next.resolve(this.createLease());
71
- }
27
+ /**
28
+ * Capacity resolver for one resource key, from the configuration's
29
+ * resource-governance block (§resource-governance). Unknown resources default
30
+ * to capacity 1. The key encodes the resource identity (`pixiv-account:<id>`),
31
+ * so two different accounts get two independent capacities.
32
+ */
33
+ function capacityOfResourceKey(config, key) {
34
+ const prefix = 'pixiv-account:';
35
+ if (!key.startsWith(prefix))
36
+ return 1;
37
+ const accountId = key.slice(prefix.length);
38
+ const profile = config?.schedulerRuntime?.resourceGovernance?.pixivAccounts?.[accountId];
39
+ return Math.max(1, profile?.maxConcurrency ?? 1);
72
40
  }
73
41
  /**
74
42
  * Hosts many cron plans in one process. A validated config snapshot replaces
75
43
  * the complete cron table at once; invalid updates leave the previous table
76
- * running. All jobs share one bounded serial admission queue by default.
44
+ * running. All jobs that consume the same constrained resource (today: the
45
+ * shared Pixiv account) pass through ONE bounded resource admission; work for
46
+ * different resource identities runs concurrently.
77
47
  */
78
48
  class MultiScheduleManager {
79
49
  options;
@@ -83,9 +53,25 @@ class MultiScheduleManager {
83
53
  reloadTimer = null;
84
54
  recoveryTimer = null;
85
55
  watching = false;
86
- admission = new SerialJobAdmission(8);
56
+ admission;
87
57
  constructor(options) {
88
58
  this.options = options;
59
+ // Capacity is read lazily from the current config snapshot so a hot
60
+ // reload that changes `resourceGovernance` applies immediately.
61
+ this.admission = new ResourceAdmission_1.ResourceAdmission((key) => capacityOfResourceKey(this.activeConfig, key), 8);
62
+ }
63
+ /**
64
+ * Deduplicated, log-safe observable: how many work items are parked waiting
65
+ * for any resource. Used by the idle lifecycle — waiting work is unfinished
66
+ * work and must prevent premature shutdown (§waiting-is-not-idle).
67
+ */
68
+ waitingWorkCount() {
69
+ return this.admission.waitingTotal();
70
+ }
71
+ /** The resource key a plan's work consumes (resource identity, never a bot/
72
+ * schedule/target name). All Pixiv-consuming work shares the account id. */
73
+ resourceKeyForPlan() {
74
+ return (0, ResourceAdmission_1.pixivAccountResourceKey)(this.activeConfig?.pixiv?.accountId);
89
75
  }
90
76
  start(initialConfig) {
91
77
  const config = initialConfig ?? this.options.loadConfig();
@@ -180,6 +166,12 @@ class MultiScheduleManager {
180
166
  slotDate: slot.slotDate || slot.occurrenceDate,
181
167
  manualRequestId: slot.manualRequestId ?? undefined,
182
168
  correlationId: slot.correlationId ?? undefined,
169
+ // A crashed manual recovery resumes with the SAME occurrence-scoped
170
+ // policy, never a re-resolution that would lose the override.
171
+ recoveryRequestId: slot.recoveryRequestId ?? undefined,
172
+ recoveryMode: slot.recoveryMode === 'relaxed' || slot.recoveryMode === 'normal'
173
+ ? slot.recoveryMode
174
+ : undefined,
183
175
  };
184
176
  const admitted = this.triggerSchedule(slot.scheduleId, {
185
177
  triggerSource: context.triggerSource,
@@ -284,12 +276,15 @@ class MultiScheduleManager {
284
276
  this.activeConfig = config;
285
277
  this.generation++;
286
278
  const generation = this.generation;
287
- this.admission.setQueueLimit(queueLimit);
279
+ // Bounded wait queue across ALL resources (mirrors the legacy queueLimit
280
+ // guard against an unbounded backlog); capacity itself is per resource key.
281
+ this.admission.setMaxWaiting(queueLimit);
282
+ const resourceKey = this.resourceKeyForPlan();
288
283
  for (const plan of enabledPlans) {
289
284
  // Watchdog: schedules without an explicit timeout still get a cap so a
290
285
  // wedged run cannot hold the shared admission queue forever.
291
286
  const schedulerConfig = plan.timeout !== undefined ? plan : { ...plan, timeout: Scheduler_1.DEFAULT_SCHEDULE_TIMEOUT_MS };
292
- const scheduler = new Scheduler_1.Scheduler(schedulerConfig, this.options.database, this.options.telemetry, plan.id, this.admission, (failure) => this.options.onFailure?.(config, plan, failure), (abandoned) => this.options.onAbandoned?.(config, plan, abandoned));
287
+ const scheduler = new Scheduler_1.Scheduler(schedulerConfig, this.options.database, this.options.telemetry, plan.id, this.admission, (failure) => this.options.onFailure?.(config, plan, failure), (abandoned) => this.options.onAbandoned?.(config, plan, abandoned), resourceKey);
293
288
  scheduler[registerCron ? 'start' : 'init'](async (options) => {
294
289
  // The closure keeps the exact validated snapshot for an in-flight run.
295
290
  // Cron fires pass no options (runJob resolves the occurrence for now);
@@ -308,6 +303,7 @@ class MultiScheduleManager {
308
303
  targets: plan.targetIds?.length ?? 'all',
309
304
  })),
310
305
  queueLimit,
306
+ resourceKey,
311
307
  });
312
308
  return { ok: true, generation, schedules: scheduleIds };
313
309
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Execution / acquisition policy preset (§recovery-policy).
3
+ *
4
+ * Operators do not tune `lookbackDays` / `candidateScanLimit` / language
5
+ * windows per attempt. The server defines a small number of named presets —
6
+ * `normal` and `relaxed` — and a recovery run reads the "effective policy"
7
+ * derived from the base config plus the occurrence-scoped override.
8
+ *
9
+ * Hard business constraints are NEVER relaxed by an ordinary recovery policy:
10
+ * security rules, explicit bans, wrong media type, data-integrity requirements,
11
+ * permission constraints, the selected topic/work-type boundary, delivery
12
+ * wiring and already-successfully-submitted exact duplicates are untouched.
13
+ * Only SOFT criteria (search range, candidate scan count, language-candidate
14
+ * window) may widen.
15
+ *
16
+ * The override is occurrence-scoped: `applyAcquisitionPolicy` is a pure
17
+ * function over a target snapshot. Nothing here ever writes the global config,
18
+ * so a manual relaxed recovery can never change future scheduled runs.
19
+ */
20
+ import { TargetConfig } from '../config';
21
+ export type RecoveryMode = 'normal' | 'relaxed';
22
+ /** The soft levers an acquisition policy may adjust. */
23
+ export interface AcquisitionPolicy {
24
+ /** Multiplier over the effective candidate scan limit (capped at 100). */
25
+ scanLimitMultiplier: number;
26
+ /** Multiplier over `noMatchPolicy.lookbackDays` (capped at the config max). */
27
+ lookbackDaysMultiplier: number;
28
+ /** Multiplier over the novel `languageCandidateLimit` search window. */
29
+ languageCandidateLimitMultiplier: number;
30
+ }
31
+ /**
32
+ * Server-defined presets. `normal` is the identity policy (the base config);
33
+ * `relaxed` widens only the soft criteria above.
34
+ */
35
+ export declare const ACQUISITION_POLICIES: Record<RecoveryMode, AcquisitionPolicy>;
36
+ /** Cap on the relaxed scan limit, mirroring the schedule fallback cap. */
37
+ export declare const RELAXED_SCAN_LIMIT_CAP = 100;
38
+ /** Cap on lookback days widened by a policy (matches the config validation cap). */
39
+ export declare const RELAXED_LOOKBACK_DAYS_CAP = 7;
40
+ /**
41
+ * Apply an acquisition policy to ONE target snapshot (pure; never mutates the
42
+ * input or global config). `normal` returns the target unchanged.
43
+ */
44
+ export declare function applyAcquisitionPolicy(target: TargetConfig, mode: RecoveryMode): TargetConfig;
45
+ //# sourceMappingURL=RecoveryPolicy.d.ts.map
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RELAXED_LOOKBACK_DAYS_CAP = exports.RELAXED_SCAN_LIMIT_CAP = exports.ACQUISITION_POLICIES = void 0;
4
+ exports.applyAcquisitionPolicy = applyAcquisitionPolicy;
5
+ /**
6
+ * Server-defined presets. `normal` is the identity policy (the base config);
7
+ * `relaxed` widens only the soft criteria above.
8
+ */
9
+ exports.ACQUISITION_POLICIES = {
10
+ normal: {
11
+ scanLimitMultiplier: 1,
12
+ lookbackDaysMultiplier: 1,
13
+ languageCandidateLimitMultiplier: 1,
14
+ },
15
+ relaxed: {
16
+ scanLimitMultiplier: 3,
17
+ lookbackDaysMultiplier: 3,
18
+ languageCandidateLimitMultiplier: 2,
19
+ },
20
+ };
21
+ /** Cap on the relaxed scan limit, mirroring the schedule fallback cap. */
22
+ exports.RELAXED_SCAN_LIMIT_CAP = 100;
23
+ /** Cap on lookback days widened by a policy (matches the config validation cap). */
24
+ exports.RELAXED_LOOKBACK_DAYS_CAP = 7;
25
+ /**
26
+ * Apply an acquisition policy to ONE target snapshot (pure; never mutates the
27
+ * input or global config). `normal` returns the target unchanged.
28
+ */
29
+ function applyAcquisitionPolicy(target, mode) {
30
+ if (mode === 'normal')
31
+ return target;
32
+ const policy = exports.ACQUISITION_POLICIES[mode];
33
+ const scanLimit = target.candidateScanLimit !== undefined
34
+ ? cap(MULTIPLY(target.candidateScanLimit, policy.scanLimitMultiplier), exports.RELAXED_SCAN_LIMIT_CAP)
35
+ : undefined;
36
+ const languageCandidateLimit = target.languageCandidateLimit !== undefined
37
+ ? MULTIPLY(target.languageCandidateLimit, policy.languageCandidateLimitMultiplier)
38
+ : undefined;
39
+ const lookbackDays = widenLookback(target.noMatchPolicy?.lookbackDays, policy.lookbackDaysMultiplier);
40
+ if (scanLimit === undefined && languageCandidateLimit === undefined && lookbackDays === undefined) {
41
+ return target;
42
+ }
43
+ return {
44
+ ...target,
45
+ ...(scanLimit !== undefined ? { candidateScanLimit: scanLimit } : {}),
46
+ ...(languageCandidateLimit !== undefined ? { languageCandidateLimit } : {}),
47
+ ...(lookbackDays !== undefined
48
+ ? { noMatchPolicy: { ...(target.noMatchPolicy ?? {}), lookbackDays } }
49
+ : {}),
50
+ };
51
+ }
52
+ function MULTIPLY(value, multiplier) {
53
+ return Math.max(1, Math.floor(value * multiplier));
54
+ }
55
+ function cap(value, maximum) {
56
+ return Math.min(value, maximum);
57
+ }
58
+ function widenLookback(value, multiplier) {
59
+ if (value === undefined)
60
+ return undefined;
61
+ return Math.min(MULTIPLY(value, multiplier), exports.RELAXED_LOOKBACK_DAYS_CAP);
62
+ }
63
+ //# sourceMappingURL=RecoveryPolicy.js.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Resource-scoped execution admission (§resource-governance).
3
+ *
4
+ * Concurrency is governed by the constrained RESOURCE a work item consumes
5
+ * (today: a Pixiv account), never by bot, schedule or target. Every work item
6
+ * that touches the scarce upstream — scheduled acquisition, fallback passes,
7
+ * manual refetch, manual normal/relaxed recovery — declares the resource key
8
+ * it needs and passes through this same bounded-capacity admission.
9
+ *
10
+ * Design constraints honoured here:
11
+ *
12
+ * - Resource identity is a stable internal profile key (e.g.
13
+ * `pixiv-account:<accountId>`). Tokens/cookies/session values are never a
14
+ * resource key and never appear in logs.
15
+ * - Capacity is config-driven per resource identity; the scheduler code does
16
+ * not hardcode `1`.
17
+ * - Waiting is FIFO (arrival order) per resource key, so no producer is
18
+ * starved. Capacity is checked at the moment a lease is handed out, so a
19
+ * single process never exceeds `capacity` active leases for one key.
20
+ * - Different resource identities never block each other: account A and
21
+ * account B run concurrently under independent capacities (no global lock).
22
+ * - Waiting is NOT a failure: `acquire` resolves with `null` only when the
23
+ * bounded wait queue itself is full (the caller then leaves the work in its
24
+ * durable ledger for the recovery sweep). A queued work item is unfinished
25
+ * work — it must keep the idle-detector from shutting the worker down
26
+ * (see `waitingTotal` on the idle snapshot).
27
+ *
28
+ * Implementation level matches the production topology: one PixivFlow process
29
+ * owns the whole ledger on one machine, so an in-process FIFO semaphore is the
30
+ * correct mechanism. A durable/distributed lease would only be needed if the
31
+ * same resource could be consumed by multiple processes at once.
32
+ */
33
+ export interface ResourceLease {
34
+ release(): void;
35
+ }
36
+ /**
37
+ * FIFO per-resource admission with config-driven capacity.
38
+ *
39
+ * `capacityOf(resourceKey)` returns the configured capacity for that key
40
+ * (default 1). `maxWaiting` bounds the TOTAL number of parked work items
41
+ * across all resources, mirroring the legacy `schedulerRuntime.queueLimit`
42
+ * guard against an unbounded backlog.
43
+ */
44
+ export declare class ResourceAdmission {
45
+ private readonly capacityOf;
46
+ private maxWaiting;
47
+ private readonly active;
48
+ private readonly waiting;
49
+ constructor(capacityOf: (resourceKey: string) => number, maxWaiting?: number);
50
+ setMaxWaiting(maxWaiting: number): void;
51
+ /**
52
+ * Acquire a lease on `resourceKey`. Resolves immediately when capacity is
53
+ * available, parks the caller in a FIFO per-key queue otherwise, and resolves
54
+ * `null` (never rejects) when the bounded wait queue is full.
55
+ */
56
+ acquire(resourceKey: string): Promise<ResourceLease | null>;
57
+ private createLease;
58
+ private releaseNext;
59
+ /** Active leases for one resource key. */
60
+ activeCount(resourceKey: string): number;
61
+ /** Work items parked waiting for one resource key. */
62
+ waitingCount(resourceKey: string): number;
63
+ /** Work items parked waiting for ANY resource. Unfinished work, not failure. */
64
+ waitingTotal(): number;
65
+ }
66
+ /** The canonical resource identity for the process's Pixiv credential profile. */
67
+ export declare function pixivAccountResourceKey(accountId: string | undefined): string;
68
+ //# sourceMappingURL=ResourceAdmission.d.ts.map