pixivflow 3.4.1 → 3.4.3

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.
@@ -945,10 +945,19 @@ export interface StandaloneConfig {
945
945
  * classified. 'skip' (default) is safe mode: never ship an unclassifiable
946
946
  * cover, and log `coverType=unknown` so a future Pixiv format change is
947
947
  * visible instead of silently leaking designs again. 'keep' prefers
948
- * availability over certainty. A failed probe always keeps the cover.
948
+ * availability over certainty.
949
+ *
950
+ * `probeFailed` governs the separate case where the cover probe itself
951
+ * failed (network / auth / rate limit) and the bytes were never seen.
952
+ * 'skip' (default) is safe mode for the same reason: most novel covers ARE
953
+ * generated designs, so keeping an unseen cover re-ships what the 640x900
954
+ * classifier exists to suppress; the cost is one illustration when a real
955
+ * cover is transiently unreachable. 'keep' restores availability-first.
956
+ * Either way the `coverType=probe_failed` warning stays visible.
949
957
  */
950
958
  novelCover?: {
951
959
  unknown?: 'skip' | 'keep';
960
+ probeFailed?: 'skip' | 'keep';
952
961
  };
953
962
  };
954
963
  }
@@ -715,6 +715,11 @@ function validateConfig(config, location, databasePath) {
715
715
  config.download.novelCover.unknown !== 'keep') {
716
716
  errors.push(`download.novelCover.unknown: Must be "skip" or "keep" (got ${String(config.download.novelCover.unknown)})`);
717
717
  }
718
+ if (config.download.novelCover?.probeFailed !== undefined &&
719
+ config.download.novelCover.probeFailed !== 'skip' &&
720
+ config.download.novelCover.probeFailed !== 'keep') {
721
+ errors.push(`download.novelCover.probeFailed: Must be "skip" or "keep" (got ${String(config.download.novelCover.probeFailed)})`);
722
+ }
718
723
  }
719
724
  // The bounded candidate scan is what stops a page full of duplicates from
720
725
  // burning a whole scheduled slot, so a non-integer value is reported here.
@@ -43,6 +43,7 @@ const node_crypto_1 = require("node:crypto");
43
43
  const node_stream_1 = require("node:stream");
44
44
  const logger_1 = require("../logger");
45
45
  const DeliveryAck_1 = require("./DeliveryAck");
46
+ const refetchProvenance_1 = require("./refetchProvenance");
46
47
  const redact_1 = require("../utils/redact");
47
48
  /** Render an ISO timestamp to YYYY-MM-DD (create_date is JST). */
48
49
  function formatPublishedDate(iso) {
@@ -476,7 +477,10 @@ function buildTemplateVariables(request) {
476
477
  // Remote manual replacement ("重抓") request UUID; empty for scheduled runs.
477
478
  // Carried through the delivery payload context (extraContext) so the
478
479
  // receiving service can correlate the review with its refetch attempt.
479
- refetchRequestId: c.refetchRequestId ?? '',
480
+ // Canonicalized at this last boundary before the wire: the receiver accepts
481
+ // only a canonical dashed lowercase UUID and rejects the whole submission
482
+ // otherwise (see delivery/refetchProvenance.ts).
483
+ refetchRequestId: (0, refetchProvenance_1.canonicalRefetchRequestId)(c.refetchRequestId),
480
484
  };
481
485
  }
