pixivflow 3.2.0 → 3.3.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 +76 -0
- package/dist/scheduler/CandidateSearchParams.js +94 -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 +298 -0
- package/dist/scheduler/JobFacade.js +622 -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 +126 -0
- package/dist/scheduler/ManualJobAdmission.js +310 -0
- package/dist/scheduler/ManualJobService.d.ts +57 -0
- package/dist/scheduler/ManualJobService.js +91 -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,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,126 @@
|
|
|
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 resolves the target
|
|
34
|
+
* from configuration instead, optionally narrowed by `account`.
|
|
35
|
+
*/
|
|
36
|
+
export interface CandidateSearchJobRequest {
|
|
37
|
+
/** Consumer idempotency key; becomes the durable `manual_request_id`. */
|
|
38
|
+
idempotencyKey: string;
|
|
39
|
+
/** Opaque caller correlation: stored, echoed back, never interpreted. */
|
|
40
|
+
correlationId?: string;
|
|
41
|
+
/** Legacy adapter only: target id taken from the request path. */
|
|
42
|
+
targetId?: string;
|
|
43
|
+
/** Generic adapter only: `params.source.account` resource identity. */
|
|
44
|
+
account?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Generic adapter only: the requested retrieval view (§6). Persisted with the
|
|
47
|
+
* occurrence-scoped slot so a resumed worker re-applies it; the legacy adapter
|
|
48
|
+
* never sets it, so a refetch keeps running the plan exactly as configured.
|
|
49
|
+
*/
|
|
50
|
+
params?: CandidateSearchParams;
|
|
51
|
+
}
|
|
52
|
+
/** What happened to the request, independent of which adapter asked. */
|
|
53
|
+
export type ManualJobDisposition = 'accepted' | 'queued' | 'already_completed';
|
|
54
|
+
export interface CandidateSearchJobResult {
|
|
55
|
+
/** The durable identity of the work item (`job_id`). */
|
|
56
|
+
slotId: string;
|
|
57
|
+
planId: string;
|
|
58
|
+
targetId: string;
|
|
59
|
+
disposition: ManualJobDisposition;
|
|
60
|
+
/** True when an existing slot owned this key (an idempotent replay). */
|
|
61
|
+
reused: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface ManualJobAdmissionDeps {
|
|
64
|
+
database: Database;
|
|
65
|
+
coordinator: SlotCoordinator;
|
|
66
|
+
/** The live config snapshot (the trigger server always reads the newest). */
|
|
67
|
+
config(): StandaloneConfig;
|
|
68
|
+
/** Hand the prepared occurrence to execution; returns false when queued. */
|
|
69
|
+
admit(planId: string, context: SlotContext, targetId: string): boolean;
|
|
70
|
+
}
|
|
71
|
+
export declare class ManualJobAdmission {
|
|
72
|
+
private readonly deps;
|
|
73
|
+
constructor(deps: ManualJobAdmissionDeps);
|
|
74
|
+
/**
|
|
75
|
+
* Resolve, admit and report one consumer-initiated candidate search.
|
|
76
|
+
*
|
|
77
|
+
* Idempotent by `idempotencyKey`: the first call creates the durable slot,
|
|
78
|
+
* every later call (from either entry point) returns the same `slotId`.
|
|
79
|
+
* Throws `ProtocolRequestError` so every adapter answers in the protocol's
|
|
80
|
+
* error shape while the legacy adapter keeps its historic message strings.
|
|
81
|
+
*/
|
|
82
|
+
admit(request: CandidateSearchJobRequest): CandidateSearchJobResult;
|
|
83
|
+
/**
|
|
84
|
+
* `params.source.account` names the Pixiv resource identity. One process owns
|
|
85
|
+
* exactly one credential profile, so the only satisfiable value is the
|
|
86
|
+
* configured one; anything else is refused rather than silently downgraded to
|
|
87
|
+
* a different account.
|
|
88
|
+
*/
|
|
89
|
+
private assertAccount;
|
|
90
|
+
/**
|
|
91
|
+
* Legacy: the target comes from the URL and must resolve to exactly one
|
|
92
|
+
* enabled plan (the historic `unknown target` / `ambiguous target` rules).
|
|
93
|
+
* Generic: the target is discovered from configuration — every enabled plan's
|
|
94
|
+
* selected target that actually wires manual candidate-search delivery.
|
|
95
|
+
*/
|
|
96
|
+
private resolveTarget;
|
|
97
|
+
/**
|
|
98
|
+
* Idempotent identity resolution. The key is the durable identity; the
|
|
99
|
+
* plan-derived slot id is only the legacy naming convention. A key that
|
|
100
|
+
* already belongs to different work is a conflict, never a second slot.
|
|
101
|
+
*/
|
|
102
|
+
private resolveExisting;
|
|
103
|
+
/**
|
|
104
|
+
* §3: reusing an idempotency key with DIFFERENT params is a conflict, never a
|
|
105
|
+
* silent second meaning for the same job. The comparison is skipped when
|
|
106
|
+
* either side carries no retrieval view (the legacy adapter never sets one),
|
|
107
|
+
* so a legacy replay of a generic job stays idempotent instead of conflicting.
|
|
108
|
+
*/
|
|
109
|
+
private assertSameParams;
|
|
110
|
+
/** A key may not be reused for a different plan/target than it first created. */
|
|
111
|
+
private assertSameWork;
|
|
112
|
+
private conflict;
|
|
113
|
+
/** Legacy messages are preserved verbatim: deployed clients match on them. */
|
|
114
|
+
private assertManualWiring;
|
|
115
|
+
/**
|
|
116
|
+
* `constraints.work_types` restricts the work type this job accepts. The
|
|
117
|
+
* producer knows the resolved target's own type, so a request that asks for a
|
|
118
|
+
* type the target does not produce is refused instead of silently widened.
|
|
119
|
+
*/
|
|
120
|
+
private assertParamsApplyToTarget;
|
|
121
|
+
/** Build the occurrence this work item occupies — the same shape as before. */
|
|
122
|
+
private buildContext;
|
|
123
|
+
}
|
|
124
|
+
/** Exported for tests/diagnostics: does this target serve manual candidate search? */
|
|
125
|
+
export declare function targetServesManualCandidateSearch(config: StandaloneConfig, target: TargetConfig): boolean;
|
|
126
|
+
//# sourceMappingURL=ManualJobAdmission.d.ts.map
|
|
@@ -0,0 +1,310 @@
|
|
|
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);
|
|
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: the target is discovered from configuration — every enabled plan's
|
|
113
|
+
* selected target that actually wires manual candidate-search delivery.
|
|
114
|
+
*/
|
|
115
|
+
resolveTarget(config, targetId) {
|
|
116
|
+
if (targetId !== undefined) {
|
|
117
|
+
const plans = (config.schedules ?? []).filter((plan) => plan.enabled !== false &&
|
|
118
|
+
(0, schedules_1.selectScheduleTargets)(config.targets, plan).some((target) => target.id === targetId));
|
|
119
|
+
if (plans.length === 0) {
|
|
120
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
121
|
+
message: 'unknown target',
|
|
122
|
+
detail: { reason: 'unknown_target', target_id: targetId },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (plans.length !== 1) {
|
|
126
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
127
|
+
message: 'ambiguous target',
|
|
128
|
+
detail: { reason: 'ambiguous_target', target_id: targetId },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const plan = plans[0];
|
|
132
|
+
const target = (0, schedules_1.selectScheduleTargets)(config.targets, plan).find((item) => item.id === targetId && Boolean(item.id));
|
|
133
|
+
if (!target || !target.id) {
|
|
134
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, {
|
|
135
|
+
message: 'unknown target',
|
|
136
|
+
detail: { reason: 'unknown_target', target_id: targetId },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
this.assertManualWiring(config, target, target.id, true);
|
|
140
|
+
return { plan, target, targetId: target.id };
|
|
141
|
+
}
|
|
142
|
+
const candidates = [];
|
|
143
|
+
for (const plan of config.schedules ?? []) {
|
|
144
|
+
if (plan.enabled === false)
|
|
145
|
+
continue;
|
|
146
|
+
for (const target of (0, schedules_1.selectScheduleTargets)(config.targets, plan)) {
|
|
147
|
+
if (!target.id)
|
|
148
|
+
continue;
|
|
149
|
+
if (targetServesManualCandidateSearch(config, target)) {
|
|
150
|
+
candidates.push({ plan, target, targetId: target.id });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (candidates.length === 0) {
|
|
155
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
156
|
+
message: 'no candidate search target is configured',
|
|
157
|
+
detail: { reason: 'no_eligible_target' },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (candidates.length > 1) {
|
|
161
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
162
|
+
message: 'more than one candidate search target is configured',
|
|
163
|
+
detail: {
|
|
164
|
+
reason: 'ambiguous_target',
|
|
165
|
+
targets: candidates.map((candidate) => candidate.targetId),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return candidates[0];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Idempotent identity resolution. The key is the durable identity; the
|
|
173
|
+
* plan-derived slot id is only the legacy naming convention. A key that
|
|
174
|
+
* already belongs to different work is a conflict, never a second slot.
|
|
175
|
+
*/
|
|
176
|
+
resolveExisting(plan, targetId, key, derivedSlotId, legacy) {
|
|
177
|
+
const byKey = this.deps.database.slots.findManualSlotByKey(key);
|
|
178
|
+
if (byKey) {
|
|
179
|
+
this.assertSameWork(byKey, plan, targetId, legacy);
|
|
180
|
+
return byKey;
|
|
181
|
+
}
|
|
182
|
+
const byId = this.deps.database.slots.getSlot(derivedSlotId);
|
|
183
|
+
if (!byId)
|
|
184
|
+
return null;
|
|
185
|
+
if (byId.manualRequestId && byId.manualRequestId !== key) {
|
|
186
|
+
throw this.conflict(legacy, `idempotency key '${key}' collides with an existing work item`, { reason: 'idempotency_conflict', slot_id: byId.id });
|
|
187
|
+
}
|
|
188
|
+
this.assertSameWork(byId, plan, targetId, legacy);
|
|
189
|
+
return byId;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* §3: reusing an idempotency key with DIFFERENT params is a conflict, never a
|
|
193
|
+
* silent second meaning for the same job. The comparison is skipped when
|
|
194
|
+
* either side carries no retrieval view (the legacy adapter never sets one),
|
|
195
|
+
* so a legacy replay of a generic job stays idempotent instead of conflicting.
|
|
196
|
+
*/
|
|
197
|
+
assertSameParams(slot, params, legacy) {
|
|
198
|
+
if (!params || !slot.paramsJson)
|
|
199
|
+
return;
|
|
200
|
+
if (slot.paramsJson === JSON.stringify(params))
|
|
201
|
+
return;
|
|
202
|
+
throw this.conflict(legacy, 'idempotency key was first used with different params', {
|
|
203
|
+
reason: 'idempotency_conflict',
|
|
204
|
+
slot_id: slot.id,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** A key may not be reused for a different plan/target than it first created. */
|
|
208
|
+
assertSameWork(slot, plan, targetId, legacy) {
|
|
209
|
+
const sameTarget = slot.targetIds.length === 1 && slot.targetIds[0] === targetId && slot.scheduleId === plan.id;
|
|
210
|
+
if (sameTarget)
|
|
211
|
+
return;
|
|
212
|
+
if (!legacy) {
|
|
213
|
+
throw this.conflict(legacy, 'idempotency key already belongs to another work item', {
|
|
214
|
+
reason: 'idempotency_conflict',
|
|
215
|
+
slot_id: slot.id,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
219
|
+
message: 'ambiguous target',
|
|
220
|
+
detail: { reason: 'ambiguous_target', slot_id: slot.id },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
conflict(legacy, message, detail) {
|
|
224
|
+
if (legacy) {
|
|
225
|
+
return new ProtocolErrors_1.ProtocolRequestError('invalid_params', 409, {
|
|
226
|
+
message: 'ambiguous target',
|
|
227
|
+
detail: { ...detail, reason: 'ambiguous_target' },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return new ProtocolErrors_1.ProtocolRequestError('idempotency_conflict', 409, { message, detail });
|
|
231
|
+
}
|
|
232
|
+
/** Legacy messages are preserved verbatim: deployed clients match on them. */
|
|
233
|
+
assertManualWiring(config, target, targetId, legacy) {
|
|
234
|
+
const deliveryName = (0, targetRoutes_1.primaryDeliveryName)(target);
|
|
235
|
+
const delivery = deliveryName ? config.delivery?.targets?.[deliveryName] : undefined;
|
|
236
|
+
if (delivery?.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim()) {
|
|
237
|
+
throw new ProtocolErrors_1.ProtocolRequestError('internal_error', 500, {
|
|
238
|
+
message: legacy
|
|
239
|
+
? 'refetch outcome endpoint not configured'
|
|
240
|
+
: 'candidate search delivery outcome is not configured',
|
|
241
|
+
detail: { reason: 'delivery_outcome_not_configured', target_id: targetId },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
const field = target.delivery?.fields?.[DELIVERY_CORRELATION_FIELD] ??
|
|
245
|
+
delivery.fields?.[DELIVERY_CORRELATION_FIELD];
|
|
246
|
+
if (field !== DELIVERY_CORRELATION_PLACEHOLDER) {
|
|
247
|
+
throw new ProtocolErrors_1.ProtocolRequestError('internal_error', 500, {
|
|
248
|
+
message: legacy
|
|
249
|
+
? 'refetch_request_id delivery field not configured'
|
|
250
|
+
: 'candidate search delivery correlation field is not configured',
|
|
251
|
+
detail: { reason: 'delivery_correlation_field_not_configured', target_id: targetId },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* `constraints.work_types` restricts the work type this job accepts. The
|
|
257
|
+
* producer knows the resolved target's own type, so a request that asks for a
|
|
258
|
+
* type the target does not produce is refused instead of silently widened.
|
|
259
|
+
*/
|
|
260
|
+
assertParamsApplyToTarget(target, params) {
|
|
261
|
+
const workTypes = params?.constraints?.work_types;
|
|
262
|
+
if (!workTypes || workTypes.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
if (!workTypes.includes(target.type)) {
|
|
265
|
+
throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
|
|
266
|
+
message: `work_types ${JSON.stringify(workTypes)} does not include the configured target work type`,
|
|
267
|
+
detail: { reason: 'work_type_not_available', target_type: target.type },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Build the occurrence this work item occupies — the same shape as before. */
|
|
272
|
+
buildContext(plan, slotId, key, correlationId, params) {
|
|
273
|
+
const now = new Date();
|
|
274
|
+
const timezone = plan.timezone ?? 'UTC';
|
|
275
|
+
const date = new Intl.DateTimeFormat('en-CA', {
|
|
276
|
+
timeZone: timezone,
|
|
277
|
+
year: 'numeric',
|
|
278
|
+
month: '2-digit',
|
|
279
|
+
day: '2-digit',
|
|
280
|
+
}).format(now);
|
|
281
|
+
return {
|
|
282
|
+
slotId,
|
|
283
|
+
scheduleId: plan.id,
|
|
284
|
+
occurrenceAt: now.getTime(),
|
|
285
|
+
occurrenceDate: date,
|
|
286
|
+
occurrenceLabel: 'manual',
|
|
287
|
+
timezone,
|
|
288
|
+
triggerSource: 'manual',
|
|
289
|
+
slotName: exports.MANUAL_CANDIDATE_SEARCH_SLOT_NAME,
|
|
290
|
+
slotDate: date,
|
|
291
|
+
manualRequestId: key,
|
|
292
|
+
correlationId: correlationId || undefined,
|
|
293
|
+
paramsJson: params ? JSON.stringify(params) : undefined,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
exports.ManualJobAdmission = ManualJobAdmission;
|
|
298
|
+
/** Exported for tests/diagnostics: does this target serve manual candidate search? */
|
|
299
|
+
function targetServesManualCandidateSearch(config, target) {
|
|
300
|
+
const deliveryName = (0, targetRoutes_1.primaryDeliveryName)(target);
|
|
301
|
+
const delivery = deliveryName ? config.delivery?.targets?.[deliveryName] : undefined;
|
|
302
|
+
if (!deliveryName || !delivery)
|
|
303
|
+
return false;
|
|
304
|
+
if (delivery.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim())
|
|
305
|
+
return false;
|
|
306
|
+
const field = target.delivery?.fields?.[DELIVERY_CORRELATION_FIELD] ??
|
|
307
|
+
delivery.fields?.[DELIVERY_CORRELATION_FIELD];
|
|
308
|
+
return field === DELIVERY_CORRELATION_PLACEHOLDER;
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=ManualJobAdmission.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic job surface: `$defs/Task` in, `$defs/Job` out.
|
|
3
|
+
*
|
|
4
|
+
* This is an ADAPTER, not a second execution system. Everything it does is a
|
|
5
|
+
* translation on top of the one durable ledger:
|
|
6
|
+
* - admission -> `ManualJobAdmission` (shared with the legacy refetch path)
|
|
7
|
+
* - read model -> `JobView` (`buildJobProjection` + `buildProtocolJob`)
|
|
8
|
+
* - cancellation -> `cancelConsumerJob` (one transaction)
|
|
9
|
+
* - events/ack -> `JobEventStream` (projection of `delivery_events`)
|
|
10
|
+
*
|
|
11
|
+
* Identity is the consumer's idempotency key, which IS the durable
|
|
12
|
+
* `manual_request_id`; `job_id` IS the slot id. A replay from either entry
|
|
13
|
+
* point therefore converges on the same job, and no second slot or delivery can
|
|
14
|
+
* be created for the same key.
|
|
15
|
+
*/
|
|
16
|
+
import type { StandaloneConfig } from '../config';
|
|
17
|
+
import type { Database } from '../storage/Database';
|
|
18
|
+
import { JobEventQuery } from './JobEventStream';
|
|
19
|
+
import { ProtocolAckResult, ProtocolCapabilities, ProtocolEventPage, ProtocolJob } from './JobFacade';
|
|
20
|
+
import { ManualJobAdmission } from './ManualJobAdmission';
|
|
21
|
+
import type { JobHandlers } from './ScheduleTriggerServer';
|
|
22
|
+
export interface ManualJobServiceDeps {
|
|
23
|
+
database: Database;
|
|
24
|
+
/** The live config snapshot (the trigger server always reads the newest). */
|
|
25
|
+
config(): StandaloneConfig;
|
|
26
|
+
/** The shared admission path — the same instance the legacy shim uses. */
|
|
27
|
+
admission: ManualJobAdmission;
|
|
28
|
+
/** Injected clock, for tests. */
|
|
29
|
+
now?(): number;
|
|
30
|
+
}
|
|
31
|
+
export declare class ManualJobService implements JobHandlers {
|
|
32
|
+
private readonly deps;
|
|
33
|
+
private readonly events;
|
|
34
|
+
constructor(deps: ManualJobServiceDeps);
|
|
35
|
+
private now;
|
|
36
|
+
private viewDeps;
|
|
37
|
+
capabilities(): ProtocolCapabilities;
|
|
38
|
+
/** Create or replay the one work item this idempotency key owns. */
|
|
39
|
+
submitJob(body: unknown): {
|
|
40
|
+
job: ProtocolJob;
|
|
41
|
+
replayed: boolean;
|
|
42
|
+
};
|
|
43
|
+
jobStatus(jobId: string): ProtocolJob | null;
|
|
44
|
+
jobsByIdempotencyKey(idempotencyKey: string): ProtocolJob[];
|
|
45
|
+
/** `GET /jobs/:jobId/events` — the durable stream, reconciled from the ledger. */
|
|
46
|
+
jobEvents(jobId: string, query: JobEventQuery): ProtocolEventPage;
|
|
47
|
+
/** `POST /jobs/:jobId/events/ack` — O(1) durable cursor write, never a job mutation. */
|
|
48
|
+
ackJobEvents(jobId: string, body: unknown): ProtocolAckResult;
|
|
49
|
+
/**
|
|
50
|
+
* Idempotent: cancelling an already-terminal job reports its current
|
|
51
|
+
* projection instead of failing. `cancelConsumerJob` owns the one transaction
|
|
52
|
+
* that terminalises the work and stops further deliveries.
|
|
53
|
+
*/
|
|
54
|
+
cancelJob(jobId: string): ProtocolJob;
|
|
55
|
+
private viewBySlotId;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=ManualJobService.d.ts.map
|