pixivflow 3.3.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.
- package/dist/commands/SchedulerCommand.js +8 -0
- package/dist/commands/scheduler-runtime.d.ts +22 -1
- package/dist/commands/scheduler-runtime.js +24 -0
- package/dist/delivery/DeliveryService.d.ts +7 -0
- package/dist/delivery/HttpMultipartDelivery.js +5 -0
- package/dist/delivery/types.d.ts +6 -0
- package/dist/notification/NotificationPolicy.js +13 -2
- package/dist/package.json +1 -1
- package/dist/scheduler/CandidateSearchParams.d.ts +17 -1
- package/dist/scheduler/CandidateSearchParams.js +13 -4
- package/dist/scheduler/JobFacade.d.ts +7 -0
- package/dist/scheduler/JobFacade.js +69 -21
- package/dist/scheduler/ManualJobAdmission.d.ts +12 -4
- package/dist/scheduler/ManualJobAdmission.js +17 -11
- package/dist/scheduler/ManualJobService.d.ts +10 -0
- package/dist/scheduler/ManualJobService.js +7 -1
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +1 -1
|
@@ -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,
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
266
|
+
reasonCode,
|
|
267
|
+
reason,
|
|
257
268
|
...scanCounts(outcome.scan),
|
|
258
269
|
};
|
|
259
270
|
}
|
package/dist/package.json
CHANGED
|
@@ -25,7 +25,23 @@ export interface CandidateSearchParams {
|
|
|
25
25
|
platform?: string;
|
|
26
26
|
account?: string;
|
|
27
27
|
};
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Scope selector: which of the producer's CONFIGURED targets this job runs
|
|
30
|
+
* for. It selects an existing target — it never overrides that target's
|
|
31
|
+
* delivery wiring or plan identity (see `ManualJobAdmission.resolveTarget`).
|
|
32
|
+
* It is deliberately NOT part of the stored retrieval view: the admission
|
|
33
|
+
* lifts it into `CandidateSearchJobRequest.targetSelector`, so a job that only
|
|
34
|
+
* names a target runs that target exactly as configured. This is what makes
|
|
35
|
+
* the generic `POST /jobs` face equivalent to the legacy refetch entry point,
|
|
36
|
+
* whose target used to live in the request path.
|
|
37
|
+
*/
|
|
38
|
+
target_id?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Retrieval override. Optional: omitting it (with or without `target_id`)
|
|
41
|
+
* means "run the resolved target's own configured search", which is what every
|
|
42
|
+
* pre-protocol refetch did.
|
|
43
|
+
*/
|
|
44
|
+
query?: {
|
|
29
45
|
tags: string[];
|
|
30
46
|
expand?: boolean;
|
|
31
47
|
};
|
|
@@ -42,18 +42,27 @@ function clamp(value, min, max) {
|
|
|
42
42
|
* where the target is known.
|
|
43
43
|
*/
|
|
44
44
|
function applyCandidateSearchParams(target, params) {
|
|
45
|
-
const tags = params.query
|
|
45
|
+
const tags = params.query?.tags;
|
|
46
46
|
const limit = params.constraints?.limit !== undefined ? clamp(params.constraints.limit, 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX) : target.limit;
|
|
47
47
|
const scanLimit = params.constraints?.scan_limit !== undefined
|
|
48
48
|
? clamp(params.constraints.scan_limit, limit ?? 1, exports.CANDIDATE_SEARCH_SCAN_LIMIT_MAX)
|
|
49
49
|
: target.candidateScanLimit;
|
|
50
|
-
|
|
50
|
+
const constrained = {
|
|
51
51
|
...target,
|
|
52
|
-
tag: tags.join(' '),
|
|
53
|
-
...(tags.length > 1 && params.query.expand === true ? { tagRelation: 'or' } : {}),
|
|
54
52
|
...(limit !== undefined ? { limit } : {}),
|
|
55
53
|
...(scanLimit !== undefined ? { candidateScanLimit: scanLimit } : {}),
|
|
56
54
|
};
|
|
55
|
+
// `query` is optional: a job may carry only a scope selector and/or
|
|
56
|
+
// constraints, in which case the retrieval side stays exactly as the target is
|
|
57
|
+
// configured. (`parseCandidateSearchParamsJson` already yields `null` for such
|
|
58
|
+
// a stored view, so this is the belt to that braces.)
|
|
59
|
+
if (!tags || tags.length === 0)
|
|
60
|
+
return constrained;
|
|
61
|
+
return {
|
|
62
|
+
...constrained,
|
|
63
|
+
tag: tags.join(' '),
|
|
64
|
+
...(tags.length > 1 && params.query?.expand === true ? { tagRelation: 'or' } : {}),
|
|
65
|
+
};
|
|
57
66
|
}
|
|
58
67
|
/**
|
|
59
68
|
* Map `constraints.exclude` onto the existing per-run duplicate history.
|
|
@@ -128,6 +128,13 @@ export interface ParsedTask {
|
|
|
128
128
|
correlationId?: string;
|
|
129
129
|
/** `params.source.account` — the Pixiv resource identity to assert. */
|
|
130
130
|
account?: string;
|
|
131
|
+
/**
|
|
132
|
+
* `params.target_id` — which CONFIGURED target this job runs for. A selector,
|
|
133
|
+
* never an override: the admission resolves it against the same enabled plans
|
|
134
|
+
* and targets the legacy path used. Absent means "the deployment must have
|
|
135
|
+
* exactly one manual-eligible target".
|
|
136
|
+
*/
|
|
137
|
+
targetSelector?: string;
|
|
131
138
|
/** `params` — the occurrence-scoped retrieval view. */
|
|
132
139
|
params: CandidateSearchParams;
|
|
133
140
|
/**
|
|
@@ -46,6 +46,7 @@ const MAX_CALLBACK_URL_LENGTH = 2000;
|
|
|
46
46
|
const MAX_TAG_LENGTH = 100;
|
|
47
47
|
const MAX_TAGS = 20;
|
|
48
48
|
const MAX_EXCLUSIONS = 100;
|
|
49
|
+
const MAX_TARGET_ID_LENGTH = 200;
|
|
49
50
|
/** The only job type this producer serves today. */
|
|
50
51
|
exports.JOB_TYPE_CANDIDATE_SEARCH = 'candidate_search';
|
|
51
52
|
/** `$defs/ProtocolVersion` — this producer speaks exactly one version. */
|
|
@@ -268,15 +269,44 @@ function parseTaskBody(body) {
|
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
271
|
const params = parseCandidateSearchParams(body.params);
|
|
272
|
+
// `params.target_id` is a SCOPE selector, not part of the retrieval view: it
|
|
273
|
+
// selects one of the targets this deployment already configures and never
|
|
274
|
+
// rewrites the target's delivery wiring or plan identity. It is lifted out
|
|
275
|
+
// here so the stored `paramsJson` stays a pure retrieval view — a job carrying
|
|
276
|
+
// only a selector therefore behaves exactly like the legacy refetch route,
|
|
277
|
+
// which ran the target as configured.
|
|
278
|
+
const targetSelector = parseTargetSelector(body.params);
|
|
271
279
|
return {
|
|
272
280
|
idempotencyKey,
|
|
273
281
|
...(correlationId !== undefined ? { correlationId } : {}),
|
|
274
282
|
...(params.source?.account !== undefined ? { account: params.source.account } : {}),
|
|
283
|
+
...(targetSelector !== undefined ? { targetSelector } : {}),
|
|
275
284
|
params,
|
|
276
285
|
...(deadlineMs !== undefined ? { deadlineMs } : {}),
|
|
277
286
|
...(callbackUrl !== undefined ? { callbackUrl } : {}),
|
|
278
287
|
};
|
|
279
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* `params.target_id` — the explicit target scope of a generic `candidate_search`.
|
|
291
|
+
*
|
|
292
|
+
* Optional and additive: absent means "the deployment must have exactly one
|
|
293
|
+
* manual-eligible target", which is the pre-existing behaviour.
|
|
294
|
+
*/
|
|
295
|
+
function parseTargetSelector(raw) {
|
|
296
|
+
if (!isPlainObject(raw))
|
|
297
|
+
return undefined;
|
|
298
|
+
const value = raw.target_id;
|
|
299
|
+
if (value === undefined || value === null)
|
|
300
|
+
return undefined;
|
|
301
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
302
|
+
throw invalid('params.target_id must be a non-empty string');
|
|
303
|
+
}
|
|
304
|
+
const selector = value.trim();
|
|
305
|
+
if (selector.length > MAX_TARGET_ID_LENGTH) {
|
|
306
|
+
throw invalid(`params.target_id must be at most ${MAX_TARGET_ID_LENGTH} characters`);
|
|
307
|
+
}
|
|
308
|
+
return selector;
|
|
309
|
+
}
|
|
280
310
|
/** `$defs/CandidateSearchParams`, validated field by field. */
|
|
281
311
|
function parseCandidateSearchParams(raw) {
|
|
282
312
|
if (!isPlainObject(raw)) {
|
|
@@ -302,32 +332,39 @@ function parseCandidateSearchParams(raw) {
|
|
|
302
332
|
source = { platform };
|
|
303
333
|
}
|
|
304
334
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
for (const tag of tags) {
|
|
317
|
-
if (typeof tag !== 'string' || tag.trim() === '' || tag.length > MAX_TAG_LENGTH) {
|
|
335
|
+
// `query` is optional: a job may carry only a scope selector
|
|
336
|
+
// (`params.target_id`) and/or constraints, in which case the resolved target
|
|
337
|
+
// runs exactly as configured — the same semantics the legacy refetch route
|
|
338
|
+
// always had. When present it is validated exactly as before.
|
|
339
|
+
let query;
|
|
340
|
+
if (raw.query !== undefined && raw.query !== null) {
|
|
341
|
+
if (!isPlainObject(raw.query)) {
|
|
342
|
+
throw invalid('params.query must be a JSON object');
|
|
343
|
+
}
|
|
344
|
+
const tags = raw.query.tags;
|
|
345
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
318
346
|
throw invalid('params.query.tags must be a non-empty array of strings');
|
|
319
347
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
348
|
+
if (tags.length > MAX_TAGS) {
|
|
349
|
+
throw invalid(`params.query.tags must contain at most ${MAX_TAGS} tags`);
|
|
350
|
+
}
|
|
351
|
+
for (const tag of tags) {
|
|
352
|
+
if (typeof tag !== 'string' || tag.trim() === '' || tag.length > MAX_TAG_LENGTH) {
|
|
353
|
+
throw invalid('params.query.tags must be a non-empty array of strings');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (raw.query.expand !== undefined && raw.query.expand !== null && typeof raw.query.expand !== 'boolean') {
|
|
357
|
+
throw invalid('params.query.expand must be a boolean');
|
|
358
|
+
}
|
|
359
|
+
query = {
|
|
360
|
+
tags: tags.map((tag) => tag.trim()),
|
|
361
|
+
...(raw.query.expand === true ? { expand: true } : {}),
|
|
362
|
+
};
|
|
323
363
|
}
|
|
324
364
|
const constraints = parseConstraints(raw.constraints);
|
|
325
365
|
return {
|
|
326
366
|
...(source !== undefined ? { source } : {}),
|
|
327
|
-
query: {
|
|
328
|
-
tags: tags.map((tag) => tag.trim()),
|
|
329
|
-
...(query.expand === true ? { expand: true } : {}),
|
|
330
|
-
},
|
|
367
|
+
...(query !== undefined ? { query } : {}),
|
|
331
368
|
...(constraints !== undefined ? { constraints } : {}),
|
|
332
369
|
};
|
|
333
370
|
}
|
|
@@ -395,7 +432,18 @@ function buildCapabilities(config, now = Date.now()) {
|
|
|
395
432
|
// durable stream and `POST /jobs/:id/events/ack` persists the cursor
|
|
396
433
|
// (`src/scheduler/JobEventStream.ts`), and a Task's `callback_url`
|
|
397
434
|
// receives `$defs/Event` bodies through the existing outbox.
|
|
398
|
-
|
|
435
|
+
// `target_selector` declares `params.target_id`: a job may name which
|
|
436
|
+
// configured target it runs for, and may omit `params.query` entirely —
|
|
437
|
+
// both are what makes this face a drop-in for the legacy refetch route.
|
|
438
|
+
features: [
|
|
439
|
+
'events',
|
|
440
|
+
'progress',
|
|
441
|
+
'cancel',
|
|
442
|
+
'idempotency',
|
|
443
|
+
'exclude',
|
|
444
|
+
'tag_expansion',
|
|
445
|
+
'target_selector',
|
|
446
|
+
],
|
|
399
447
|
queued_timeout_ms: budgets.queuedTimeoutMs,
|
|
400
448
|
stall_timeout_ms: budgets.stallTimeoutMs,
|
|
401
449
|
heartbeat_interval_ms: SlotCoordinator_1.SLOT_HEARTBEAT_MS,
|
|
@@ -30,8 +30,11 @@ export declare const MANUAL_CANDIDATE_SEARCH_SLOT_NAME = "\u5BA1\u6838\u7FA4\u91
|
|
|
30
30
|
/**
|
|
31
31
|
* A consumer-initiated candidate search, normalized from whichever adapter
|
|
32
32
|
* received it. `targetId` exists only for the legacy path (the old endpoint
|
|
33
|
-
* carries the target in its URL); the generic job surface
|
|
34
|
-
*
|
|
33
|
+
* carries the target in its URL); the generic job surface instead names the
|
|
34
|
+
* target explicitly with `targetSelector` (`params.target_id`) or, when it names
|
|
35
|
+
* none, resolves the unique manual-eligible target from configuration. Both are
|
|
36
|
+
* SELECTORS over the configured targets — neither can override delivery wiring or
|
|
37
|
+
* plan identity.
|
|
35
38
|
*/
|
|
36
39
|
export interface CandidateSearchJobRequest {
|
|
37
40
|
/** Consumer idempotency key; becomes the durable `manual_request_id`. */
|
|
@@ -40,6 +43,8 @@ export interface CandidateSearchJobRequest {
|
|
|
40
43
|
correlationId?: string;
|
|
41
44
|
/** Legacy adapter only: target id taken from the request path. */
|
|
42
45
|
targetId?: string;
|
|
46
|
+
/** Generic adapter only: `params.target_id`, an explicit target selector. */
|
|
47
|
+
targetSelector?: string;
|
|
43
48
|
/** Generic adapter only: `params.source.account` resource identity. */
|
|
44
49
|
account?: string;
|
|
45
50
|
/**
|
|
@@ -90,8 +95,11 @@ export declare class ManualJobAdmission {
|
|
|
90
95
|
/**
|
|
91
96
|
* Legacy: the target comes from the URL and must resolve to exactly one
|
|
92
97
|
* enabled plan (the historic `unknown target` / `ambiguous target` rules).
|
|
93
|
-
* Generic
|
|
94
|
-
*
|
|
98
|
+
* Generic with `params.target_id`: the same rules, applied to the selector the
|
|
99
|
+
* caller named — a selector picks an existing target, it never rewrites one.
|
|
100
|
+
* Generic without any selector: the target is discovered from configuration —
|
|
101
|
+
* every enabled plan's selected target that actually wires manual
|
|
102
|
+
* candidate-search delivery, which must be exactly one.
|
|
95
103
|
*/
|
|
96
104
|
private resolveTarget;
|
|
97
105
|
/**
|
|
@@ -60,7 +60,7 @@ class ManualJobAdmission {
|
|
|
60
60
|
const legacy = request.targetId !== undefined;
|
|
61
61
|
const config = this.deps.config();
|
|
62
62
|
this.assertAccount(config, request.account);
|
|
63
|
-
const admission = this.resolveTarget(config, request.targetId);
|
|
63
|
+
const admission = this.resolveTarget(config, request.targetId, request.targetSelector);
|
|
64
64
|
const { plan, target } = admission;
|
|
65
65
|
this.assertParamsApplyToTarget(target, request.params);
|
|
66
66
|
const derivedSlotId = `${plan.id}@manual-${key.toLowerCase()}`;
|
|
@@ -109,34 +109,40 @@ class ManualJobAdmission {
|
|
|
109
109
|
/**
|
|
110
110
|
* Legacy: the target comes from the URL and must resolve to exactly one
|
|
111
111
|
* enabled plan (the historic `unknown target` / `ambiguous target` rules).
|
|
112
|
-
* Generic
|
|
113
|
-
*
|
|
112
|
+
* Generic with `params.target_id`: the same rules, applied to the selector the
|
|
113
|
+
* caller named — a selector picks an existing target, it never rewrites one.
|
|
114
|
+
* Generic without any selector: the target is discovered from configuration —
|
|
115
|
+
* every enabled plan's selected target that actually wires manual
|
|
116
|
+
* candidate-search delivery, which must be exactly one.
|
|
114
117
|
*/
|
|
115
|
-
resolveTarget(config, targetId) {
|
|
116
|
-
|
|
118
|
+
resolveTarget(config, targetId, targetSelector) {
|
|
119
|
+
const explicit = targetId ?? targetSelector;
|
|
120
|
+
if (explicit !== undefined) {
|
|
117
121
|
const plans = (config.schedules ?? []).filter((plan) => plan.enabled !== false &&
|
|
118
|
-
(0, schedules_1.selectScheduleTargets)(config.targets, plan).some((target) => target.id ===
|
|
122
|
+
(0, schedules_1.selectScheduleTargets)(config.targets, plan).some((target) => target.id === explicit));
|
|
119
123
|
if (plans.length === 0) {
|
|
120
124
|
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
121
125
|
message: 'unknown target',
|
|
122
|
-
detail: { reason: 'unknown_target', target_id:
|
|
126
|
+
detail: { reason: 'unknown_target', target_id: explicit },
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
129
|
if (plans.length !== 1) {
|
|
126
130
|
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
127
131
|
message: 'ambiguous target',
|
|
128
|
-
detail: { reason: 'ambiguous_target', target_id:
|
|
132
|
+
detail: { reason: 'ambiguous_target', target_id: explicit },
|
|
129
133
|
});
|
|
130
134
|
}
|
|
131
135
|
const plan = plans[0];
|
|
132
|
-
const target = (0, schedules_1.selectScheduleTargets)(config.targets, plan).find((item) => item.id ===
|
|
136
|
+
const target = (0, schedules_1.selectScheduleTargets)(config.targets, plan).find((item) => item.id === explicit && Boolean(item.id));
|
|
133
137
|
if (!target || !target.id) {
|
|
134
138
|
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
135
139
|
message: 'unknown target',
|
|
136
|
-
detail: { reason: 'unknown_target', target_id:
|
|
140
|
+
detail: { reason: 'unknown_target', target_id: explicit },
|
|
137
141
|
});
|
|
138
142
|
}
|
|
139
|
-
|
|
143
|
+
// `legacy` only selects the historic diagnostic vocabulary: the URL-borne
|
|
144
|
+
// target is the old path, `params.target_id` is the generic one.
|
|
145
|
+
this.assertManualWiring(config, target, target.id, targetId !== undefined);
|
|
140
146
|
return { plan, target, targetId: target.id };
|
|
141
147
|
}
|
|
142
148
|
const candidates = [];
|
|
@@ -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
|
}
|
|
@@ -47,6 +47,7 @@ class ManualJobService {
|
|
|
47
47
|
idempotencyKey: task.idempotencyKey,
|
|
48
48
|
...(task.correlationId !== undefined ? { correlationId: task.correlationId } : {}),
|
|
49
49
|
...(task.account !== undefined ? { account: task.account } : {}),
|
|
50
|
+
...(task.targetSelector !== undefined ? { targetSelector: task.targetSelector } : {}),
|
|
50
51
|
params: task.params,
|
|
51
52
|
});
|
|
52
53
|
// The admission event (and the declared callback_url) are recorded against
|
|
@@ -77,10 +78,15 @@ class ManualJobService {
|
|
|
77
78
|
* that terminalises the work and stops further deliveries.
|
|
78
79
|
*/
|
|
79
80
|
cancelJob(jobId) {
|
|
80
|
-
(0, JobCancellation_1.cancelConsumerJob)(this.deps.database, jobId, this.now());
|
|
81
|
+
const result = (0, JobCancellation_1.cancelConsumerJob)(this.deps.database, jobId, this.now());
|
|
81
82
|
// The cancellation is now durable, so its terminal event must be too — a
|
|
82
83
|
// consumer that polls after cancelling must never see an unterminated stream.
|
|
83
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);
|
|
84
90
|
return this.viewBySlotId(jobId);
|
|
85
91
|
}
|
|
86
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.
|
|
5
|
+
exports.BUILD = { version: '3.4.1', commit: 'd2e9c9e338bd' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixivflow",
|
|
3
|
-
"version": "3.
|
|
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",
|