482
486
  /**
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The `refetch_request_id` wire contract.
3
+ *
4
+ * The receiving service (TelePost) validates this field as a *canonical* dashed
5
+ * lowercase UUID — `utils/api_server.py:706-715` answers HTTP 400
6
+ * `invalid_refetch_provenance` ("refetch_request_id 必须是 UUID") for anything
7
+ * else — and it rejects the whole submission, permanently, before any review
8
+ * exists. It is strict on purpose: the stored value must later match the
9
+ * refetch attempt's own `request_id`, which TelePost generates as
10
+ * `str(uuid.uuid4())`.
11
+ *
12
+ * Callers hand us their key in whatever spelling they generated it. Production
13
+ * incident 2026-09-27: a manual refetch arrived at the delivery with the bare
14
+ * 32-hex key `1e22b55cb33e47289f30c62e8ee1e11f` — a v4 UUID whose dashes were
15
+ * stripped — and the run ended in
16
+ * `permanent delivery failure: delivery endpoint HTTP 400:
17
+ * {"code":"invalid_refetch_provenance","message":"refetch_request_id 必须是 UUID"}`.
18
+ *
19
+ * So the boundary canonicalizes instead of forwarding the raw key:
20
+ *
21
+ * - a UUID in any spelling we can recognize (dashed, undashed, UPPER, braces,
22
+ * `urn:uuid:` prefix) becomes the canonical dashed lowercase form, which
23
+ * keeps the receiving service's identity match intact;
24
+ * - a value that is not a UUID at all becomes `''` — an unusable provenance
25
+ * must never fail an otherwise valid delivery, and the empty value is
26
+ * already the documented shape of a scheduled (non-refetch) delivery.
27
+ *
28
+ * Identity is never touched here: the consumer's own idempotency key keeps its
29
+ * exact spelling as the durable `manual_request_id` (slot identity), and only
30
+ * the payload field that crosses the wire is normalized.
31
+ */
32
+ /**
33
+ * Canonical dashed lowercase UUID for `value`, or `''` when `value` is not a
34
+ * UUID. Mirrors Python's `uuid.UUID()` leniency exactly: only hyphens may be
35
+ * removed, and what remains must be 32 hex digits.
36
+ */
37
+ export declare function canonicalRefetchRequestId(value: unknown): string;
38
+ //# sourceMappingURL=refetchProvenance.d.ts.map
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /**
3
+ * The `refetch_request_id` wire contract.
4
+ *
5
+ * The receiving service (TelePost) validates this field as a *canonical* dashed
6
+ * lowercase UUID — `utils/api_server.py:706-715` answers HTTP 400
7
+ * `invalid_refetch_provenance` ("refetch_request_id 必须是 UUID") for anything
8
+ * else — and it rejects the whole submission, permanently, before any review
9
+ * exists. It is strict on purpose: the stored value must later match the
10
+ * refetch attempt's own `request_id`, which TelePost generates as
11
+ * `str(uuid.uuid4())`.
12
+ *
13
+ * Callers hand us their key in whatever spelling they generated it. Production
14
+ * incident 2026-09-27: a manual refetch arrived at the delivery with the bare
15
+ * 32-hex key `1e22b55cb33e47289f30c62e8ee1e11f` — a v4 UUID whose dashes were
16
+ * stripped — and the run ended in
17
+ * `permanent delivery failure: delivery endpoint HTTP 400:
18
+ * {"code":"invalid_refetch_provenance","message":"refetch_request_id 必须是 UUID"}`.
19
+ *
20
+ * So the boundary canonicalizes instead of forwarding the raw key:
21
+ *
22
+ * - a UUID in any spelling we can recognize (dashed, undashed, UPPER, braces,
23
+ * `urn:uuid:` prefix) becomes the canonical dashed lowercase form, which
24
+ * keeps the receiving service's identity match intact;
25
+ * - a value that is not a UUID at all becomes `''` — an unusable provenance
26
+ * must never fail an otherwise valid delivery, and the empty value is
27
+ * already the documented shape of a scheduled (non-refetch) delivery.
28
+ *
29
+ * Identity is never touched here: the consumer's own idempotency key keeps its
30
+ * exact spelling as the durable `manual_request_id` (slot identity), and only
31
+ * the payload field that crosses the wire is normalized.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.canonicalRefetchRequestId = canonicalRefetchRequestId;
35
+ /**
36
+ * Canonical dashed lowercase UUID for `value`, or `''` when `value` is not a
37
+ * UUID. Mirrors Python's `uuid.UUID()` leniency exactly: only hyphens may be
38
+ * removed, and what remains must be 32 hex digits.
39
+ */
40
+ function canonicalRefetchRequestId(value) {
41
+ if (typeof value !== 'string')
42
+ return '';
43
+ let candidate = value.trim().toLowerCase();
44
+ if (!candidate)
45
+ return '';
46
+ if (candidate.startsWith('urn:uuid:'))
47
+ candidate = candidate.slice('urn:uuid:'.length);
48
+ if (candidate.startsWith('{') && candidate.endsWith('}'))
49
+ candidate = candidate.slice(1, -1);
50
+ const hex = candidate.replace(/-/g, '');
51
+ if (!/^[0-9a-f]{32}$/.test(hex))
52
+ return '';
53
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
54
+ }
55
+ //# sourceMappingURL=refetchProvenance.js.map
@@ -15,14 +15,36 @@ export interface NovelCoverPolicy {
15
15
  * 'keep': prefer availability over certainty.
16
16
  */
17
17
  unknownCover: 'skip' | 'keep';
18
+ /**
19
+ * What to do when the cover probe itself failed (network / auth / rate
20
+ * limit), so the bytes were never fetched and no content type exists.
21
+ *
22
+ * 'skip' (default): safe mode — most novel covers ARE Pixiv's generated
23
+ * designs, so keeping an unseen probe failure re-ships exactly the covers
24
+ * the 640x900 classifier exists to suppress. The cost is one illustration
25
+ * when a real author cover happens to be transiently unreachable.
26
+ * 'keep': prefer availability over certainty — a failed probe never costs a
27
+ * cover, at the price of re-shipping generated designs whenever the CDN or
28
+ * the account is unreachable.
29
+ */
30
+ probeFailed: 'skip' | 'keep';
18
31
  }
