pixivflow 3.4.0 → 3.4.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.
@@ -12,6 +12,7 @@ const ScheduleTriggerServer_1 = require("../scheduler/ScheduleTriggerServer");
12
12
  const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
13
13
  const ManualJobAdmission_1 = require("../scheduler/ManualJobAdmission");
14
14
  const ManualJobService_1 = require("../scheduler/ManualJobService");
15
+ const JobCancellation_1 = require("../scheduler/JobCancellation");
15
16
  const ManualRefetchAdapter_1 = require("../scheduler/ManualRefetchAdapter");
16
17
  const schedules_1 = require("../scheduler/schedules");
17
18
  const targetRoutes_1 = require("../delivery/targetRoutes");
@@ -100,6 +101,13 @@ class SchedulerCommand extends Command_1.BaseCommand {
100
101
  database: runtime.database,
101
102
  config: resolveConfig,
102
103
  admission,
104
+ // A consumer cancel must bite the WORK, not only the ledger: the ledger
105
+ // write already happened inside `cancelJob`, so this only stops the
106
+ // in-flight download loop (and the `Scheduler.running` it holds, which
107
+ // otherwise refuses every later admission for the same schedule).
108
+ onCancel: (slotId) => {
109
+ runtime.cancelSlot(slotId, JobCancellation_1.CANCELLED_BY_CONSUMER_MESSAGE);
110
+ },
103
111
  });
104
112
  // Durable dispatch. The trigger endpoint records the occurrence FIRST,
105
113
  // then hands it to the shared scheduler and answers immediately. It must
