pixivflow 3.2.0 → 3.4.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.
- package/README.md +26 -1
- package/dist/commands/SchedulerCommand.js +31 -51
- package/dist/commands/scheduler-runtime.js +47 -7
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +6 -0
- package/dist/config/types.d.ts +20 -0
- package/dist/config/validation.js +11 -0
- package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
- package/dist/delivery/DeliveryDispatcher.js +14 -0
- package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
- package/dist/delivery/EventCallbackDelivery.js +89 -0
- package/dist/delivery/OutboxWorker.d.ts +11 -0
- package/dist/delivery/OutboxWorker.js +51 -1
- package/dist/package.json +1 -1
- package/dist/scheduler/CandidateSearchParams.d.ts +92 -0
- package/dist/scheduler/CandidateSearchParams.js +103 -0
- package/dist/scheduler/JobCancellation.d.ts +36 -0
- package/dist/scheduler/JobCancellation.js +73 -0
- package/dist/scheduler/JobEventStream.d.ts +94 -0
- package/dist/scheduler/JobEventStream.js +251 -0
- package/dist/scheduler/JobFacade.d.ts +305 -0
- package/dist/scheduler/JobFacade.js +670 -0
- package/dist/scheduler/JobProjection.d.ts +67 -0
- package/dist/scheduler/JobProjection.js +32 -0
- package/dist/scheduler/JobView.d.ts +34 -0
- package/dist/scheduler/JobView.js +82 -0
- package/dist/scheduler/ManualJobAdmission.d.ts +134 -0
- package/dist/scheduler/ManualJobAdmission.js +316 -0
- package/dist/scheduler/ManualJobService.d.ts +57 -0
- package/dist/scheduler/ManualJobService.js +92 -0
- package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
- package/dist/scheduler/ManualRefetchAdapter.js +41 -0
- package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
- package/dist/scheduler/MultiScheduleManager.js +68 -6
- package/dist/scheduler/ProtocolErrors.d.ts +68 -0
- package/dist/scheduler/ProtocolErrors.js +94 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
- package/dist/scheduler/ScheduleTriggerServer.js +166 -1
- package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
- package/dist/scheduler/SlotBusinessStatus.js +6 -0
- package/dist/scheduler/SlotCoordinator.d.ts +49 -5
- package/dist/scheduler/SlotCoordinator.js +89 -22
- package/dist/scheduler/StallSweep.d.ts +65 -0
- package/dist/scheduler/StallSweep.js +105 -0
- package/dist/scheduler/TargetOutcome.d.ts +39 -1
- package/dist/scheduler/TargetOutcome.js +51 -1
- package/dist/scheduler/ledger-time.d.ts +23 -0
- package/dist/scheduler/ledger-time.js +37 -0
- package/dist/storage/DatabaseMigration.js +48 -0
- package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
- package/dist/storage/repositories/DeliveryRepository.js +13 -0
- package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
- package/dist/storage/repositories/OutboxRepository.js +142 -0
- package/dist/storage/repositories/SlotRepository.d.ts +34 -0
- package/dist/storage/repositories/SlotRepository.js +61 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic Job projection (§liveness).
|
|
3
|
+
*
|
|
4
|
+
* One durable Slot cell, described in terms a consumer can act on WITHOUT
|
|
5
|
+
* knowing this service's internal column names, table layout or scheduler
|
|
6
|
+
* topology. This is the body a job-status endpoint returns; the manual-refetch
|
|
7
|
+
* GET endpoint is the first such consumer (and declares the legacy
|
|
8
|
+
* `requestId`/`slotId`/`state`/`slotStatus` aliases on top of it).
|
|
9
|
+
*
|
|
10
|
+
* Why the extra fields exist: a caller that receives only a coarse state cannot
|
|
11
|
+
* tell "queued two seconds ago" from "pending for three days behind a stopped
|
|
12
|
+
* scheduler", so it cannot implement a heartbeat-based watchdog — it can only
|
|
13
|
+
* poll forever. Timestamps + lease liveness + attempt count make the difference
|
|
14
|
+
* decidable, and `terminalReasonCode`/`terminalReasonMessage` give the
|
|
15
|
+
* first-level cause once the job is terminal.
|
|
16
|
+
*
|
|
17
|
+
* Timestamp normalisation (the columns do NOT share one shape):
|
|
18
|
+
* - `schedule_slots.created_at` / `started_at` and
|
|
19
|
+
* `schedule_slot_items.updated_at` are SQLite `CURRENT_TIMESTAMP` UTC
|
|
20
|
+
* datetimes without a zone marker -> parsed as UTC (see `ledger-time`).
|
|
21
|
+
* - `schedule_slots.heartbeat_at` / `lease_until` are already epoch ms.
|
|
22
|
+
* - Every timestamp in this projection is epoch milliseconds UTC, or null when
|
|
23
|
+
* the ledger has no such instant.
|
|
24
|
+
*/
|
|
25
|
+
import type { SlotItemRecord, SlotRecord } from '../storage/repositories/SlotRepository';
|
|
26
|
+
export interface JobStatusProjection {
|
|
27
|
+
/** The caller's own request id, echoed back. */
|
|
28
|
+
requestId: string;
|
|
29
|
+
slotId: string;
|
|
30
|
+
/** Cell state from the Slot FSM (pending|selected|artifact_ready|delivery_pending|submitted|no_candidate|duplicate|failed). */
|
|
31
|
+
state: string;
|
|
32
|
+
/** Rolled-up Slot status (pending|running|success|partial|failed|expired). */
|
|
33
|
+
slotStatus: string;
|
|
34
|
+
createdAt: number | null;
|
|
35
|
+
/** When a worker first marked the slot running. */
|
|
36
|
+
startedAt: number | null;
|
|
37
|
+
/** Last cell update: the freshest durable progress signal for this target. */
|
|
38
|
+
updatedAt: number | null;
|
|
39
|
+
/** Last lease heartbeat written by the owning run. */
|
|
40
|
+
heartbeatAt: number | null;
|
|
41
|
+
/** When the current lease expires (null when nobody holds one). */
|
|
42
|
+
leaseExpiresAt: number | null;
|
|
43
|
+
/** A lease is held AND unexpired: some run owns this slot right now. */
|
|
44
|
+
leaseActive: boolean;
|
|
45
|
+
/** The slot left `pending` (claimed by a run or already terminal). */
|
|
46
|
+
claimed: boolean;
|
|
47
|
+
/** Delivery/execution attempts recorded on this cell. */
|
|
48
|
+
attemptCount: number;
|
|
49
|
+
terminalReasonCode: string | null;
|
|
50
|
+
terminalReasonMessage: string | null;
|
|
51
|
+
/** Internal name of the request that opened this job. */
|
|
52
|
+
manualRequestId: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Consumer-facing opaque key for the same request. A consumer groups work by
|
|
55
|
+
* this value and never needs to learn the column it came from.
|
|
56
|
+
*/
|
|
57
|
+
idempotencyKey: string | null;
|
|
58
|
+
/** Opaque caller correlation (review chain / review id). */
|
|
59
|
+
correlationId: string | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Project one (slot, cell) pair. Pure: no clock reads beyond the injectable
|
|
63
|
+
* `now`, so the same ledger row always projects the same way for a given
|
|
64
|
+
* instant.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildJobProjection(requestId: string, slot: SlotRecord, cell: SlotItemRecord, now?: number): JobStatusProjection;
|
|
67
|
+
//# sourceMappingURL=JobProjection.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildJobProjection = buildJobProjection;
|
|
4
|
+
const ledger_time_1 = require("./ledger-time");
|
|
5
|
+
/**
|
|
6
|
+
* Project one (slot, cell) pair. Pure: no clock reads beyond the injectable
|
|
7
|
+
* `now`, so the same ledger row always projects the same way for a given
|
|
8
|
+
* instant.
|
|
9
|
+
*/
|
|
10
|
+
function buildJobProjection(requestId, slot, cell, now = Date.now()) {
|
|
11
|
+
const leaseActive = slot.leaseOwner !== null && slot.leaseUntil !== null && slot.leaseUntil > now;
|
|
12
|
+
return {
|
|
13
|
+
requestId,
|
|
14
|
+
slotId: slot.id,
|
|
15
|
+
state: cell.status,
|
|
16
|
+
slotStatus: slot.status,
|
|
17
|
+
createdAt: (0, ledger_time_1.sqliteUtcMs)(slot.createdAt) ?? null,
|
|
18
|
+
startedAt: (0, ledger_time_1.sqliteUtcMs)(slot.startedAt) ?? null,
|
|
19
|
+
updatedAt: (0, ledger_time_1.sqliteUtcMs)(cell.updatedAt) ?? null,
|
|
20
|
+
heartbeatAt: slot.heartbeatAt ?? null,
|
|
21
|
+
leaseExpiresAt: slot.leaseUntil ?? null,
|
|
22
|
+
leaseActive,
|
|
23
|
+
claimed: slot.status !== 'pending' || leaseActive,
|
|
24
|
+
attemptCount: cell.attemptCount,
|
|
25
|
+
terminalReasonCode: cell.terminalReasonCode ?? null,
|
|
26
|
+
terminalReasonMessage: cell.terminalReasonMessage ?? null,
|
|
27
|
+
manualRequestId: slot.manualRequestId ?? null,
|
|
28
|
+
idempotencyKey: slot.manualRequestId ?? null,
|
|
29
|
+
correlationId: slot.correlationId ?? null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=JobProjection.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one read model for a generic job: durable slot + cell -> `$defs/Job`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `ManualJobService` so the HTTP job surface and the event stream
|
|
5
|
+
* (`JobEventStream`) project a slot through EXACTLY the same code. Two views of
|
|
6
|
+
* the same ledger that could disagree would be a second status machine in
|
|
7
|
+
* disguise.
|
|
8
|
+
*/
|
|
9
|
+
import type { StandaloneConfig } from '../config';
|
|
10
|
+
import type { Database } from '../storage/Database';
|
|
11
|
+
import type { SlotItemRecord, SlotRecord } from '../storage/repositories/SlotRepository';
|
|
12
|
+
import { ProtocolJob } from './JobFacade';
|
|
13
|
+
export interface JobViewDeps {
|
|
14
|
+
database: Database;
|
|
15
|
+
/** The live config snapshot (the trigger server always reads the newest). */
|
|
16
|
+
config(): StandaloneConfig;
|
|
17
|
+
/** Injected clock, for tests. */
|
|
18
|
+
now(): number;
|
|
19
|
+
}
|
|
20
|
+
/** Project one durable slot. Throws when the ledger row is not materialized. */
|
|
21
|
+
export declare function protocolJobForSlot(deps: JobViewDeps, slot: SlotRecord): ProtocolJob;
|
|
22
|
+
/** Project one slot by id; null when this job is not in the ledger. */
|
|
23
|
+
export declare function protocolJobBySlotId(deps: JobViewDeps, slotId: string): ProtocolJob | null;
|
|
24
|
+
/** Project an admitted slot that MUST exist. */
|
|
25
|
+
export declare function requireProtocolJobForSlotId(deps: JobViewDeps, slotId: string): ProtocolJob;
|
|
26
|
+
/**
|
|
27
|
+
* What this job actually delivered, read from the durable delivery intents.
|
|
28
|
+
* Only a confirmed delivery counts; an intent that never left is not an outcome.
|
|
29
|
+
*/
|
|
30
|
+
export declare function deliveredWork(deps: JobViewDeps, slot: SlotRecord, targetId: string, cell: SlotItemRecord): {
|
|
31
|
+
workId: string | null;
|
|
32
|
+
workType: string | null;
|
|
33
|
+
} | null;
|
|
34
|
+
//# sourceMappingURL=JobView.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The one read model for a generic job: durable slot + cell -> `$defs/Job`.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `ManualJobService` so the HTTP job surface and the event stream
|
|
6
|
+
* (`JobEventStream`) project a slot through EXACTLY the same code. Two views of
|
|
7
|
+
* the same ledger that could disagree would be a second status machine in
|
|
8
|
+
* disguise.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.protocolJobForSlot = protocolJobForSlot;
|
|
12
|
+
exports.protocolJobBySlotId = protocolJobBySlotId;
|
|
13
|
+
exports.requireProtocolJobForSlotId = requireProtocolJobForSlotId;
|
|
14
|
+
exports.deliveredWork = deliveredWork;
|
|
15
|
+
const JobFacade_1 = require("./JobFacade");
|
|
16
|
+
const JobProjection_1 = require("./JobProjection");
|
|
17
|
+
const ProtocolErrors_1 = require("./ProtocolErrors");
|
|
18
|
+
/** Project one durable slot. Throws when the ledger row is not materialized. */
|
|
19
|
+
function protocolJobForSlot(deps, slot) {
|
|
20
|
+
const targetId = slot.targetIds[0];
|
|
21
|
+
const cell = targetId ? deps.database.slots.getCell(slot.id, targetId) : null;
|
|
22
|
+
if (!targetId || !cell) {
|
|
23
|
+
throw new ProtocolErrors_1.ProtocolRequestError('internal_error', 500, {
|
|
24
|
+
message: 'job has no materialized work item',
|
|
25
|
+
detail: { reason: 'job_cell_missing', job_id: slot.id },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const requestId = slot.manualRequestId ?? slot.id;
|
|
29
|
+
const projection = (0, JobProjection_1.buildJobProjection)(requestId, slot, cell, deps.now());
|
|
30
|
+
return (0, JobFacade_1.buildProtocolJob)({
|
|
31
|
+
jobType: JobFacade_1.JOB_TYPE_CANDIDATE_SEARCH,
|
|
32
|
+
projection,
|
|
33
|
+
deadlineAt: deadlineAt(slot, deps.config()),
|
|
34
|
+
delivered: deliveredWork(deps, slot, targetId, cell),
|
|
35
|
+
supply: cell.candidateReport,
|
|
36
|
+
now: deps.now(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Project one slot by id; null when this job is not in the ledger. */
|
|
40
|
+
function protocolJobBySlotId(deps, slotId) {
|
|
41
|
+
const slot = deps.database.slots.getSlot(slotId);
|
|
42
|
+
return slot ? protocolJobForSlot(deps, slot) : null;
|
|
43
|
+
}
|
|
44
|
+
/** Project an admitted slot that MUST exist. */
|
|
45
|
+
function requireProtocolJobForSlotId(deps, slotId) {
|
|
46
|
+
const job = protocolJobBySlotId(deps, slotId);
|
|
47
|
+
if (!job) {
|
|
48
|
+
throw new ProtocolErrors_1.ProtocolRequestError('internal_error', 500, {
|
|
49
|
+
message: 'admitted job is missing from the ledger',
|
|
50
|
+
detail: { reason: 'job_not_persisted', job_id: slotId },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return job;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What this job actually delivered, read from the durable delivery intents.
|
|
57
|
+
* Only a confirmed delivery counts; an intent that never left is not an outcome.
|
|
58
|
+
*/
|
|
59
|
+
function deliveredWork(deps, slot, targetId, cell) {
|
|
60
|
+
const delivered = deps.database.deliveries
|
|
61
|
+
.listForSlotCell(slot.id, targetId)
|
|
62
|
+
.filter((row) => row.status === 'delivered');
|
|
63
|
+
const last = delivered[delivered.length - 1];
|
|
64
|
+
if (last)
|
|
65
|
+
return { workId: last.pixivId, workType: last.workType };
|
|
66
|
+
if (cell.status === 'submitted' && cell.workId) {
|
|
67
|
+
return { workId: cell.workId, workType: cell.workType };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The ceiling this deployment enforces for the job: the plan's execution
|
|
73
|
+
* timeout, measured from the occurrence. A consumer-supplied `deadline_ms`
|
|
74
|
+
* that is shorter is NOT honoured yet (no durable column enforces it), so the
|
|
75
|
+
* facade must not report it here.
|
|
76
|
+
*/
|
|
77
|
+
function deadlineAt(slot, config) {
|
|
78
|
+
if (slot.occurrenceAt === null)
|
|
79
|
+
return null;
|
|
80
|
+
return slot.occurrenceAt + (0, JobFacade_1.resolveDeadlineMs)(config, slot.scheduleId);
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=JobView.js.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE admission path for consumer-initiated candidate search work.
|
|
3
|
+
*
|
|
4
|
+
* Two HTTP entry points exist for the same durable work item:
|
|
5
|
+
* - the legacy TelePost manual refetch (`POST /internal/targets/:id/refetch`)
|
|
6
|
+
* - the generic Workflow Protocol v1 job surface (`POST /jobs`)
|
|
7
|
+
*
|
|
8
|
+
* They differ only in how the request is shaped, never in what happens: both
|
|
9
|
+
* adapters translate their input into one `CandidateSearchJobRequest` and call
|
|
10
|
+
* `admit()`, which owns target resolution, idempotent identity resolution and
|
|
11
|
+
* the durable slot admission. Identity is the consumer's own key
|
|
12
|
+
* (`manual_request_id`), so a replay from either entry point converges on the
|
|
13
|
+
* same `job_id`/`slotId` and never creates a second slot or delivery.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here inspects the business meaning of a target, a plan or a slot
|
|
16
|
+
* name; a slot label is durable display data and is written, never branched on.
|
|
17
|
+
*/
|
|
18
|
+
import { StandaloneConfig, TargetConfig } from '../config';
|
|
19
|
+
import type { Database } from '../storage/Database';
|
|
20
|
+
import { CandidateSearchParams } from './CandidateSearchParams';
|
|
21
|
+
import type { SlotContext, SlotCoordinator } from './SlotCoordinator';
|
|
22
|
+
/**
|
|
23
|
+
* Durable slot label for consumer-initiated candidate search.
|
|
24
|
+
*
|
|
25
|
+
* FROZEN: production ledgers already carry this value on historical rows, and
|
|
26
|
+
* the legacy compatibility contract forbids changing it. It is a display label
|
|
27
|
+
* only — no code may branch on a slot name.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MANUAL_CANDIDATE_SEARCH_SLOT_NAME = "\u5BA1\u6838\u7FA4\u91CD\u6293";
|
|
30
|
+
/**
|
|
31
|
+
* A consumer-initiated candidate search, normalized from whichever adapter
|
|
32
|
+
* received it. `targetId` exists only for the legacy path (the old endpoint
|
|
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.
|
|
38
|
+
*/
|
|
39
|
+
export interface CandidateSearchJobRequest {
|
|
40
|
+
/** Consumer idempotency key; becomes the durable `manual_request_id`. */
|
|
41
|
+
idempotencyKey: string;
|
|
42
|
+
/** Opaque caller correlation: stored, echoed back, never interpreted. */
|
|
43
|
+
correlationId?: string;
|
|
44
|
+
/** Legacy adapter only: target id taken from the request path. */
|
|
45
|
+
targetId?: string;
|
|
46
|
+
/** Generic adapter only: `params.target_id`, an explicit target selector. */
|
|
47
|
+
targetSelector?: string;
|
|
48
|
+
/** Generic adapter only: `params.source.account` resource identity. */
|
|
49
|
+
account?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Generic adapter only: the requested retrieval view (§6). Persisted with the
|
|
52
|
+
* occurrence-scoped slot so a resumed worker re-applies it; the legacy adapter
|
|
53
|
+
* never sets it, so a refetch keeps running the plan exactly as configured.
|
|
54
|
+
*/
|
|
55
|
+
params?: CandidateSearchParams;
|
|
56
|
+
}
|
|
57
|
+
/** What happened to the request, independent of which adapter asked. */
|
|
58
|
+
export type ManualJobDisposition = 'accepted' | 'queued' | 'already_completed';
|
|
59
|
+
export interface CandidateSearchJobResult {
|
|
60
|
+
/** The durable identity of the work item (`job_id`). */
|
|
61
|
+
slotId: string;
|
|
62
|
+
planId: string;
|
|
63
|
+
targetId: string;
|
|
64
|
+
disposition: ManualJobDisposition;
|
|
65
|
+
/** True when an existing slot owned this key (an idempotent replay). */
|
|
66
|
+
reused: boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface ManualJobAdmissionDeps {
|
|
69
|
+
database: Database;
|
|
70
|
+
coordinator: SlotCoordinator;
|
|
71
|
+
/** The live config snapshot (the trigger server always reads the newest). */
|
|
72
|
+
config(): StandaloneConfig;
|
|
73
|
+
/** Hand the prepared occurrence to execution; returns false when queued. */
|
|
74
|
+
admit(planId: string, context: SlotContext, targetId: string): boolean;
|
|
75
|
+
}
|
|
76
|
+
export declare class ManualJobAdmission {
|
|
77
|
+
private readonly deps;
|
|
78
|
+
constructor(deps: ManualJobAdmissionDeps);
|
|
79
|
+
/**
|
|
80
|
+
* Resolve, admit and report one consumer-initiated candidate search.
|
|
81
|
+
*
|
|
82
|
+
* Idempotent by `idempotencyKey`: the first call creates the durable slot,
|
|
83
|
+
* every later call (from either entry point) returns the same `slotId`.
|
|
84
|
+
* Throws `ProtocolRequestError` so every adapter answers in the protocol's
|
|
85
|
+
* error shape while the legacy adapter keeps its historic message strings.
|
|
86
|
+
*/
|
|
87
|
+
admit(request: CandidateSearchJobRequest): CandidateSearchJobResult;
|
|
88
|
+
/**
|
|
89
|
+
* `params.source.account` names the Pixiv resource identity. One process owns
|
|
90
|
+
* exactly one credential profile, so the only satisfiable value is the
|
|
91
|
+
* configured one; anything else is refused rather than silently downgraded to
|
|
92
|
+
* a different account.
|
|
93
|
+
*/
|
|
94
|
+
private assertAccount;
|
|
95
|
+
/**
|
|
96
|
+
* Legacy: the target comes from the URL and must resolve to exactly one
|
|
97
|
+
* enabled plan (the historic `unknown target` / `ambiguous target` rules).
|
|
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.
|
|
103
|
+
*/
|
|
104
|
+
private resolveTarget;
|
|
105
|
+
/**
|
|
106
|
+
* Idempotent identity resolution. The key is the durable identity; the
|
|
107
|
+
* plan-derived slot id is only the legacy naming convention. A key that
|
|
108
|
+
* already belongs to different work is a conflict, never a second slot.
|
|
109
|
+
*/
|
|
110
|
+
private resolveExisting;
|
|
111
|
+
/**
|
|
112
|
+
* §3: reusing an idempotency key with DIFFERENT params is a conflict, never a
|
|
113
|
+
* silent second meaning for the same job. The comparison is skipped when
|
|
114
|
+
* either side carries no retrieval view (the legacy adapter never sets one),
|
|
115
|
+
* so a legacy replay of a generic job stays idempotent instead of conflicting.
|
|
116
|
+
*/
|
|
117
|
+
private assertSameParams;
|
|
118
|
+
/** A key may not be reused for a different plan/target than it first created. */
|
|
119
|
+
private assertSameWork;
|
|
120
|
+
private conflict;
|
|
121
|
+
/** Legacy messages are preserved verbatim: deployed clients match on them. */
|
|
122
|
+
private assertManualWiring;
|
|
123
|
+
/**
|
|
124
|
+
* `constraints.work_types` restricts the work type this job accepts. The
|
|
125
|
+
* producer knows the resolved target's own type, so a request that asks for a
|
|
126
|
+
* type the target does not produce is refused instead of silently widened.
|
|
127
|
+
*/
|
|
128
|
+
private assertParamsApplyToTarget;
|
|
129
|
+
/** Build the occurrence this work item occupies — the same shape as before. */
|
|
130
|
+
private buildContext;
|
|
131
|
+
}
|
|
132
|
+
/** Exported for tests/diagnostics: does this target serve manual candidate search? */
|
|
133
|
+
export declare function targetServesManualCandidateSearch(config: StandaloneConfig, target: TargetConfig): boolean;
|
|
134
|
+
//# sourceMappingURL=ManualJobAdmission.d.ts.map
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The ONE admission path for consumer-initiated candidate search work.
|
|
4
|
+
*
|
|
5
|
+
* Two HTTP entry points exist for the same durable work item:
|
|
6
|
+
* - the legacy TelePost manual refetch (`POST /internal/targets/:id/refetch`)
|
|
7
|
+
* - the generic Workflow Protocol v1 job surface (`POST /jobs`)
|
|
8
|
+
*
|
|
9
|
+
* They differ only in how the request is shaped, never in what happens: both
|
|
10
|
+
* adapters translate their input into one `CandidateSearchJobRequest` and call
|
|
11
|
+
* `admit()`, which owns target resolution, idempotent identity resolution and
|
|
12
|
+
* the durable slot admission. Identity is the consumer's own key
|
|
13
|
+
* (`manual_request_id`), so a replay from either entry point converges on the
|
|
14
|
+
* same `job_id`/`slotId` and never creates a second slot or delivery.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here inspects the business meaning of a target, a plan or a slot
|
|
17
|
+
* name; a slot label is durable display data and is written, never branched on.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.ManualJobAdmission = exports.MANUAL_CANDIDATE_SEARCH_SLOT_NAME = void 0;
|
|
21
|
+
exports.targetServesManualCandidateSearch = targetServesManualCandidateSearch;
|
|
22
|
+
const targetRoutes_1 = require("../delivery/targetRoutes");
|
|
23
|
+
const ProtocolErrors_1 = require("./ProtocolErrors");
|
|
24
|
+
const schedules_1 = require("./schedules");
|
|
25
|
+
/**
|
|
26
|
+
* Durable slot label for consumer-initiated candidate search.
|
|
27
|
+
*
|
|
28
|
+
* FROZEN: production ledgers already carry this value on historical rows, and
|
|
29
|
+
* the legacy compatibility contract forbids changing it. It is a display label
|
|
30
|
+
* only — no code may branch on a slot name.
|
|
31
|
+
*/
|
|
32
|
+
exports.MANUAL_CANDIDATE_SEARCH_SLOT_NAME = '审核群重抓';
|
|
33
|
+
/**
|
|
34
|
+
* The delivery-template wiring a target must declare for manual work. The
|
|
35
|
+
* field name and placeholder VALUE are the frozen production config contract
|
|
36
|
+
* (deployed delivery templates declare them); this module only reads them.
|
|
37
|
+
*/
|
|
38
|
+
const DELIVERY_CORRELATION_FIELD = 'refetch_request_id';
|
|
39
|
+
const DELIVERY_CORRELATION_PLACEHOLDER = '{{refetchRequestId}}';
|
|
40
|
+
class ManualJobAdmission {
|
|
41
|
+
deps;
|
|
42
|
+
constructor(deps) {
|
|
43
|
+
this.deps = deps;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve, admit and report one consumer-initiated candidate search.
|
|
47
|
+
*
|
|
48
|
+
* Idempotent by `idempotencyKey`: the first call creates the durable slot,
|
|
49
|
+
* every later call (from either entry point) returns the same `slotId`.
|
|
50
|
+
* Throws `ProtocolRequestError` so every adapter answers in the protocol's
|
|
51
|
+
* error shape while the legacy adapter keeps its historic message strings.
|
|
52
|
+
*/
|
|
53
|
+
admit(request) {
|
|
54
|
+
const key = (request.idempotencyKey ?? '').trim();
|
|
55
|
+
if (!key) {
|
|
56
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
57
|
+
message: 'idempotency_key must be a non-empty string',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const legacy = request.targetId !== undefined;
|
|
61
|
+
const config = this.deps.config();
|
|
62
|
+
this.assertAccount(config, request.account);
|
|
63
|
+
const admission = this.resolveTarget(config, request.targetId, request.targetSelector);
|
|
64
|
+
const { plan, target } = admission;
|
|
65
|
+
this.assertParamsApplyToTarget(target, request.params);
|
|
66
|
+
const derivedSlotId = `${plan.id}@manual-${key.toLowerCase()}`;
|
|
67
|
+
const existing = this.resolveExisting(plan, admission.targetId, key, derivedSlotId, legacy);
|
|
68
|
+
const slotId = existing ? existing.id : derivedSlotId;
|
|
69
|
+
if (existing)
|
|
70
|
+
this.assertSameParams(existing, request.params, legacy);
|
|
71
|
+
const context = this.buildContext(plan, slotId, key, request.correlationId, request.params);
|
|
72
|
+
const prepared = this.deps.coordinator.prepare(context, plan, [target]);
|
|
73
|
+
if (prepared.alreadyCompleted) {
|
|
74
|
+
return {
|
|
75
|
+
slotId: prepared.slotRec.id,
|
|
76
|
+
planId: plan.id,
|
|
77
|
+
targetId: admission.targetId,
|
|
78
|
+
disposition: 'already_completed',
|
|
79
|
+
reused: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const started = this.deps.admit(plan.id, context, admission.targetId);
|
|
83
|
+
return {
|
|
84
|
+
slotId: context.slotId,
|
|
85
|
+
planId: plan.id,
|
|
86
|
+
targetId: admission.targetId,
|
|
87
|
+
disposition: started ? 'accepted' : 'queued',
|
|
88
|
+
reused: existing !== null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `params.source.account` names the Pixiv resource identity. One process owns
|
|
93
|
+
* exactly one credential profile, so the only satisfiable value is the
|
|
94
|
+
* configured one; anything else is refused rather than silently downgraded to
|
|
95
|
+
* a different account.
|
|
96
|
+
*/
|
|
97
|
+
assertAccount(config, requested) {
|
|
98
|
+
if (requested === undefined)
|
|
99
|
+
return;
|
|
100
|
+
const configured = (config.pixiv?.accountId ?? '').trim() || 'default';
|
|
101
|
+
const wanted = requested.trim() || 'default';
|
|
102
|
+
if (wanted !== configured) {
|
|
103
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
104
|
+
message: `account '${wanted}' is not available in this deployment`,
|
|
105
|
+
detail: { reason: 'account_unavailable', requested: wanted, configured },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Legacy: the target comes from the URL and must resolve to exactly one
|
|
111
|
+
* enabled plan (the historic `unknown target` / `ambiguous target` rules).
|
|
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.
|
|
117
|
+
*/
|
|
118
|
+
resolveTarget(config, targetId, targetSelector) {
|
|
119
|
+
const explicit = targetId ?? targetSelector;
|
|
120
|
+
if (explicit !== undefined) {
|
|
121
|
+
const plans = (config.schedules ?? []).filter((plan) => plan.enabled !== false &&
|
|
122
|
+
(0, schedules_1.selectScheduleTargets)(config.targets, plan).some((target) => target.id === explicit));
|
|
123
|
+
if (plans.length === 0) {
|
|
124
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
125
|
+
message: 'unknown target',
|
|
126
|
+
detail: { reason: 'unknown_target', target_id: explicit },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (plans.length !== 1) {
|
|
130
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
131
|
+
message: 'ambiguous target',
|
|
132
|
+
detail: { reason: 'ambiguous_target', target_id: explicit },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const plan = plans[0];
|
|
136
|
+
const target = (0, schedules_1.selectScheduleTargets)(config.targets, plan).find((item) => item.id === explicit && Boolean(item.id));
|
|
137
|
+
if (!target || !target.id) {
|
|
138
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
139
|
+
message: 'unknown target',
|
|
140
|
+
detail: { reason: 'unknown_target', target_id: explicit },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
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);
|
|
146
|
+
return { plan, target, targetId: target.id };
|
|
147
|
+
}
|
|
148
|
+
const candidates = [];
|
|
149
|
+
for (const plan of config.schedules ?? []) {
|
|
150
|
+
if (plan.enabled === false)
|
|
151
|
+
continue;
|
|
152
|
+
for (const target of (0, schedules_1.selectScheduleTargets)(config.targets, plan)) {
|
|
153
|
+
if (!target.id)
|
|
154
|
+
continue;
|
|
155
|
+
if (targetServesManualCandidateSearch(config, target)) {
|
|
156
|
+
candidates.push({ plan, target, targetId: target.id });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (candidates.length === 0) {
|
|
161
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
162
|
+
message: 'no candidate search target is configured',
|
|
163
|
+
detail: { reason: 'no_eligible_target' },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (candidates.length > 1) {
|
|
167
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
168
|
+
message: 'more than one candidate search target is configured',
|
|
169
|
+
detail: {
|
|
170
|
+
reason: 'ambiguous_target',
|
|
171
|
+
targets: candidates.map((candidate) => candidate.targetId),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return candidates[0];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Idempotent identity resolution. The key is the durable identity; the
|
|
179
|
+
* plan-derived slot id is only the legacy naming convention. A key that
|
|
180
|
+
* already belongs to different work is a conflict, never a second slot.
|
|
181
|
+
*/
|
|
182
|
+
resolveExisting(plan, targetId, key, derivedSlotId, legacy) {
|
|
183
|
+
const byKey = this.deps.database.slots.findManualSlotByKey(key);
|
|
184
|
+
if (byKey) {
|
|
185
|
+
this.assertSameWork(byKey, plan, targetId, legacy);
|
|
186
|
+
return byKey;
|
|
187
|
+
}
|
|
188
|
+
const byId = this.deps.database.slots.getSlot(derivedSlotId);
|
|
189
|
+
if (!byId)
|
|
190
|
+
return null;
|
|
191
|
+
if (byId.manualRequestId && byId.manualRequestId !== key) {
|
|
192
|
+
throw this.conflict(legacy, `idempotency key '${key}' collides with an existing work item`, { reason: 'idempotency_conflict', slot_id: byId.id });
|
|
193
|
+
}
|
|
194
|
+
this.assertSameWork(byId, plan, targetId, legacy);
|
|
195
|
+
return byId;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* §3: reusing an idempotency key with DIFFERENT params is a conflict, never a
|
|
199
|
+
* silent second meaning for the same job. The comparison is skipped when
|
|
200
|
+
* either side carries no retrieval view (the legacy adapter never sets one),
|
|
201
|
+
* so a legacy replay of a generic job stays idempotent instead of conflicting.
|
|
202
|
+
*/
|
|
203
|
+
assertSameParams(slot, params, legacy) {
|
|
204
|
+
if (!params || !slot.paramsJson)
|
|
205
|
+
return;
|
|
206
|
+
if (slot.paramsJson === JSON.stringify(params))
|
|
207
|
+
return;
|
|
208
|
+
throw this.conflict(legacy, 'idempotency key was first used with different params', {
|
|
209
|
+
reason: 'idempotency_conflict',
|
|
210
|
+
slot_id: slot.id,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** A key may not be reused for a different plan/target than it first created. */
|
|
214
|
+
assertSameWork(slot, plan, targetId, legacy) {
|
|
215
|
+
const sameTarget = slot.targetIds.length === 1 && slot.targetIds[0] === targetId && slot.scheduleId === plan.id;
|
|
216
|
+
if (sameTarget)
|
|
217
|
+
return;
|
|
218
|
+
if (!legacy) {
|
|
219
|
+
throw this.conflict(legacy, 'idempotency key already belongs to another work item', {
|
|
220
|
+
reason: 'idempotency_conflict',
|
|
221
|
+
slot_id: slot.id,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
225
|
+
message: 'ambiguous target',
|
|
226
|
+
detail: { reason: 'ambiguous_target', slot_id: slot.id },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
conflict(legacy, message, detail) {
|
|
230
|
+
if (legacy) {
|
|
231
|
+
return new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
232
|
+
message: 'ambiguous target',
|
|
233
|
+
detail: { ...detail, reason: 'ambiguous_target' },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return new ProtocolErrors_1.ProtocolRequestError('idempotency_conflict', 409, { message, detail });
|
|
237
|
+
}
|
|
238
|
+
/** Legacy messages are preserved verbatim: deployed clients match on them. */
|
|
239
|
+
assertManualWiring(config, target, targetId, legacy) {
|
|
240
|
+
const deliveryName = (0, targetRoutes_1.primaryDeliveryName)(target);
|
|
241
|
+
const delivery = deliveryName ? config.delivery?.targets?.[deliveryName] : undefined;
|
|
242
|
+
if (delivery?.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim()) {
|
|
243
|
+
throw new ProtocolErrors_1.ProtocolRequestError('internal_error', 500, {
|
|
244
|
+
message: legacy
|
|
245
|
+
? 'refetch outcome endpoint not configured'
|
|
246
|
+
: 'candidate search delivery outcome is not configured',
|
|
247
|
+
detail: { reason: 'delivery_outcome_not_configured', target_id: targetId },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const field = target.delivery?.fields?.[DELIVERY_CORRELATION_FIELD] ??
|
|
251
|
+
delivery.fields?.[DELIVERY_CORRELATION_FIELD];
|
|
252
|
+
if (field !== DELIVERY_CORRELATION_PLACEHOLDER) {
|
|
253
|
+
throw new ProtocolErrors_1.ProtocolRequestError('internal_error', 500, {
|
|
254
|
+
message: legacy
|
|
255
|
+
? 'refetch_request_id delivery field not configured'
|
|
256
|
+
: 'candidate search delivery correlation field is not configured',
|
|
257
|
+
detail: { reason: 'delivery_correlation_field_not_configured', target_id: targetId },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* `constraints.work_types` restricts the work type this job accepts. The
|
|
263
|
+
* producer knows the resolved target's own type, so a request that asks for a
|
|
264
|
+
* type the target does not produce is refused instead of silently widened.
|
|
265
|
+
*/
|
|
266
|
+
assertParamsApplyToTarget(target, params) {
|
|
267
|
+
const workTypes = params?.constraints?.work_types;
|
|
268
|
+
if (!workTypes || workTypes.length === 0)
|
|
269
|
+
return;
|
|
270
|
+
if (!workTypes.includes(target.type)) {
|
|
271
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
272
|
+
message: `work_types ${JSON.stringify(workTypes)} does not include the configured target work type`,
|
|
273
|
+
detail: { reason: 'work_type_not_available', target_type: target.type },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Build the occurrence this work item occupies — the same shape as before. */
|
|
278
|
+
buildContext(plan, slotId, key, correlationId, params) {
|
|
279
|
+
const now = new Date();
|
|
280
|
+
const timezone = plan.timezone ?? 'UTC';
|
|
281
|
+
const date = new Intl.DateTimeFormat('en-CA', {
|
|
282
|
+
timeZone: timezone,
|
|
283
|
+
year: 'numeric',
|
|
284
|
+
month: '2-digit',
|
|
285
|
+
day: '2-digit',
|
|
286
|
+
}).format(now);
|
|
287
|
+
return {
|
|
288
|
+
slotId,
|
|
289
|
+
scheduleId: plan.id,
|
|
290
|
+
occurrenceAt: now.getTime(),
|
|
291
|
+
occurrenceDate: date,
|
|
292
|
+
occurrenceLabel: 'manual',
|
|
293
|
+
timezone,
|
|
294
|
+
triggerSource: 'manual',
|
|
295
|
+
slotName: exports.MANUAL_CANDIDATE_SEARCH_SLOT_NAME,
|
|
296
|
+
slotDate: date,
|
|
297
|
+
manualRequestId: key,
|
|
298
|
+
correlationId: correlationId || undefined,
|
|
299
|
+
paramsJson: params ? JSON.stringify(params) : undefined,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
exports.ManualJobAdmission = ManualJobAdmission;
|
|
304
|
+
/** Exported for tests/diagnostics: does this target serve manual candidate search? */
|
|
305
|
+
function targetServesManualCandidateSearch(config, target) {
|
|
306
|
+
const deliveryName = (0, targetRoutes_1.primaryDeliveryName)(target);
|
|
307
|
+
const delivery = deliveryName ? config.delivery?.targets?.[deliveryName] : undefined;
|
|
308
|
+
if (!deliveryName || !delivery)
|
|
309
|
+
return false;
|
|
310
|
+
if (delivery.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim())
|
|
311
|
+
return false;
|
|
312
|
+
const field = target.delivery?.fields?.[DELIVERY_CORRELATION_FIELD] ??
|
|
313
|
+
delivery.fields?.[DELIVERY_CORRELATION_FIELD];
|
|
314
|
+
return field === DELIVERY_CORRELATION_PLACEHOLDER;
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=ManualJobAdmission.js.map
|