19
- /** Production-safe default: an unclassifiable cover is never shipped. */
32
+ /**
33
+ * Production-safe default: an unclassifiable cover is never shipped, and a
34
+ * probe that never produced bytes is not treated as evidence that the cover is
35
+ * real content.
36
+ */
20
37
  export declare const DEFAULT_NOVEL_COVER_POLICY: NovelCoverPolicy;
21
38
  /**
22
39
  * Classify fetched cover bytes. Returns `unknown` when the image header cannot
23
40
  * be read (no decoder in this project, so only the header is inspected).
24
41
  */
25
42
  export declare function classifyNovelCover(cover: ArrayBuffer | Uint8Array | null | undefined): NovelCoverType;
26
- /** The delivery decision for one classified cover under a policy. */
27
- export declare function coverDeliveryDecision(policy: NovelCoverPolicy, type: NovelCoverType): 'deliver' | 'skip';
43
+ /**
44
+ * Everything `coverDeliveryDecision` can be asked about: the three results
45
+ * `classifyNovelCover` returns, plus the probe that never produced bytes.
46
+ */
47
+ export type NovelCoverOutcome = NovelCoverType | 'probe_failed';
48
+ /** The delivery decision for one cover outcome under a policy. */
49
+ export declare function coverDeliveryDecision(policy: NovelCoverPolicy, outcome: NovelCoverOutcome): 'deliver' | 'skip';
28
50
  //# sourceMappingURL=NovelCoverPolicy.d.ts.map
@@ -31,8 +31,15 @@ exports.coverDeliveryDecision = coverDeliveryDecision;
31
31
  * shipping generated covers again.
32
32
  *
33
33
  * A FAILED probe (network / auth / rate limit) is deliberately NOT `unknown`:
34
- * it is `probe_failed` and always keeps the cover, because a transient fetch
35
- * error must never cost a real one.
34
+ * it is `probe_failed` — the bytes were never seen, so no content type exists.
35
+ * It is a policy decision like `unknown` (`download.novelCover.probeFailed`),
36
+ * and the production-safe default is `skip`: the majority of novel covers ARE
37
+ * Pixiv's generated designs, so a probe failure that keeps the cover re-ships
38
+ * exactly the payloads the 640x900 classifier exists to suppress. `keep` stays
39
+ * available for an operator who prefers availability over certainty (a
40
+ * transient fetch error must never cost a real cover). Either way the loud
41
+ * `coverType=probe_failed` log line stays, so the decision is observable
42
+ * instead of silent.
36
43
  */
37
44
  const imageDimensions_1 = require("../../utils/imageDimensions");
38
45
  /**
@@ -43,8 +50,15 @@ const imageDimensions_1 = require("../../utils/imageDimensions");
43
50
  */
44
51
  exports.PIXIV_GENERATED_COVER_WIDTH = 640;
45
52
  exports.PIXIV_GENERATED_COVER_HEIGHT = 900;