@@ -62,8 +62,14 @@ export declare function withDeliveryMode<T extends {
62
62
  * - `shutdown`: the process is going away. The Slot is deliberately left
63
63
  * non-terminal so recovery resumes the same occurrence after restart; no
64
64
  * worker survives to duplicate it.
65
+ * - `consumer`: an operator/consumer cancelled THIS job through the protocol
66
+ * `POST /jobs/{job_id}/cancel`. The ledger already took the Slot terminal in
67
+ * one transaction (`cancelConsumerJob`, reason `cancelled_by_consumer`), so
68
+ * the abort path must not roll it up a second time — doing so would overwrite
69
+ * the cancel verdict with a generic failure and flip the protocol status from
70
+ * `cancelled` back to `failed`.
65
71
  */
66
- export type CancelOrigin = 'timeout' | 'shutdown';
72
+ export type CancelOrigin = 'timeout' | 'shutdown' | 'consumer';
67
73
  /**
68
74
  * Decide a scheduled run's Slot fate when `runAllTargets()` aborts abnormally
69
75
  * instead of reporting per-target failures.
@@ -90,6 +96,21 @@ export interface SchedulerRuntime {
90
96
  * (`shutdown`: leave the Slot non-terminal so recovery resumes it).
91
97
  */
92
98
  cancelActive(reason: string, origin?: CancelOrigin): void;
99
+ /**
100
+ * Cancel the in-flight run **only if it is the one owning `slotId`**.
101
+ *
102
+ * This is what makes a protocol consumer cancel bite: `cancelConsumerJob`
103
+ * terminalises the ledger in one transaction, but the work itself is an
104
+ * in-process download loop that knows nothing about the ledger. Without this
105
+ * call the cancelled job keeps consuming until it finishes on its own, and the
106
+ * holding `Scheduler.running` keeps refusing every later admission for that
107
+ * schedule (`scheduler_busy`) for the whole remaining run.
108
+ *
109
+ * Returns true when this call cancelled that Slot's live run. A slot another
110
+ * run owns (or a job that is only queued) is untouched — its ledger terminal is
111
+ * all there is to do.
112
+ */
113
+ cancelSlot(slotId: string, reason: string): boolean;
93
114
  /**
94
115
  * The active run never settled after its timeout and drain window. Stop
95
116
  * renewing its lease and take its Slot terminal so recovery cannot re-dispatch
@@ -98,6 +98,11 @@ function shouldTerminaliseAbortedSlot(origin, slotAbandoned) {
98
98
  // only overwrite it when the wedged job finally settles.
99
99
  if (slotAbandoned)
100
100
  return false;
101
+ // A consumer cancel is already terminal in the ledger (`cancelled_by_consumer`,
102
+ // written by cancelConsumerJob in one transaction). Finishing it again would
103
+ // replace that verdict with a generic failure.
104
+ if (origin === 'consumer')
105
+ return false;
101
106
  // Shutdown is not a failure: recovery is meant to resume this occurrence.
102
107
  return origin !== 'shutdown';
103
108
  }
@@ -291,6 +296,12 @@ async function createSchedulerRuntime(configPathArg) {
291
296
  * `null` while no cancellation has been requested for the current run.
292
297
  */
293
298
  let activeAbortOrigin = null;
299
+ /**
300
+ * The Slot the in-flight run owns, if any. Set where the lease is claimed and
301
+ * cleared where it is released, so `cancelSlot()` can tell "this job is the one
302
+ * running right now" from "this job is merely queued behind another run".
303
+ */
304
+ let activeSlotId = null;
294
305
  // Independently-pumped durable outbox (content + notifications). Started in
295
306
  // the long-running scheduler daemon; run-once drains explicitly before exit.
296
307
  const deliveryDispatcher = new DeliveryDispatcher_1.DeliveryDispatcher(config.delivery, buildProxyUrl(config.network));
@@ -452,6 +463,7 @@ async function createSchedulerRuntime(configPathArg) {
452
463
  // This run owns the Slot now: any cancellation recorded against a previous
453
464
  // run must not decide how THIS run's abort is handled.
454
465
  activeAbortOrigin = null;
466
+ activeSlotId = activeSlot.slotId;
455
467
  let cancelled = false;
456
468
  const heartbeat = setInterval(() => {
457
469
  // A cancelled/timed-out run must stop renewing its lease. An infinitely
@@ -468,6 +480,7 @@ async function createSchedulerRuntime(configPathArg) {
468
480
  varReleaseLease = () => {
469
481
  clearInterval(heartbeat);
470
482
  activeLeaseHooks = null;
483
+ activeSlotId = null;
471
484
  coordinator.releaseRunLease(activeSlot.slotId, runOwner);
472
485
  };
473
486
  activeLeaseHooks = {
@@ -497,6 +510,7 @@ async function createSchedulerRuntime(configPathArg) {
497
510
  database.slots.markSlotStatus(activeSlot.slotId, 'failed', `abandoned after scheduler timeout; no delivery (${reason})`);
498
511
  coordinator.releaseRunLease(activeSlot.slotId, runOwner);
499
512
  activeLeaseHooks = null;
513
+ activeSlotId = null;
500
514
  },
501
515
  };
502
516
  }
@@ -778,6 +792,15 @@ async function createSchedulerRuntime(configPathArg) {
778
792
  // or left recoverable is decided by `origin` in the abort path of runJob.
779
793
  activeLeaseHooks?.stopHeartbeat();
780
794
  };
795
+ const cancelSlot = (slotId, reason) => {
796
+ // A cancellation is only meaningful for the run that owns THIS Slot. Every
797
+ // other case (queued behind another run, or already terminal) has nothing to
798
+ // abort: the durable ledger write is the whole effect.
799
+ if (!activeSlotId || activeSlotId !== slotId)
800
+ return false;
801
+ cancelActive(reason, 'consumer');
802
+ return true;
803
+ };
781
804
  const abandonActiveRun = (reason) => {
782
805
  // Cancellation did not take effect inside the drain window — a request that
783
806
  // ignores the abort. This run will never settle, so finish the lease
@@ -800,6 +823,7 @@ async function createSchedulerRuntime(configPathArg) {
800
823
  tokenMaintenance,
801
824
  runJob,
802
825
  cancelActive,
826
+ cancelSlot,
803
827
  abandonActiveRun,
804
828
  activeExecutionCount: () => activeExecutions,
805
829
  startOutboxWorker: () => outboxWorker.start(),
@@ -47,6 +47,13 @@ export interface RefetchOutcomePayload {
47
47
  requestId: string;
48
48
  /** 'no_alternative' | 'failed' (replacement success rides the submission). */
49
49
  disposition: 'no_alternative' | 'failed';
50
+ /**
51
+ * The cross-boundary, closed-vocabulary protocol error code (protocol/v1
52
+ * `$defs/Error.code`). The consumer writes it into its `failure_code` CODE
53
+ * column, so raw upstream text must never appear here.
54
+ */
55
+ reasonCode?: string;
56
+ /** Bounded, single-line, human-readable business message (never a raw body). */
50
57
  reason?: string;
51
58
  workId?: string;
52
59
  /** Bounded candidate-scan bookkeeping for diagnostics (spec-compatible). */
@@ -173,6 +173,11 @@ class HttpMultipartDelivery {
173
173
  ? {
174
174
  request_id: outcome.requestId,
175
175
  disposition: outcome.disposition,
176
+ // The closed-vocabulary code goes to TelePost's `failure_code` CODE
177
+ // column; `reason` is only the bounded human business message it
178
+ // sanitizes into `terminal_reason`. Raw upstream text (an nginx 502
179
+ // HTML page) must never appear on the wire.
180
+ reason_code: outcome.reasonCode,
176
181
  reason: outcome.reason,
177
182
  work_id: outcome.workId,
178
183
  scanned: outcome.scanned,
@@ -154,6 +154,12 @@ export interface DeliveryNotificationRequest {
154
154
  refetchOutcome?: {
155
155
  requestId: string;
156
156
  disposition: 'no_alternative' | 'failed';
157
+ /**
158
+ * The cross-boundary, closed-vocabulary protocol error code (protocol/v1
159
+ * `$defs/Error.code`); raw upstream text never crosses the boundary.
160
+ */
161
+ reasonCode?: string;
162
+ /** Bounded, single-line, human-readable business message (never a raw body). */
157
163
  reason?: string;
158
164
  workId?: string;
159
165
  scanned?: number;
@@ -239,12 +239,22 @@ class NotificationPolicy {
239
239
  });
240
240
  return;
241
241
  }
242
+ // Translate the internal outcome ONCE, through the existing classifier:
243
+ // the wire carries the closed-vocabulary protocol code plus the short
244
+ // Chinese business message. The raw technical text (`outcome.error` /
245
+ // `outcome.reason`) never crosses the boundary — a live acceptance run
246
+ // shipped a 220-char multi-line nginx 502 HTML page into TelePost's
247
+ // `failure_code` CODE column that way.
248
+ const normalized = (0, TargetOutcome_1.terminalReasonFor)(outcome);
249
+ const reasonCode = (0, TargetOutcome_1.protocolErrorCodeForTerminalReason)(normalized?.code) ?? undefined;
250
+ const reason = (normalized?.message ?? '').trim() || undefined;
242
251
  let payload;
243
252
  if (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') {
244
253
  payload = {
245
254
  requestId,
246
255
  disposition: 'no_alternative',
247
- reason: outcome.reason,
256
+ reasonCode,
257
+ reason,
248
258
  workId: outcome.kind === 'duplicate' ? outcome.workId : undefined,
249
259
  ...scanCounts(outcome.scan),
250
260
  };
@@ -253,7 +263,8 @@ class NotificationPolicy {
253
263
  payload = {
254
264
  requestId,
255
265
  disposition: 'failed',
256
- reason: outcome.error,
266
+ reasonCode,
267
+ reason,
257
268
  ...scanCounts(outcome.scan),
258
269
  };
259
270
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "3.4.0",
4
+ "version": "3.4.1",
5
5
  "private": true
6
6
  }
@@ -25,6 +25,16 @@ export interface ManualJobServiceDeps {
25
25
  config(): StandaloneConfig;
26
26
  /** The shared admission path — the same instance the legacy shim uses. */
27
27
  admission: ManualJobAdmission;
28
+ /**
29
+ * Ask the runtime to abort the in-flight run when the cancelled job is the one
30
+ * executing right now.
31
+ *
32
+ * The ledger write is the source of truth and happens first; this only stops
33
+ * the in-process download loop from running to completion after it has been
34
+ * cancelled. A job that is merely queued behind another run has no live run to
35
+ * abort, and the hook is then never called.
36
+ */
37
+ onCancel?(slotId: string): void;
28
38
  /** Injected clock, for tests. */
29
39
  now?(): number;
30
40
  }
@@ -78,10 +78,15 @@ class ManualJobService {
78
78
  * that terminalises the work and stops further deliveries.
79
79
  */
80
80
  cancelJob(jobId) {
81
- (0, JobCancellation_1.cancelConsumerJob)(this.deps.database, jobId, this.now());
81
+ const result = (0, JobCancellation_1.cancelConsumerJob)(this.deps.database, jobId, this.now());
82
82
  // The cancellation is now durable, so its terminal event must be too — a
83
83
  // consumer that polls after cancelling must never see an unterminated stream.
84
84
  this.events.reconcile(jobId);
85
+ // Only a cancel that actually transitioned the ledger has a live run worth
86
+ // aborting. The ledger write came first, so the abort path cannot resurrect
87
+ // the job; it only makes the running download loop stop consuming.
88
+ if (result.cancelled)
89
+ this.deps.onCancel?.(jobId);
85
90
  return this.viewBySlotId(jobId);
86
91
  }
87
92
  viewBySlotId(slotId) {
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: '3.4.0', commit: '5d231179a9b3' };
5
+ exports.BUILD = { version: '3.4.1', commit: 'd2e9c9e338bd' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "3.4.0",
4
+ "version": "3.4.1",
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": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
5
5
  "repository": {
6
6
  "type": "git",