pixivflow 2.19.5 → 2.20.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/dist/commands/SchedulerCommand.js +36 -0
- package/dist/commands/SchedulerRunOnceCommand.d.ts +2 -3
- package/dist/commands/SchedulerRunOnceCommand.js +2 -3
- package/dist/commands/scheduler-runtime.js +17 -1
- package/dist/config/types.d.ts +7 -0
- package/dist/config/validation.js +10 -0
- package/dist/delivery/DeliveryService.d.ts +21 -1
- package/dist/delivery/DeliveryService.js +2 -2
- package/dist/delivery/HttpMultipartDelivery.js +29 -8
- package/dist/delivery/OutboxWorker.d.ts +2 -0
- package/dist/delivery/OutboxWorker.js +3 -0
- package/dist/delivery/types.d.ts +18 -0
- package/dist/download/handlers/IllustrationTargetHandler.d.ts +0 -1
- package/dist/download/handlers/IllustrationTargetHandler.js +2 -13
- package/dist/download/handlers/NovelTargetHandler.js +2 -0
- package/dist/download/handlers/deliveryContext.d.ts +16 -0
- package/dist/download/handlers/deliveryContext.js +32 -0
- package/dist/notification/NotificationPolicy.d.ts +12 -0
- package/dist/notification/NotificationPolicy.js +86 -0
- package/dist/package.json +1 -1
- package/dist/scheduler/MultiScheduleManager.js +2 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +58 -1
- package/dist/scheduler/ScheduleTriggerServer.js +207 -40
- package/dist/scheduler/SlotCoordinator.d.ts +80 -2
- package/dist/scheduler/SlotCoordinator.js +145 -3
- package/dist/storage/DatabaseMigration.js +11 -0
- package/dist/storage/repositories/SlotRepository.d.ts +8 -0
- package/dist/storage/repositories/SlotRepository.js +6 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +1 -1
|
@@ -157,6 +157,42 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
157
157
|
};
|
|
158
158
|
},
|
|
159
159
|
drainOutbox: () => runtime.drainOutbox(),
|
|
160
|
+
refetch: async (targetId, requestId, correlationId) => {
|
|
161
|
+
const cfg = resolveConfig();
|
|
162
|
+
const plans = (cfg.schedules ?? []).filter((plan) => plan.enabled !== false && (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).some((target) => target.id === targetId));
|
|
163
|
+
if (plans.length === 0)
|
|
164
|
+
throw new Error('unknown target');
|
|
165
|
+
if (plans.length !== 1)
|
|
166
|
+
throw new Error('ambiguous target');
|
|
167
|
+
const plan = plans[0];
|
|
168
|
+
const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const date = new Intl.DateTimeFormat('en-CA', {
|
|
171
|
+
timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
172
|
+
}).format(now);
|
|
173
|
+
const slot = {
|
|
174
|
+
slotId: `${plan.id}@manual-${requestId.toLowerCase()}`,
|
|
175
|
+
scheduleId: plan.id,
|
|
176
|
+
occurrenceAt: now.getTime(),
|
|
177
|
+
occurrenceDate: date,
|
|
178
|
+
occurrenceLabel: 'manual',
|
|
179
|
+
timezone: plan.timezone ?? 'UTC',
|
|
180
|
+
triggerSource: 'manual',
|
|
181
|
+
slotName: '审核群重抓',
|
|
182
|
+
slotDate: date,
|
|
183
|
+
manualRequestId: requestId,
|
|
184
|
+
correlationId: correlationId || undefined,
|
|
185
|
+
};
|
|
186
|
+
const existing = runtime.database.slots.getSlot(slot.slotId);
|
|
187
|
+
if (existing && (existing.scheduleId !== plan.id || existing.targetIds.length !== 1 || existing.targetIds[0] !== targetId)) {
|
|
188
|
+
throw new Error('ambiguous target');
|
|
189
|
+
}
|
|
190
|
+
const prepared = coordinator.prepare(slot, plan, [target]);
|
|
191
|
+
if (prepared.alreadyCompleted)
|
|
192
|
+
return { slotId: slot.slotId, disposition: 'already_completed' };
|
|
193
|
+
const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
|
|
194
|
+
return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
|
|
195
|
+
},
|
|
160
196
|
status: (scheduleId) => {
|
|
161
197
|
const cfg = resolveConfig();
|
|
162
198
|
const plan = findPlan(cfg, scheduleId);
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Scheduler run-once command
|
|
3
3
|
*
|
|
4
4
|
* Runs every enabled schedule's download plan exactly once, then exits.
|
|
5
|
-
* This is the
|
|
6
|
-
* a
|
|
7
|
-
* so refetch needs a bounded one-shot invocation instead.
|
|
5
|
+
* This is the local CLI path. Remote review-group refetch uses the authenticated
|
|
6
|
+
* trigger server and a durable manual Slot so a sleeping worker can recover it.
|
|
8
7
|
*/
|
|
9
8
|
import { BaseCommand } from './Command';
|
|
10
9
|
import { CommandCategory } from './metadata';
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* Scheduler run-once command
|
|
4
4
|
*
|
|
5
5
|
* Runs every enabled schedule's download plan exactly once, then exits.
|
|
6
|
-
* This is the
|
|
7
|
-
* a
|
|
8
|
-
* so refetch needs a bounded one-shot invocation instead.
|
|
6
|
+
* This is the local CLI path. Remote review-group refetch uses the authenticated
|
|
7
|
+
* trigger server and a durable manual Slot so a sleeping worker can recover it.
|
|
9
8
|
*/
|
|
10
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
10
|
exports.SchedulerRunOnceCommand = void 0;
|
|
@@ -438,10 +438,26 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
438
438
|
if (!target.id)
|
|
439
439
|
return;
|
|
440
440
|
options.onTargetOutcome?.(target.id, outcome);
|
|
441
|
-
if (!scheduleSlot)
|
|
441
|
+
if (!scheduleSlot) {
|
|
442
|
+
// No durable slot (run-once CLI): nothing to converge or report.
|
|
442
443
|
return;
|
|
444
|
+
}
|
|
443
445
|
coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
|
|
444
446
|
notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
|
|
447
|
+
// A remote manual replacement ("重抓") must report its terminal verdict
|
|
448
|
+
// back to the reviewer. Only terminal outcomes are reported: a candidate
|
|
449
|
+
// the scan skipped is not a verdict, and a durable delivery intent
|
|
450
|
+
// ('delivery_pending' / later 'submitted') is reported through the
|
|
451
|
+
// replacement submission itself (the caller correlates on requestId).
|
|
452
|
+
const manualRequestId = scheduleSlot.manualRequestId;
|
|
453
|
+
if (manualRequestId) {
|
|
454
|
+
const terminal = outcome.kind === 'no_candidate' ||
|
|
455
|
+
outcome.kind === 'duplicate' ||
|
|
456
|
+
(outcome.kind === 'failed' && !outcome.retryable);
|
|
457
|
+
if (terminal) {
|
|
458
|
+
notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, manualRequestId, outcome);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
445
461
|
});
|
|
446
462
|
if (scheduleSlot) {
|
|
447
463
|
downloadManager.slotContext = {
|
package/dist/config/types.d.ts
CHANGED
|
@@ -517,6 +517,13 @@ export interface HttpMultipartDeliveryConfig {
|
|
|
517
517
|
url: string;
|
|
518
518
|
/** Optional JSON endpoint used for no-match operational notifications. */
|
|
519
519
|
notificationUrl?: string;
|
|
520
|
+
/**
|
|
521
|
+
* Optional JSON endpoint for remote manual replacement ("重抓") terminal
|
|
522
|
+
* verdicts (no_alternative / failed). Must be http(s); if unset, manual
|
|
523
|
+
* outcome reports are skipped (the work itself is still durable). Reuses
|
|
524
|
+
* `headers` for auth, so no extra credential is needed.
|
|
525
|
+
*/
|
|
526
|
+
refetchOutcomeUrl?: string;
|
|
520
527
|
method?: 'POST' | 'PUT';
|
|
521
528
|
/** 支持 ${ENV_NAME} 环境变量插值 */
|
|
522
529
|
headers?: Record<string, string>;
|
|
@@ -302,6 +302,16 @@ function validateConfig(config, location, databasePath) {
|
|
|
302
302
|
errors.push(`${prefix}.notificationUrl: Must be a valid HTTP or HTTPS URL`);
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
|
+
if (delivery.refetchOutcomeUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.refetchOutcomeUrl)) {
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(delivery.refetchOutcomeUrl);
|
|
308
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
309
|
+
throw new Error('unsupported protocol');
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
errors.push(`${prefix}.refetchOutcomeUrl: Must be a valid HTTP or HTTPS URL`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
305
315
|
if (delivery.readinessUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.readinessUrl)) {
|
|
306
316
|
try {
|
|
307
317
|
const url = new URL(delivery.readinessUrl);
|
|
@@ -19,6 +19,26 @@ export interface EnqueueResult {
|
|
|
19
19
|
/** True when this call created a brand new delivery row. */
|
|
20
20
|
created: boolean;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Machine-readable terminal verdict of a remote manual replacement ("重抓"),
|
|
24
|
+
* reported back to the requester (TelePost) through the durable outbox.
|
|
25
|
+
* Never contains credentials; ids are the opaque request UUID and Pixiv work ids.
|
|
26
|
+
*/
|
|
27
|
+
export interface RefetchOutcomePayload {
|
|
28
|
+
requestId: string;
|
|
29
|
+
/** 'no_alternative' | 'failed' (replacement success rides the submission). */
|
|
30
|
+
disposition: 'no_alternative' | 'failed';
|
|
31
|
+
reason?: string;
|
|
32
|
+
workId?: string;
|
|
33
|
+
/** Bounded candidate-scan bookkeeping for diagnostics (spec-compatible). */
|
|
34
|
+
scanned?: number;
|
|
35
|
+
skipped?: {
|
|
36
|
+
total: number;
|
|
37
|
+
duplicate: number;
|
|
38
|
+
invalid: number;
|
|
39
|
+
unavailable: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
22
42
|
export declare class DeliveryService {
|
|
23
43
|
private readonly database;
|
|
24
44
|
constructor(database: Database);
|
|
@@ -76,7 +96,7 @@ export declare class DeliveryService {
|
|
|
76
96
|
created: boolean;
|
|
77
97
|
};
|
|
78
98
|
/** Enqueue a durable notification (retried independently; never affects content). */
|
|
79
|
-
enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string): void;
|
|
99
|
+
enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string, refetchOutcome?: RefetchOutcomePayload): void;
|
|
80
100
|
private guardCell;
|
|
81
101
|
private contextFrom;
|
|
82
102
|
}
|
|
@@ -175,12 +175,12 @@ class DeliveryService {
|
|
|
175
175
|
return { deliveryId: row.id, created };
|
|
176
176
|
}
|
|
177
177
|
/** Enqueue a durable notification (retried independently; never affects content). */
|
|
178
|
-
enqueueNotification(deliveryTarget, text, idempotencyKey) {
|
|
178
|
+
enqueueNotification(deliveryTarget, text, idempotencyKey, refetchOutcome) {
|
|
179
179
|
this.database.outbox.enqueue({
|
|
180
180
|
kind: 'notification',
|
|
181
181
|
deliveryTarget,
|
|
182
182
|
idempotencyKey,
|
|
183
|
-
payload: { text },
|
|
183
|
+
payload: refetchOutcome ? { text, refetchOutcome } : { text },
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
186
|
guardCell(slotId, targetId, next) {
|
|
@@ -133,33 +133,54 @@ class HttpMultipartDelivery {
|
|
|
133
133
|
}
|
|
134
134
|
/** Single notification attempt; the outbox owns retries. */
|
|
135
135
|
async notifyOnce(request) {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
const outcome = request.refetchOutcome;
|
|
137
|
+
const url = (outcome ? this.config.refetchOutcomeUrl?.trim() : this.config.notificationUrl?.trim());
|
|
138
|
+
if (!url) {
|
|
139
|
+
throw new Error(outcome
|
|
140
|
+
? 'HTTP delivery refetchOutcomeUrl is not configured'
|
|
141
|
+
: 'HTTP delivery notificationUrl is not configured');
|
|
142
|
+
}
|
|
139
143
|
const headers = {
|
|
140
144
|
...this.resolveHeaders(this.config.headers ?? {}),
|
|
141
145
|
'Content-Type': 'application/json',
|
|
142
146
|
};
|
|
147
|
+
// Refetch verdicts are machine-readable JSON (the requester's review state
|
|
148
|
+
// machine consumes disposition, not prose). Plain notifications remain
|
|
149
|
+
// {text, idempotency_key}.
|
|
150
|
+
const body = outcome
|
|
151
|
+
? {
|
|
152
|
+
request_id: outcome.requestId,
|
|
153
|
+
disposition: outcome.disposition,
|
|
154
|
+
reason: outcome.reason,
|
|
155
|
+
work_id: outcome.workId,
|
|
156
|
+
scanned: outcome.scanned,
|
|
157
|
+
skipped: outcome.skipped,
|
|
158
|
+
}
|
|
159
|
+
: { text: request.text, idempotency_key: request.idempotencyKey };
|
|
143
160
|
const options = {
|
|
144
161
|
method: 'POST',
|
|
145
162
|
headers,
|
|
146
|
-
body: JSON.stringify(
|
|
163
|
+
body: JSON.stringify(body),
|
|
147
164
|
};
|
|
148
165
|
if (this.dispatcher)
|
|
149
166
|
options.dispatcher = this.dispatcher;
|
|
150
167
|
const response = await fetch(this.interpolateEnvironment(url), options);
|
|
151
168
|
const text = await response.text();
|
|
152
|
-
let
|
|
169
|
+
let parsed = text;
|
|
153
170
|
if (text) {
|
|
154
171
|
try {
|
|
155
|
-
|
|
172
|
+
parsed = JSON.parse(text);
|
|
156
173
|
}
|
|
157
174
|
catch { /* plain ok */ }
|
|
158
175
|
}
|
|
159
176
|
if (!response.ok)
|
|
160
177
|
throw new Error(`notification endpoint returned HTTP ${response.status}`);
|
|
161
|
-
logger_1.logger.info('HTTP delivery notification sent', {
|
|
162
|
-
|
|
178
|
+
logger_1.logger.info('HTTP delivery notification sent', {
|
|
179
|
+
url: (0, redact_1.redactUrl)(url),
|
|
180
|
+
status: response.status,
|
|
181
|
+
hasRefetchOutcome: Boolean(outcome),
|
|
182
|
+
});
|
|
183
|
+
return { status: response.status, body: parsed };
|
|
163
184
|
}
|
|
164
185
|
async attempt(request) {
|
|
165
186
|
const fields = this.resolveFields({ ...(this.config.fields ?? {}), ...(request.fields ?? {}) }, request);
|
|
@@ -29,6 +29,8 @@ export interface DeliveryPayload {
|
|
|
29
29
|
/** Notification side-effect payload. */
|
|
30
30
|
export interface NotificationPayload {
|
|
31
31
|
text: string;
|
|
32
|
+
/** Optional structured remote-manual-replacement verdict (refetch outcome). */
|
|
33
|
+
refetchOutcome?: unknown;
|
|
32
34
|
}
|
|
33
35
|
/** Exponential backoff with jitter, capped. */
|
|
34
36
|
export declare function backoffDelayMs(attempt: number, base: number, max: number): number;
|
|
@@ -211,6 +211,9 @@ class OutboxWorker {
|
|
|
211
211
|
await this.dispatcher.notify(row.deliveryTarget, {
|
|
212
212
|
text: payload.text,
|
|
213
213
|
idempotencyKey: row.idempotencyKey ?? row.id,
|
|
214
|
+
refetchOutcome: payload.refetchOutcome !== undefined
|
|
215
|
+
? payload.refetchOutcome
|
|
216
|
+
: undefined,
|
|
214
217
|
});
|
|
215
218
|
}
|
|
216
219
|
else {
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -89,6 +89,24 @@ export interface DeliveryResult {
|
|
|
89
89
|
export interface DeliveryNotificationRequest {
|
|
90
90
|
text: string;
|
|
91
91
|
idempotencyKey: string;
|
|
92
|
+
/**
|
|
93
|
+
* Optional structured remote-manual-replacement verdict. When present the
|
|
94
|
+
* delivery sends a JSON body to the target's `refetchOutcomeUrl` instead of
|
|
95
|
+
* a plain text notification to `notificationUrl` (auth reuses `headers`).
|
|
96
|
+
*/
|
|
97
|
+
refetchOutcome?: {
|
|
98
|
+
requestId: string;
|
|
99
|
+
disposition: 'no_alternative' | 'failed';
|
|
100
|
+
reason?: string;
|
|
101
|
+
workId?: string;
|
|
102
|
+
scanned?: number;
|
|
103
|
+
skipped?: {
|
|
104
|
+
total: number;
|
|
105
|
+
duplicate: number;
|
|
106
|
+
invalid: number;
|
|
107
|
+
unavailable: number;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
92
110
|
}
|
|
93
111
|
export interface DeliveryProvider {
|
|
94
112
|
deliver(request: DeliveryRequest): Promise<DeliveryResult>;
|
|
@@ -110,6 +110,5 @@ export declare class IllustrationTargetHandler {
|
|
|
110
110
|
* second concurrent worker lose this race instead of double-submitting.
|
|
111
111
|
*/
|
|
112
112
|
private recordArtifactOutcome;
|
|
113
|
-
private executionContextFields;
|
|
114
113
|
}
|
|
115
114
|
//# sourceMappingURL=IllustrationTargetHandler.d.ts.map
|
|
@@ -9,6 +9,7 @@ const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
|
|
|
9
9
|
const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
|
|
10
10
|
const target_label_1 = require("../../utils/target-label");
|
|
11
11
|
const DownloadPlanner_1 = require("../plan/DownloadPlanner");
|
|
12
|
+
const deliveryContext_1 = require("./deliveryContext");
|
|
12
13
|
class IllustrationTargetHandler {
|
|
13
14
|
client;
|
|
14
15
|
database;
|
|
@@ -681,7 +682,7 @@ class IllustrationTargetHandler {
|
|
|
681
682
|
const res = this.deliveryService.enqueue(artifact, target, {
|
|
682
683
|
slotId,
|
|
683
684
|
fields: target.delivery?.fields,
|
|
684
|
-
extraContext:
|
|
685
|
+
extraContext: (0, deliveryContext_1.deliveryContextFields)(target),
|
|
685
686
|
});
|
|
686
687
|
if (res.duplicate) {
|
|
687
688
|
return {
|
|
@@ -701,18 +702,6 @@ class IllustrationTargetHandler {
|
|
|
701
702
|
});
|
|
702
703
|
return { kind: 'selected', workId: artifact.pixivId, workType: artifact.type };
|
|
703
704
|
}
|
|
704
|
-
executionContextFields(target) {
|
|
705
|
-
const ec = target.delivery;
|
|
706
|
-
return {
|
|
707
|
-
scheduleId: ec?.executionContext?.scheduleId,
|
|
708
|
-
executionId: ec?.executionContext?.slotId,
|
|
709
|
-
occurrenceAt: ec?.executionContext?.occurrenceAtIso,
|
|
710
|
-
triggerSource: ec?.executionContext?.triggerSource,
|
|
711
|
-
slotId: ec?.slotContext?.slotId ?? ec?.executionContext?.slotId,
|
|
712
|
-
slotName: ec?.slotContext?.slotName ?? ec?.executionContext?.slotName,
|
|
713
|
-
slotDate: ec?.slotContext?.slotDate ?? ec?.executionContext?.slotDate,
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
705
|
}
|
|
717
706
|
exports.IllustrationTargetHandler = IllustrationTargetHandler;
|
|
718
707
|
//# sourceMappingURL=IllustrationTargetHandler.js.map
|
|
@@ -9,6 +9,7 @@ const TargetOutcome_1 = require("../../scheduler/TargetOutcome");
|
|
|
9
9
|
const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
|
|
10
10
|
const target_label_1 = require("../../utils/target-label");
|
|
11
11
|
const DownloadPlanner_1 = require("../plan/DownloadPlanner");
|
|
12
|
+
const deliveryContext_1 = require("./deliveryContext");
|
|
12
13
|
class NovelTargetHandler {
|
|
13
14
|
client;
|
|
14
15
|
database;
|
|
@@ -702,6 +703,7 @@ class NovelTargetHandler {
|
|
|
702
703
|
const res = this.deliveryService.enqueue(artifact, target, {
|
|
703
704
|
slotId,
|
|
704
705
|
fields: target.delivery?.fields,
|
|
706
|
+
extraContext: (0, deliveryContext_1.deliveryContextFields)(target),
|
|
705
707
|
});
|
|
706
708
|
if (res.duplicate) {
|
|
707
709
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TargetConfig } from '../../config';
|
|
2
|
+
/**
|
|
3
|
+
* Delivery payload context for one target run, merging schedule/slot provenance
|
|
4
|
+
* with the optional remote manual replacement identity.
|
|
5
|
+
*
|
|
6
|
+
* Every key is ALWAYS present (empty string when absent) so delivery templates
|
|
7
|
+
* never render a literal `{{placeholder}}` into the submitted payload — a
|
|
8
|
+
* scheduled run and a manual refetch share the same field templates.
|
|
9
|
+
*
|
|
10
|
+
* `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
|
|
11
|
+
* that produced this delivery; it is empty for scheduled occurrences. The
|
|
12
|
+
* receiving service (TelePost) correlates the incoming review with the refetch
|
|
13
|
+
* attempt on this id.
|
|
14
|
+
*/
|
|
15
|
+
export declare function deliveryContextFields(target: TargetConfig): Record<string, unknown>;
|
|
16
|
+
//# sourceMappingURL=deliveryContext.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deliveryContextFields = deliveryContextFields;
|
|
4
|
+
/**
|
|
5
|
+
* Delivery payload context for one target run, merging schedule/slot provenance
|
|
6
|
+
* with the optional remote manual replacement identity.
|
|
7
|
+
*
|
|
8
|
+
* Every key is ALWAYS present (empty string when absent) so delivery templates
|
|
9
|
+
* never render a literal `{{placeholder}}` into the submitted payload — a
|
|
10
|
+
* scheduled run and a manual refetch share the same field templates.
|
|
11
|
+
*
|
|
12
|
+
* `refetchRequestId` is the request UUID of a remote manual replacement ("重抓")
|
|
13
|
+
* that produced this delivery; it is empty for scheduled occurrences. The
|
|
14
|
+
* receiving service (TelePost) correlates the incoming review with the refetch
|
|
15
|
+
* attempt on this id.
|
|
16
|
+
*/
|
|
17
|
+
function deliveryContextFields(target) {
|
|
18
|
+
const ec = target.delivery;
|
|
19
|
+
const slotContext = ec?.slotContext;
|
|
20
|
+
const executionContext = ec?.executionContext;
|
|
21
|
+
return {
|
|
22
|
+
scheduleId: executionContext?.scheduleId ?? '',
|
|
23
|
+
executionId: executionContext?.slotId ?? '',
|
|
24
|
+
occurrenceAt: executionContext?.occurrenceAtIso ?? '',
|
|
25
|
+
triggerSource: executionContext?.triggerSource ?? '',
|
|
26
|
+
slotId: slotContext?.slotId ?? executionContext?.slotId ?? '',
|
|
27
|
+
slotName: slotContext?.slotName ?? executionContext?.slotName ?? '',
|
|
28
|
+
slotDate: slotContext?.slotDate ?? executionContext?.slotDate ?? '',
|
|
29
|
+
refetchRequestId: slotContext?.manualRequestId ?? '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=deliveryContext.js.map
|
|
@@ -33,6 +33,7 @@ export declare class NotificationPolicy {
|
|
|
33
33
|
hardFail: (slotId: string, targetId: string) => string;
|
|
34
34
|
summary: (slotId: string) => string;
|
|
35
35
|
dead: (slotId: string, targetId: string) => string;
|
|
36
|
+
refetchOutcome: (slotId: string, targetId: string) => string;
|
|
36
37
|
};
|
|
37
38
|
noteOutcome(slotId: string, slot: SlotContext, schedule: ScheduleConfig, target: TargetConfig, outcome: TargetOutcome): void;
|
|
38
39
|
/** One consolidated summary per slot, delivered to every notifying target's endpoint. */
|
|
@@ -45,5 +46,16 @@ export declare class NotificationPolicy {
|
|
|
45
46
|
error: string | null;
|
|
46
47
|
}>): void;
|
|
47
48
|
private send;
|
|
49
|
+
/**
|
|
50
|
+
* Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
|
|
51
|
+
* the requester through the durable outbox. Only terminal outcomes are
|
|
52
|
+
* reported (no_candidate / non-retryable failed); a successful replacement is
|
|
53
|
+
* correlated by the submission itself (its payload carries the request id).
|
|
54
|
+
*
|
|
55
|
+
* The key is anchored to the manual SLOT, so a slot recovered and re-run can
|
|
56
|
+
* never enqueue a second verdict for the same logical attempt, and helpers
|
|
57
|
+
* that already returned remain idempotent.
|
|
58
|
+
*/
|
|
59
|
+
noteRefetchOutcome(slot: SlotContext, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
|
|
48
60
|
}
|
|
49
61
|
//# sourceMappingURL=NotificationPolicy.d.ts.map
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.NotificationPolicy = void 0;
|
|
4
4
|
const DeliveryService_1 = require("../delivery/DeliveryService");
|
|
5
|
+
const logger_1 = require("../logger");
|
|
5
6
|
/**
|
|
6
7
|
* Central policy for all operational notifications. Handlers/scheduler do not
|
|
7
8
|
* decide how or when to notify — they emit domain outcomes and this policy
|
|
@@ -44,6 +45,7 @@ class NotificationPolicy {
|
|
|
44
45
|
hardFail: (slotId, targetId) => `notification:${slotId}:${targetId}:failed`,
|
|
45
46
|
summary: (slotId) => `notification:${slotId}:summary`,
|
|
46
47
|
dead: (slotId, targetId) => `notification:${slotId}:${targetId}:delivery-dead`,
|
|
48
|
+
refetchOutcome: (slotId, targetId) => `refetch-outcome:${slotId}:${targetId}`,
|
|
47
49
|
};
|
|
48
50
|
noteOutcome(slotId, slot, schedule, target, outcome) {
|
|
49
51
|
const name = this.targetName(target);
|
|
@@ -104,6 +106,90 @@ class NotificationPolicy {
|
|
|
104
106
|
console.warn('notification enqueue failed', { targetName, key, error: error.message });
|
|
105
107
|
}
|
|
106
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
|
|
111
|
+
* the requester through the durable outbox. Only terminal outcomes are
|
|
112
|
+
* reported (no_candidate / non-retryable failed); a successful replacement is
|
|
113
|
+
* correlated by the submission itself (its payload carries the request id).
|
|
114
|
+
*
|
|
115
|
+
* The key is anchored to the manual SLOT, so a slot recovered and re-run can
|
|
116
|
+
* never enqueue a second verdict for the same logical attempt, and helpers
|
|
117
|
+
* that already returned remain idempotent.
|
|
118
|
+
*/
|
|
119
|
+
noteRefetchOutcome(slot, _schedule, target, requestId, outcome) {
|
|
120
|
+
const name = this.targetName(target);
|
|
121
|
+
if (!name)
|
|
122
|
+
return;
|
|
123
|
+
const deliveryTarget = this.config.delivery?.targets?.[name];
|
|
124
|
+
if (!deliveryTarget || deliveryTarget.type !== 'httpMultipart')
|
|
125
|
+
return;
|
|
126
|
+
if (!deliveryTarget.refetchOutcomeUrl?.trim()) {
|
|
127
|
+
// No endpoint configured: skip the report (content is still durable).
|
|
128
|
+
logger_1.logger.info('Refetch outcome not reported: refetchOutcomeUrl unset', {
|
|
129
|
+
slot: slot.slotId,
|
|
130
|
+
target: target.id,
|
|
131
|
+
requestId,
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let payload;
|
|
136
|
+
if (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') {
|
|
137
|
+
payload = {
|
|
138
|
+
requestId,
|
|
139
|
+
disposition: 'no_alternative',
|
|
140
|
+
reason: outcome.reason,
|
|
141
|
+
workId: outcome.kind === 'duplicate' ? outcome.workId : undefined,
|
|
142
|
+
...scanCounts(outcome.scan),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
else if (outcome.kind === 'failed' && !outcome.retryable) {
|
|
146
|
+
payload = {
|
|
147
|
+
requestId,
|
|
148
|
+
disposition: 'failed',
|
|
149
|
+
reason: outcome.error,
|
|
150
|
+
...scanCounts(outcome.scan),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
return; // not terminal for refetch purposes
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
new DeliveryService_1.DeliveryService(this.database).enqueueNotification(name, `refetch outcome: ${payload.disposition} (slot ${slot.slotId})`, NotificationPolicy.keys.refetchOutcome(slot.slotId, target.id ?? target.type), payload);
|
|
158
|
+
logger_1.logger.info('Refetch outcome enqueued for report', {
|
|
159
|
+
slot: slot.slotId,
|
|
160
|
+
target: target.id,
|
|
161
|
+
requestId,
|
|
162
|
+
disposition: payload.disposition,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
// A failed VERDICT report never changes the terminal content state; it is
|
|
167
|
+
// retried by the operator/resume path, and never unwinds the run.
|
|
168
|
+
logger_1.logger.warn('Refetch outcome report enqueue failed', {
|
|
169
|
+
slot: slot.slotId,
|
|
170
|
+
target: target.id,
|
|
171
|
+
error: error instanceof Error ? error.message : String(error),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
107
175
|
}
|
|
108
176
|
exports.NotificationPolicy = NotificationPolicy;
|
|
177
|
+
/** Fold a CandidateScanSummary into the refetch-outcome bookkeeping (bounded). */
|
|
178
|
+
function scanCounts(scan) {
|
|
179
|
+
if (!scan)
|
|
180
|
+
return {};
|
|
181
|
+
const skipped = scan.skipped ?? [];
|
|
182
|
+
const duplicate = skipped.filter((s) => s.code === 'duplicate').length;
|
|
183
|
+
const unavailable = skipped.filter((s) => s.code === 'unavailable').length;
|
|
184
|
+
const invalid = skipped.filter((s) => ['deleted', 'access_denied', 'unsupported_media', 'invalid_metadata', 'filtered'].includes(s.code)).length;
|
|
185
|
+
return {
|
|
186
|
+
scanned: scan.attempted,
|
|
187
|
+
skipped: {
|
|
188
|
+
total: skipped.length,
|
|
189
|
+
duplicate,
|
|
190
|
+
invalid,
|
|
191
|
+
unavailable,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
109
195
|
//# sourceMappingURL=NotificationPolicy.js.map
|
package/dist/package.json
CHANGED
|
@@ -178,6 +178,8 @@ class MultiScheduleManager {
|
|
|
178
178
|
triggerSource: asTriggerSource(slot.triggerSource),
|
|
179
179
|
slotName: slot.slotName || slot.occurrenceLabel,
|
|
180
180
|
slotDate: slot.slotDate || slot.occurrenceDate,
|
|
181
|
+
manualRequestId: slot.manualRequestId ?? undefined,
|
|
182
|
+
correlationId: slot.correlationId ?? undefined,
|
|
181
183
|
};
|
|
182
184
|
const admitted = this.triggerSchedule(slot.scheduleId, {
|
|
183
185
|
triggerSource: context.triggerSource,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Request } from 'express';
|
|
1
2
|
import { SlotContext } from './SlotCoordinator';
|
|
2
3
|
import { TriggerSource } from './OccurrenceResolver';
|
|
3
4
|
/**
|
|
@@ -51,6 +52,30 @@ export interface TriggerRunResult {
|
|
|
51
52
|
error?: string | null;
|
|
52
53
|
}>;
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Correlation id issued by the calling clock (`x-schedule-attempt-id`, falling
|
|
57
|
+
* back to `x-attempt-id`).
|
|
58
|
+
*
|
|
59
|
+
* Purely diagnostic: it is trimmed, reduced to `[A-Za-z0-9._:-]` and capped, and
|
|
60
|
+
* it NEVER participates in occurrence identity. When the clock sends nothing
|
|
61
|
+
* usable the field is omitted rather than synthesized — a made-up id would make
|
|
62
|
+
* a missing clock indistinguishable from a satisfied one, which is exactly the
|
|
63
|
+
* blindness this logging exists to remove.
|
|
64
|
+
*/
|
|
65
|
+
export declare function scheduleAttemptId(req: Request): string | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Optional self-identification from the clock that fired: `X-Schedule-Provider`
|
|
68
|
+
* (e.g. `cron-job-org`, `cloudflare`, `manual`).
|
|
69
|
+
*
|
|
70
|
+
* OBSERVABILITY ONLY. It NEVER participates in authorization and NEVER in
|
|
71
|
+
* occurrence identity: a clock that lies about its name changes one log field
|
|
72
|
+
* and nothing else. That is deliberate — a self-declared header must not be able
|
|
73
|
+
* to alter which occurrence runs or whether the request is admitted. Sanitized
|
|
74
|
+
* and bounded like the attempt id, and omitted when the clock sends nothing
|
|
75
|
+
* usable rather than defaulted, so "we do not know which clock this was" stays
|
|
76
|
+
* distinguishable from "the clock identified itself".
|
|
77
|
+
*/
|
|
78
|
+
export declare function scheduleProvider(req: Request): string | undefined;
|
|
54
79
|
export interface TriggerHandlers {
|
|
55
80
|
/** Enabled schedule ids (for 404 on unknown / GET listing). */
|
|
56
81
|
listSchedules(): string[];
|
|
@@ -75,16 +100,48 @@ export interface TriggerHandlers {
|
|
|
75
100
|
retried?: number;
|
|
76
101
|
dead?: number;
|
|
77
102
|
}>;
|
|
103
|
+
/** Admit one target into a durable, separate manual Slot. */
|
|
104
|
+
refetch?(targetId: string, requestId: string, correlationId?: string): Promise<{
|
|
105
|
+
slotId: string;
|
|
106
|
+
disposition: string;
|
|
107
|
+
}>;
|
|
78
108
|
}
|
|
79
109
|
export declare class ScheduleTriggerServer {
|
|
80
110
|
private readonly token;
|
|
81
111
|
private readonly handlers;
|
|
112
|
+
private readonly refetchToken;
|
|
82
113
|
private server;
|
|
83
|
-
constructor(token: string | undefined, handlers: TriggerHandlers);
|
|
114
|
+
constructor(token: string | undefined, handlers: TriggerHandlers, refetchToken?: string | undefined);
|
|
84
115
|
/** Token from config or SCHEDULER_TRIGGER_TOKEN env; empty => fail closed. */
|
|
85
116
|
static resolveToken(configured?: string): string | undefined;
|
|
86
117
|
start(host: string, port: number): void;
|
|
118
|
+
/**
|
|
119
|
+
* Per-request correlation, installed before every route. It records only the
|
|
120
|
+
* sanitized attempt id and the arrival instant, so each later line can report
|
|
121
|
+
* the same `attempt_id` and a handler-relative `elapsed_ms`.
|
|
122
|
+
*/
|
|
123
|
+
private correlate;
|
|
124
|
+
/**
|
|
125
|
+
* `trigger.received` — the clock arrived. Runs before auth, so it carries only
|
|
126
|
+
* non-sensitive request metadata: never the Authorization header, the bearer
|
|
127
|
+
* value, the configured token, or any fragment of them.
|
|
128
|
+
*/
|
|
129
|
+
private received;
|
|
130
|
+
/**
|
|
131
|
+
* The single writer for every post-resolution `trigger.*` line. `event` and
|
|
132
|
+
* `http_status` are passed in from the caller's outcome table, so the line
|
|
133
|
+
* always describes the same status the response carried.
|
|
134
|
+
*/
|
|
135
|
+
private triggerOutcome;
|
|
136
|
+
/**
|
|
137
|
+
* The non-sensitive correlation fields shared by every `schedule.trigger_*`
|
|
138
|
+
* line. `attempt_id` and `provider` are OMITTED (never defaulted) when the
|
|
139
|
+
* clock did not send them, so an unidentified clock stays visible as such.
|
|
140
|
+
*/
|
|
141
|
+
private attemptMeta;
|
|
87
142
|
private auth;
|
|
143
|
+
private refetchAuth;
|
|
144
|
+
private authenticate;
|
|
88
145
|
stop(): void;
|
|
89
146
|
}
|
|
90
147
|
//# sourceMappingURL=ScheduleTriggerServer.d.ts.map
|