pixivflow 3.1.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.
Files changed (61) hide show
  1. package/README.md +26 -1
  2. package/dist/commands/SchedulerCommand.js +31 -51
  3. package/dist/commands/scheduler-runtime.js +47 -7
  4. package/dist/config/defaults.d.ts +2 -0
  5. package/dist/config/defaults.js +6 -0
  6. package/dist/config/types.d.ts +20 -0
  7. package/dist/config/validation.js +11 -0
  8. package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
  9. package/dist/delivery/DeliveryDispatcher.js +14 -0
  10. package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
  11. package/dist/delivery/EventCallbackDelivery.js +89 -0
  12. package/dist/delivery/OutboxWorker.d.ts +11 -0
  13. package/dist/delivery/OutboxWorker.js +51 -1
  14. package/dist/package.json +1 -1
  15. package/dist/scheduler/CandidateSearchParams.d.ts +76 -0
  16. package/dist/scheduler/CandidateSearchParams.js +94 -0
  17. package/dist/scheduler/JobCancellation.d.ts +36 -0
  18. package/dist/scheduler/JobCancellation.js +73 -0
  19. package/dist/scheduler/JobEventStream.d.ts +94 -0
  20. package/dist/scheduler/JobEventStream.js +251 -0
  21. package/dist/scheduler/JobFacade.d.ts +298 -0
  22. package/dist/scheduler/JobFacade.js +622 -0
  23. package/dist/scheduler/JobProjection.d.ts +67 -0
  24. package/dist/scheduler/JobProjection.js +32 -0
  25. package/dist/scheduler/JobView.d.ts +34 -0
  26. package/dist/scheduler/JobView.js +82 -0
  27. package/dist/scheduler/ManualJobAdmission.d.ts +126 -0
  28. package/dist/scheduler/ManualJobAdmission.js +310 -0
  29. package/dist/scheduler/ManualJobService.d.ts +57 -0
  30. package/dist/scheduler/ManualJobService.js +91 -0
  31. package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
  32. package/dist/scheduler/ManualRefetchAdapter.js +41 -0
  33. package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
  34. package/dist/scheduler/MultiScheduleManager.js +68 -6
  35. package/dist/scheduler/ProtocolErrors.d.ts +68 -0
  36. package/dist/scheduler/ProtocolErrors.js +94 -0
  37. package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
  38. package/dist/scheduler/ScheduleTriggerServer.js +166 -1
  39. package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
  40. package/dist/scheduler/SlotBusinessStatus.js +6 -0
  41. package/dist/scheduler/SlotCoordinator.d.ts +49 -5
  42. package/dist/scheduler/SlotCoordinator.js +89 -22
  43. package/dist/scheduler/StallSweep.d.ts +65 -0
  44. package/dist/scheduler/StallSweep.js +105 -0
  45. package/dist/scheduler/TargetOutcome.d.ts +39 -1
  46. package/dist/scheduler/TargetOutcome.js +51 -1
  47. package/dist/scheduler/ledger-time.d.ts +23 -0
  48. package/dist/scheduler/ledger-time.js +37 -0
  49. package/dist/storage/DatabaseMigration.js +48 -0
  50. package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
  51. package/dist/storage/repositories/DeliveryRepository.js +13 -0
  52. package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
  53. package/dist/storage/repositories/OutboxRepository.js +142 -0
  54. package/dist/storage/repositories/SlotRepository.d.ts +34 -0
  55. package/dist/storage/repositories/SlotRepository.js +61 -2
  56. package/dist/version.js +1 -1
  57. package/dist/webui/package.json +1 -1
  58. package/examples/gateway/README.md +4 -0
  59. package/examples/onebot-adapter/README.md +139 -0
  60. package/examples/onebot-adapter/server.mjs +612 -0
  61. package/package.json +2 -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