46
- /** Production-safe default: an unclassifiable cover is never shipped. */
47
- exports.DEFAULT_NOVEL_COVER_POLICY = { unknownCover: 'skip' };
53
+ /**
54
+ * Production-safe default: an unclassifiable cover is never shipped, and a
55
+ * probe that never produced bytes is not treated as evidence that the cover is
56
+ * real content.
57
+ */
58
+ exports.DEFAULT_NOVEL_COVER_POLICY = {
59
+ unknownCover: 'skip',
60
+ probeFailed: 'skip',
61
+ };
48
62
  /**
49
63
  * Classify fetched cover bytes. Returns `unknown` when the image header cannot
50
64
  * be read (no decoder in this project, so only the header is inspected).
@@ -59,12 +73,14 @@ function classifyNovelCover(cover) {
59
73
  }
60
74
  return 'custom';
61
75
  }
62
- /** The delivery decision for one classified cover under a policy. */
63
- function coverDeliveryDecision(policy, type) {
64
- if (type === 'pixiv_generated')
76
+ /** The delivery decision for one cover outcome under a policy. */
77
+ function coverDeliveryDecision(policy, outcome) {
78
+ if (outcome === 'pixiv_generated')
65
79
  return 'skip';
66
- if (type === 'unknown')
80
+ if (outcome === 'unknown')
67
81
  return policy.unknownCover === 'keep' ? 'deliver' : 'skip';
82
+ if (outcome === 'probe_failed')
83
+ return policy.probeFailed === 'keep' ? 'deliver' : 'skip';
68
84
  return 'deliver';
69
85
  }
70
86
  //# sourceMappingURL=NovelCoverPolicy.js.map
@@ -132,6 +132,7 @@ class DownloadManager {
132
132
  : MaterializationPolicy_1.DEFAULT_MATERIALIZATION_POLICY;
133
133
  const novelCoverPolicy = {
134
134
  unknownCover: config.download?.novelCover?.unknown ?? NovelCoverPolicy_1.DEFAULT_NOVEL_COVER_POLICY.unknownCover,
135
+ probeFailed: config.download?.novelCover?.probeFailed ?? NovelCoverPolicy_1.DEFAULT_NOVEL_COVER_POLICY.probeFailed,
135
136
  };
136
137
  this.novelDownloader = new NovelDownloader_1.NovelDownloader(client, database, fileService, database, undefined, materialization, novelCoverPolicy);
137
138
  this.planner = new DownloadPlanner_1.DownloadPlanner(database, {
@@ -34,8 +34,11 @@ export declare class NovelDownloader {
34
34
  * - unknown → policy decision, default 'skip' (safe mode), so a future
35
35
  * Pixiv cover-format change surfaces as a loud
36
36
  * `coverType=unknown` log instead of leaking silently
37
- * A FAILED probe (network / auth / rate limit) is a separate case and always
38
- * keeps the cover: a transient fetch error must never cost a real one.
37
+ * - probe_failed → policy decision, default 'skip': the bytes were never
38
+ * seen, and most novel covers are generated designs, so
39
+ * keeping an unseen cover re-ships what this classifier
40
+ * exists to suppress. `download.novelCover.probeFailed:
41
+ * keep` restores the availability-first behaviour.
39
42
  */
40
43
  private resolveCoverUrl;
41
44
  }
@@ -418,8 +418,11 @@ class NovelDownloader {
418
418
  * - unknown → policy decision, default 'skip' (safe mode), so a future
419
419
  * Pixiv cover-format change surfaces as a loud
420
420
  * `coverType=unknown` log instead of leaking silently
421
- * A FAILED probe (network / auth / rate limit) is a separate case and always
422
- * keeps the cover: a transient fetch error must never cost a real one.
421
+ * - probe_failed → policy decision, default 'skip': the bytes were never
422
+ * seen, and most novel covers are generated designs, so
423
+ * keeping an unseen cover re-ships what this classifier
424
+ * exists to suppress. `download.novelCover.probeFailed:
425
+ * keep` restores the availability-first behaviour.
423
426
  */
424
427
  async resolveCoverUrl(novelId, coverUrl) {
425
428
  const normalized = (0, novelCover_1.normalizeNovelCoverUrl)(coverUrl);
@@ -430,13 +433,29 @@ class NovelDownloader {
430
433
  cover = await this.client.downloadImage(normalized);
431
434
  }
432
435
  catch (error) {
433
- logger_1.logger.warn(`Novel ${novelId} cover probe failed; keeping the cover (coverType=probe_failed)`, {
436
+ const reason = error instanceof Error ? error.message : String(error);
437
+ // A failed probe is a policy decision, not an automatic keep: most novel
438
+ // covers ARE Pixiv's generated designs, so silently shipping an unseen
439
+ // cover re-ships exactly what the 640x900 classifier exists to suppress.
440
+ const probeDecision = (0, NovelCoverPolicy_1.coverDeliveryDecision)(this.novelCoverPolicy, 'probe_failed');
441
+ if (probeDecision === 'deliver') {
442
+ logger_1.logger.warn(`Novel ${novelId} cover probe failed; keeping the cover per novelCover.probeFailed=keep (coverType=probe_failed)`, {
443
+ novelId,
444
+ coverUrl: normalized,
445
+ coverType: 'probe_failed',
446
+ policy: this.novelCoverPolicy.probeFailed,
447
+ reason,
448
+ });
449
+ return normalized;
450
+ }
451
+ logger_1.logger.warn(`Novel ${novelId} cover probe failed; skipping the cover per novelCover.probeFailed=skip (coverType=probe_failed)`, {
434
452
  novelId,
435
453
  coverUrl: normalized,
436
454
  coverType: 'probe_failed',
437
- reason: error instanceof Error ? error.message : String(error),
455
+ policy: this.novelCoverPolicy.probeFailed,
456
+ reason,
438
457
  });
439
- return normalized;
458
+ return null;
440
459
  }
441
460
  const coverType = (0, NovelCoverPolicy_1.classifyNovelCover)(cover);
442
461
  const decision = (0, NovelCoverPolicy_1.coverDeliveryDecision)(this.novelCoverPolicy, coverType);
@@ -10,7 +10,11 @@ import { TargetConfig } from '../../config';
10
10
  * `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
11
11
  * that produced this delivery; it is empty for scheduled occurrences. The
12
12
  * receiving service (TelePost) correlates the incoming review with the refetch
13
- * attempt on this id.
13
+ * attempt on this id, and validates it as a canonical UUID — see
14
+ * `delivery/refetchProvenance.ts` for why an unusable key becomes empty instead
15
+ * of failing the whole delivery. The consumer's idempotency key itself keeps its
16
+ * exact spelling as the durable slot identity; only this wire field is
17
+ * normalized.
14
18
  */
15
19
  export declare function deliveryContextFields(target: TargetConfig): Record<string, unknown>;
16
20
  //# sourceMappingURL=deliveryContext.d.ts.map
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.deliveryContextFields = deliveryContextFields;
4
+ const logger_1 = require("../../logger");
5
+ const refetchProvenance_1 = require("../../delivery/refetchProvenance");
4
6
  /**
5
7
  * Delivery payload context for one target run, merging schedule/slot provenance
6
8
  * with the optional remote manual replacement identity.
@@ -12,12 +14,34 @@ exports.deliveryContextFields = deliveryContextFields;
12
14
  * `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
13
15
  * that produced this delivery; it is empty for scheduled occurrences. The
14
16
  * receiving service (TelePost) correlates the incoming review with the refetch
15
- * attempt on this id.
17
+ * attempt on this id, and validates it as a canonical UUID — see
18
+ * `delivery/refetchProvenance.ts` for why an unusable key becomes empty instead
19
+ * of failing the whole delivery. The consumer's idempotency key itself keeps its
20
+ * exact spelling as the durable slot identity; only this wire field is
21
+ * normalized.
16
22
  */
17
23
  function deliveryContextFields(target) {
18
24
  const ec = target.delivery;
19
25
  const slotContext = ec?.slotContext;
20
26
  const executionContext = ec?.executionContext;
27
+ const manualRequestId = slotContext?.manualRequestId ?? '';
28
+ const refetchRequestId = (0, refetchProvenance_1.canonicalRefetchRequestId)(manualRequestId);
29
+ if (manualRequestId && !refetchRequestId) {
30
+ // Visible, not silent: the delivery still goes out (the receiving service
31
+ // treats an empty provenance as "not a refetch"), but the operator must be
32
+ // able to see that this manual run cannot be correlated remotely.
33
+ logger_1.logger.warn('Manual refetch request id is not a UUID; sending an empty refetch_request_id so the delivery is not rejected', {
34
+ slotId: slotContext?.slotId ?? '',
35
+ manualRequestIdLength: manualRequestId.length,
36
+ manualRequestIdPrefix: manualRequestId.slice(0, 8),
37
+ });
38
+ }
39
+ else if (manualRequestId && manualRequestId !== refetchRequestId) {
40
+ logger_1.logger.info('Canonicalized the manual refetch request id for the delivery payload', {
41
+ slotId: slotContext?.slotId ?? '',
42
+ canonicalRefetchRequestId: refetchRequestId,
43
+ });
44
+ }
21
45
  return {
22
46
  scheduleId: executionContext?.scheduleId ?? '',
23
47
  executionId: executionContext?.slotId ?? '',
@@ -26,7 +50,7 @@ function deliveryContextFields(target) {
26
50
  slotId: slotContext?.slotId ?? executionContext?.slotId ?? '',
27
51
  slotName: slotContext?.slotName ?? executionContext?.slotName ?? '',
28
52
  slotDate: slotContext?.slotDate ?? executionContext?.slotDate ?? '',
29
- refetchRequestId: slotContext?.manualRequestId ?? '',
53
+ refetchRequestId,
30
54
  };
31
55
  }
32
56
  //# sourceMappingURL=deliveryContext.js.map
@@ -4,6 +4,7 @@ exports.NotificationPolicy = void 0;
4
4
  const DeliveryService_1 = require("../delivery/DeliveryService");
5
5
  const targetRoutes_1 = require("../delivery/targetRoutes");
6
6
  const TargetOutcome_1 = require("../scheduler/TargetOutcome");
7
+ const ProtocolErrors_1 = require("../scheduler/ProtocolErrors");
7
8
  const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
8
9
  const logger_1 = require("../logger");
9
10
  /**
@@ -227,6 +228,20 @@ class NotificationPolicy {
227
228
  const name = this.targetName(target);
228
229
  if (!name)
229
230
  return;
231
+ // A consumer/operator cancel is NOT a refetch failure (finding D). The
232
+ // cancelled cell keeps its terminal `cancelled_by_consumer` verdict, and no
233
+ // `disposition:"failed"` outcome may ever be emitted for it — the operator
234
+ // deliberately stopped the work, and the cancellation is already surfaced
235
+ // through the job status / events channel, never as a failed refetch report.
236
+ const cancelledCell = target.id ? this.database.slots.getCell(slot.slotId, target.id) : null;
237
+ if (cancelledCell?.terminalReasonCode === ProtocolErrors_1.CANCELLED_BY_CONSUMER) {
238
+ logger_1.logger.info('Refetch outcome suppressed: job was cancelled by the consumer', {
239
+ slot: slot.slotId,
240
+ target: target.id,
241
+ requestId,
242
+ });
243
+ return;
244
+ }
230
245
  const deliveryTarget = this.config.delivery?.targets?.[name];
231
246
  if (!deliveryTarget || deliveryTarget.type !== 'httpMultipart')
232
247
  return;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "3.4.1",
4
+ "version": "3.4.3",
5
5
  "private": true
6
6
  }
@@ -245,6 +245,13 @@ class SlotCoordinator {
245
245
  const cell = this.database.slots.getCell(slotId, targetId);
246
246
  if (!cell)
247
247
  return;
248
+ // A consumer/operator cancel is FINAL (see JobCancellation.ts). The abort of
249
+ // the interrupted run still lands here through the normal per-target outcome
250
+ // path; a late `failed` must never re-verdict a cancelled cell, or the
251
+ // protocol status flips from `cancelled` back to `failed` ~10s after the
252
+ // operator's cancel (finding D). Cancel verdicts are never re-written.
253
+ if (cell.terminalReasonCode === ProtocolErrors_1.CANCELLED_BY_CONSUMER)
254
+ return;
248
255
  switch (outcome.kind) {
249
256
  case 'submitted':
250
257
  this.database.slots.lockCellWork(slotId, targetId, outcome.workId, outcome.workType);
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.1', commit: 'd2e9c9e338bd' };
5
+ exports.BUILD = { version: '3.4.3', commit: '74ffa4d29ec2' };
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.1",
4
+ "version": "3.4.3",
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.1",
3
+ "version": "3.4.3",
4
4
  "description": "🎨 Pixiv 下载、筛选与自动收集工具 - 批量下载插画和小说、按标签/热度/日期筛选、定时任务与可靠 HTTP 交付 | Pixiv downloader and automation toolkit with filtering, scheduling and reliable HTTP delivery",
5
5
  "repository": {
6
6
  "type": "git",