pixivflow 2.20.1 → 2.20.3
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 +15 -0
- package/dist/commands/scheduler-runtime.d.ts +8 -0
- package/dist/commands/scheduler-runtime.js +126 -34
- package/dist/config/types.d.ts +12 -0
- package/dist/config/validation.js +10 -0
- package/dist/delivery/DeliveryDispatcher.js +7 -2
- package/dist/delivery/DeliveryService.d.ts +14 -1
- package/dist/delivery/DeliveryService.js +6 -2
- package/dist/delivery/HttpMultipartDelivery.js +27 -5
- package/dist/delivery/OutboxWorker.d.ts +2 -0
- package/dist/delivery/OutboxWorker.js +3 -0
- package/dist/delivery/types.d.ts +16 -0
- package/dist/notification/NotificationPolicy.d.ts +9 -1
- package/dist/notification/NotificationPolicy.js +52 -4
- package/dist/package.json +1 -1
- package/dist/scheduler/ScheduleTriggerServer.d.ts +6 -0
- package/dist/scheduler/ScheduleTriggerServer.js +13 -0
- package/dist/scheduler/SlotCoordinator.d.ts +14 -1
- package/dist/scheduler/SlotCoordinator.js +26 -2
- package/dist/storage/DatabaseMigration.js +5 -0
- package/dist/storage/repositories/SlotRepository.d.ts +11 -0
- package/dist/storage/repositories/SlotRepository.js +28 -0
- package/dist/utils/config-validator-unified.js +14 -0
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/package.json +1 -1
|
@@ -166,6 +166,14 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
166
166
|
throw new Error('ambiguous target');
|
|
167
167
|
const plan = plans[0];
|
|
168
168
|
const target = (0, schedules_1.selectScheduleTargets)(cfg.targets, plan).find((item) => item.id === targetId);
|
|
169
|
+
const deliveryName = target.delivery?.target;
|
|
170
|
+
const delivery = deliveryName ? cfg.delivery?.targets?.[deliveryName] : undefined;
|
|
171
|
+
if (delivery?.type !== 'httpMultipart' || !delivery.refetchOutcomeUrl?.trim()) {
|
|
172
|
+
throw new Error('refetch outcome endpoint not configured');
|
|
173
|
+
}
|
|
174
|
+
if ((target.delivery?.fields?.refetch_request_id ?? delivery.fields?.refetch_request_id) !== '{{refetchRequestId}}') {
|
|
175
|
+
throw new Error('refetch_request_id delivery field not configured');
|
|
176
|
+
}
|
|
169
177
|
const now = new Date();
|
|
170
178
|
const date = new Intl.DateTimeFormat('en-CA', {
|
|
171
179
|
timeZone: plan.timezone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
@@ -193,6 +201,13 @@ class SchedulerCommand extends Command_1.BaseCommand {
|
|
|
193
201
|
const started = manager.triggerSchedule(plan.id, { slot, onlyTarget: targetId, triggerSource: 'manual' });
|
|
194
202
|
return { slotId: slot.slotId, disposition: started ? 'accepted' : 'queued' };
|
|
195
203
|
},
|
|
204
|
+
refetchStatus: (targetId, requestId) => {
|
|
205
|
+
const slot = runtime.database.slots.findManualSlot(requestId, targetId);
|
|
206
|
+
const cell = slot && runtime.database.slots.getCell(slot.id, targetId);
|
|
207
|
+
return slot && cell
|
|
208
|
+
? { requestId, slotId: slot.id, state: cell.status, slotStatus: slot.status }
|
|
209
|
+
: null;
|
|
210
|
+
},
|
|
196
211
|
status: (scheduleId) => {
|
|
197
212
|
const cfg = resolveConfig();
|
|
198
213
|
const plan = findPlan(cfg, scheduleId);
|
|
@@ -111,6 +111,14 @@ export interface SchedulerRuntime {
|
|
|
111
111
|
close(): void;
|
|
112
112
|
}
|
|
113
113
|
export declare function notifyScheduleFailure(config: StandaloneConfig, database: Database, schedule: ScheduleConfig, failure: JobFailure): Promise<void>;
|
|
114
|
+
/**
|
|
115
|
+
* Expanded, still-bounded candidate scan bound for one fallback stage
|
|
116
|
+
* (§schedule-recovery). Stage 0 is the primary pass; stage N scans
|
|
117
|
+
* `base * (N + 1)` candidates, hard-capped so a fallback can never become an
|
|
118
|
+
* unbounded scan of the whole tag. `undefined` (no declared bound) stays
|
|
119
|
+
* `undefined`: the handler's own default applies, not a fabricated number.
|
|
120
|
+
*/
|
|
121
|
+
export declare function fallbackScanLimit(base: number | undefined, stage: number): number | undefined;
|
|
114
122
|
export declare function createSchedulerRuntime(configPathArg?: string): Promise<SchedulerRuntime>;
|
|
115
123
|
/**
|
|
116
124
|
* Watchdog for a single plan run (used by `run-once`; the daemon's cron runs
|
|
@@ -45,6 +45,7 @@ exports.EXECUTION_MODES = void 0;
|
|
|
45
45
|
exports.withDeliveryMode = withDeliveryMode;
|
|
46
46
|
exports.shouldTerminaliseAbortedSlot = shouldTerminaliseAbortedSlot;
|
|
47
47
|
exports.notifyScheduleFailure = notifyScheduleFailure;
|
|
48
|
+
exports.fallbackScanLimit = fallbackScanLimit;
|
|
48
49
|
exports.createSchedulerRuntime = createSchedulerRuntime;
|
|
49
50
|
exports.runWithTimeout = runWithTimeout;
|
|
50
51
|
const config_1 = require("../config");
|
|
@@ -199,6 +200,16 @@ function buildProxyUrl(network) {
|
|
|
199
200
|
const auth = proxy.username ? `${proxy.username}:${proxy.password ?? ''}@` : '';
|
|
200
201
|
return `${protocol}://${auth}${proxy.host}:${proxy.port}`;
|
|
201
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Expanded, still-bounded candidate scan bound for one fallback stage
|
|
205
|
+
* (§schedule-recovery). Stage 0 is the primary pass; stage N scans
|
|
206
|
+
* `base * (N + 1)` candidates, hard-capped so a fallback can never become an
|
|
207
|
+
* unbounded scan of the whole tag. `undefined` (no declared bound) stays
|
|
208
|
+
* `undefined`: the handler's own default applies, not a fabricated number.
|
|
209
|
+
*/
|
|
210
|
+
function fallbackScanLimit(base, stage) {
|
|
211
|
+
return base === undefined ? undefined : Math.min(Math.max(base, 1) * (stage + 1), 100);
|
|
212
|
+
}
|
|
202
213
|
async function createSchedulerRuntime(configPathArg) {
|
|
203
214
|
logger_1.logger.info('PixivFlow runtime starting', { component: 'pixivflow', version: version_1.BUILD.version, commit: version_1.BUILD.commit });
|
|
204
215
|
// Keep TODAY/YESTERDAY placeholders intact. They are resolved afresh for
|
|
@@ -239,6 +250,7 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
239
250
|
// Independently-pumped durable outbox (content + notifications). Started in
|
|
240
251
|
// the long-running scheduler daemon; run-once drains explicitly before exit.
|
|
241
252
|
const deliveryDispatcher = new DeliveryDispatcher_1.DeliveryDispatcher(config.delivery, buildProxyUrl(config.network));
|
|
253
|
+
const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
|
|
242
254
|
const outboxWorker = new OutboxWorker_1.OutboxWorker(database, deliveryDispatcher, {
|
|
243
255
|
retryBaseMs: config.delivery?.outboxRetryBaseMs,
|
|
244
256
|
retryMaxMs: config.delivery?.outboxRetryMaxMs,
|
|
@@ -246,9 +258,22 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
246
258
|
// failed); see settleDeliveryTerminal for the invariant it enforces.
|
|
247
259
|
onDeliveryTerminal: (deliveryId, ack) => {
|
|
248
260
|
(0, settleDeliveryTerminal_1.settleDeliveryTerminal)(database, deliveryId, ack);
|
|
261
|
+
const delivery = database.deliveries.getById(deliveryId);
|
|
262
|
+
if (delivery?.slotId && delivery.targetId)
|
|
263
|
+
notificationPolicy.noteTerminalRefetchCell(delivery.slotId, delivery.targetId);
|
|
264
|
+
},
|
|
265
|
+
onDead: (row, error) => {
|
|
266
|
+
if (!row.deliveryId)
|
|
267
|
+
return;
|
|
268
|
+
const delivery = database.deliveries.getById(row.deliveryId);
|
|
269
|
+
if (!delivery?.slotId || !delivery.targetId)
|
|
270
|
+
return;
|
|
271
|
+
new SlotCoordinator_1.SlotCoordinator(database).applyOutcome(delivery.slotId, delivery.targetId, {
|
|
272
|
+
kind: 'failed', retryable: false, error,
|
|
273
|
+
});
|
|
274
|
+
notificationPolicy.noteTerminalRefetchCell(delivery.slotId, delivery.targetId);
|
|
249
275
|
},
|
|
250
276
|
});
|
|
251
|
-
const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
|
|
252
277
|
const runJob = async (snapshot, schedule, options = {}) => {
|
|
253
278
|
const { onlyTarget, adhoc = false, slot: providedSlot } = options;
|
|
254
279
|
// A scheduled run is cron by default; an HTTP/manual trigger passes its own.
|
|
@@ -363,6 +388,14 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
363
388
|
cancelled = true;
|
|
364
389
|
slotAbandoned = true;
|
|
365
390
|
clearInterval(heartbeat);
|
|
391
|
+
for (const target of targets) {
|
|
392
|
+
if (!target.id)
|
|
393
|
+
continue;
|
|
394
|
+
if (database.slots.getCell(activeSlot.slotId, target.id)?.status === 'delivery_pending')
|
|
395
|
+
continue;
|
|
396
|
+
coordinator.applyOutcome(activeSlot.slotId, target.id, { kind: 'failed', retryable: false, error: reason });
|
|
397
|
+
notificationPolicy.noteTerminalRefetchCell(activeSlot.slotId, target.id);
|
|
398
|
+
}
|
|
366
399
|
database.slots.markSlotStatus(activeSlot.slotId, 'failed', `abandoned after scheduler timeout; no delivery (${reason})`);
|
|
367
400
|
coordinator.releaseRunLease(activeSlot.slotId, runOwner);
|
|
368
401
|
activeLeaseHooks = null;
|
|
@@ -384,6 +417,9 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
384
417
|
if (slotCtx && runTargets.length === 0) {
|
|
385
418
|
logger_1.logger.info('All slot cells already complete', { slot: slotCtx.slotId });
|
|
386
419
|
coordinator.finish(slotCtx, schedule, targets);
|
|
420
|
+
for (const target of targets)
|
|
421
|
+
if (target.id)
|
|
422
|
+
notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
|
|
387
423
|
// Release LAST: the slot must stay owned until its aggregate state has
|
|
388
424
|
// been rolled up, otherwise a concurrent trigger could claim and re-run it
|
|
389
425
|
// against a half-finished ledger.
|
|
@@ -420,21 +456,53 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
420
456
|
targets: runTargets.map((t) => t.id),
|
|
421
457
|
});
|
|
422
458
|
}
|
|
423
|
-
const downloadManager = new DownloadManager_1.DownloadManager(scopedConfig, pixivClient, database, fileService);
|
|
424
|
-
if (targetExecutionContexts)
|
|
425
|
-
downloadManager.setTargetExecutionContexts(targetExecutionContexts);
|
|
426
|
-
if (options.excludedWorkIds)
|
|
427
|
-
downloadManager.setProcessedWorkIds(options.excludedWorkIds);
|
|
428
|
-
activeDownloadManager = downloadManager;
|
|
429
|
-
await downloadManager.initialise();
|
|
430
459
|
const scheduleSlot = slotCtx; // stable for callbacks; null for ad-hoc runs
|
|
460
|
+
// Bounded candidate fallback budget (§schedule-recovery): a missing required
|
|
461
|
+
// target must exhaust its recovery stages before the occurrence may roll up
|
|
462
|
+
// as a degraded (partial) terminal result.
|
|
463
|
+
const maxFallbackStages = Math.max(0, Math.min(10, Number(runtimeConfig.download?.maxFallbackStages ?? 3)));
|
|
464
|
+
const boostScanLimit = fallbackScanLimit;
|
|
465
|
+
const boostedTargets = (list, stage) => stage === 0
|
|
466
|
+
? list
|
|
467
|
+
: list.map((t) => ({
|
|
468
|
+
...t,
|
|
469
|
+
candidateScanLimit: boostScanLimit(t.candidateScanLimit ?? runtimeConfig.download?.candidateScanLimit, stage),
|
|
470
|
+
}));
|
|
471
|
+
const buildManager = (list) => {
|
|
472
|
+
const scoped = {
|
|
473
|
+
...runtimeConfig,
|
|
474
|
+
targets: withDeliveryMode(list.map((t) => ({
|
|
475
|
+
...t,
|
|
476
|
+
delivery: t.delivery
|
|
477
|
+
? { ...t.delivery, slotContext: slotCtx ?? undefined, executionContext }
|
|
478
|
+
: t.delivery,
|
|
479
|
+
})), options.deliveryMode),
|
|
480
|
+
};
|
|
481
|
+
const manager = new DownloadManager_1.DownloadManager(scoped, pixivClient, database, fileService);
|
|
482
|
+
if (targetExecutionContexts)
|
|
483
|
+
manager.setTargetExecutionContexts(targetExecutionContexts);
|
|
484
|
+
if (options.excludedWorkIds)
|
|
485
|
+
manager.setProcessedWorkIds(options.excludedWorkIds);
|
|
486
|
+
manager.setTargetOutcomeHook(outcomeHook);
|
|
487
|
+
if (scheduleSlot) {
|
|
488
|
+
manager.slotContext = {
|
|
489
|
+
slotId: scheduleSlot.slotId,
|
|
490
|
+
scheduleId: scheduleSlot.scheduleId,
|
|
491
|
+
occurrenceAtIso: new Date(scheduleSlot.occurrenceAt).toISOString(),
|
|
492
|
+
triggerSource: scheduleSlot.triggerSource,
|
|
493
|
+
slotName: scheduleSlot.slotName,
|
|
494
|
+
slotDate: scheduleSlot.slotDate,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return manager;
|
|
498
|
+
};
|
|
431
499
|
// TYPED outcome -> explicit FSM transition. No message regex, no
|
|
432
500
|
// "no throw => submitted". Only a confirmed ACK yields 'submitted'.
|
|
433
501
|
//
|
|
434
502
|
// Registered for EVERY run, not just scheduled ones: the batch runner
|
|
435
503
|
// (execute-slot) runs without a Slot and still has to report a
|
|
436
504
|
// machine-readable per-target result to its caller.
|
|
437
|
-
|
|
505
|
+
const outcomeHook = (target, outcome) => {
|
|
438
506
|
if (!target.id)
|
|
439
507
|
return;
|
|
440
508
|
options.onTargetOutcome?.(target.id, outcome);
|
|
@@ -442,33 +510,33 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
442
510
|
// No durable slot (run-once CLI): nothing to converge or report.
|
|
443
511
|
return;
|
|
444
512
|
}
|
|
513
|
+
// A scheduled (non-manual) target with nothing to submit advances to its
|
|
514
|
+
// next bounded fallback stage instead of terminalising: the cell returns
|
|
515
|
+
// to `pending` and the next pass re-selects it with expanded scan bounds.
|
|
516
|
+
// The FINAL stage (stage == maxFallbackStages - 1) does NOT advance: its
|
|
517
|
+
// real terminal outcome (no_candidate / duplicate / failed) is applied, so
|
|
518
|
+
// an exhausted occurrence reports the true cause — never a generic
|
|
519
|
+
// "target did not complete".
|
|
520
|
+
if (!scheduleSlot.manualRequestId &&
|
|
521
|
+
(outcome.kind === 'no_candidate' || outcome.kind === 'duplicate') &&
|
|
522
|
+
coordinator.cellFallbackStage(scheduleSlot.slotId, target.id) < maxFallbackStages - 1) {
|
|
523
|
+
coordinator.advanceFallback(scheduleSlot.slotId, target.id, outcome.kind === 'duplicate'
|
|
524
|
+
? `duplicate candidates (stage ${coordinator.cellFallbackStage(scheduleSlot.slotId, target.id)})`
|
|
525
|
+
: outcome.reason ?? 'no eligible candidate', maxFallbackStages);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
445
528
|
coordinator.applyOutcome(scheduleSlot.slotId, target.id, outcome);
|
|
446
529
|
notificationPolicy.noteOutcome(scheduleSlot.slotId, scheduleSlot, schedule, target, outcome);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
}
|
|
530
|
+
if (scheduleSlot.manualRequestId && (outcome.kind === 'no_candidate' || outcome.kind === 'duplicate' ||
|
|
531
|
+
(outcome.kind === 'failed' && !outcome.retryable))) {
|
|
532
|
+
// Preserve scan bookkeeping; later durable-cell reporting is the
|
|
533
|
+
// fallback for retry exhaustion, timeout, and outbox dead-letter.
|
|
534
|
+
notificationPolicy.noteRefetchOutcome(scheduleSlot, schedule, target, scheduleSlot.manualRequestId, outcome);
|
|
460
535
|
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
scheduleId: scheduleSlot.scheduleId,
|
|
466
|
-
occurrenceAtIso: new Date(scheduleSlot.occurrenceAt).toISOString(),
|
|
467
|
-
triggerSource: scheduleSlot.triggerSource,
|
|
468
|
-
slotName: scheduleSlot.slotName,
|
|
469
|
-
slotDate: scheduleSlot.slotDate,
|
|
470
|
-
};
|
|
471
|
-
}
|
|
536
|
+
};
|
|
537
|
+
let downloadManager = buildManager(boostedTargets(runTargets, 0));
|
|
538
|
+
activeDownloadManager = downloadManager;
|
|
539
|
+
await downloadManager.initialise();
|
|
472
540
|
// Apply initial delay if configured
|
|
473
541
|
if (runtimeConfig.initialDelay && runtimeConfig.initialDelay > 0) {
|
|
474
542
|
logger_1.logger.info(`Waiting ${runtimeConfig.initialDelay}ms before starting download...`, {
|
|
@@ -490,7 +558,25 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
490
558
|
if (slotCtx && varReleaseLease)
|
|
491
559
|
releaseLease = varReleaseLease;
|
|
492
560
|
try {
|
|
493
|
-
|
|
561
|
+
// Candidate fallback passes (§schedule-recovery): after each pass, cells
|
|
562
|
+
// still mid-fallback (advanced stages) are re-selected with expanded,
|
|
563
|
+
// still-bounded scan limits. Successful sibling cells are NEVER re-run:
|
|
564
|
+
// pendingTargets only returns unconverged cells of this slot.
|
|
565
|
+
for (let pass = 0;; pass += 1) {
|
|
566
|
+
await downloadManager.runAllTargets();
|
|
567
|
+
if (!slotCtx || pass >= maxFallbackStages - 1)
|
|
568
|
+
break;
|
|
569
|
+
const pendingFallback = coordinator
|
|
570
|
+
.pendingTargets(slotCtx.slotId, targets)
|
|
571
|
+
.filter((p) => p.cell &&
|
|
572
|
+
p.cell.fallback_stage > 0 &&
|
|
573
|
+
p.cell.fallback_stage < maxFallbackStages);
|
|
574
|
+
if (pendingFallback.length === 0)
|
|
575
|
+
break;
|
|
576
|
+
downloadManager = buildManager(boostedTargets(pendingFallback.map((p) => p.target), pass + 1));
|
|
577
|
+
activeDownloadManager = downloadManager;
|
|
578
|
+
await downloadManager.initialise();
|
|
579
|
+
}
|
|
494
580
|
}
|
|
495
581
|
catch (error) {
|
|
496
582
|
if (!slotCtx || !(error instanceof Error) || !/^All \d+ target\(s\) failed\./.test(error.message)) {
|
|
@@ -509,6 +595,9 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
509
595
|
// non-terminal so recovery resumes the same occurrence after restart.
|
|
510
596
|
if (slotCtx && shouldTerminaliseAbortedSlot(activeAbortOrigin, slotAbandoned)) {
|
|
511
597
|
coordinator.finish(slotCtx, schedule, targets);
|
|
598
|
+
for (const target of targets)
|
|
599
|
+
if (target.id)
|
|
600
|
+
notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
|
|
512
601
|
}
|
|
513
602
|
// Hand the lease back either way: a terminal Slot cannot be re-dispatched,
|
|
514
603
|
// and a non-terminal one (shutdown) must not wait for its TTL to expire.
|
|
@@ -527,6 +616,9 @@ async function createSchedulerRuntime(configPathArg) {
|
|
|
527
616
|
const duration = Math.round((Date.now() - startTime) / 1000);
|
|
528
617
|
if (slotCtx && !slotAbandoned) {
|
|
529
618
|
const summary = coordinator.finish(slotCtx, schedule, targets);
|
|
619
|
+
for (const target of targets)
|
|
620
|
+
if (target.id)
|
|
621
|
+
notificationPolicy.noteTerminalRefetchCell(slotCtx.slotId, target.id);
|
|
530
622
|
notificationPolicy.sendSlotSummary(slotCtx, schedule, summary.cells.map((c) => {
|
|
531
623
|
const t = targets.find((x) => x.id === c.targetId);
|
|
532
624
|
return {
|
package/dist/config/types.d.ts
CHANGED
|
@@ -524,6 +524,12 @@ export interface HttpMultipartDeliveryConfig {
|
|
|
524
524
|
* `headers` for auth, so no extra credential is needed.
|
|
525
525
|
*/
|
|
526
526
|
refetchOutcomeUrl?: string;
|
|
527
|
+
/**
|
|
528
|
+
* Optional JSON endpoint for TERMINAL SCHEDULE occurrence summaries
|
|
529
|
+
* (success/partial/failed) — delivered by the durable outbox to TelePost,
|
|
530
|
+
* which relays a user/operator-visible message. Reuses `headers` for auth.
|
|
531
|
+
*/
|
|
532
|
+
scheduleOutcomeUrl?: string;
|
|
527
533
|
method?: 'POST' | 'PUT';
|
|
528
534
|
/** 支持 ${ENV_NAME} 环境变量插值 */
|
|
529
535
|
headers?: Record<string, string>;
|
|
@@ -683,6 +689,12 @@ export interface StandaloneConfig {
|
|
|
683
689
|
* Default: 5 (clamped to 1..100)
|
|
684
690
|
*/
|
|
685
691
|
candidateScanLimit?: number;
|
|
692
|
+
/**
|
|
693
|
+
* Bounded candidate-fallback budget for SCHEDULED occurrences
|
|
694
|
+
* (§schedule-recovery). 0 disables fallback (no_candidate/duplicate
|
|
695
|
+
* terminalises immediately). Default: 3.
|
|
696
|
+
*/
|
|
697
|
+
maxFallbackStages?: number;
|
|
686
698
|
};
|
|
687
699
|
}
|
|
688
700
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -312,6 +312,16 @@ function validateConfig(config, location, databasePath) {
|
|
|
312
312
|
errors.push(`${prefix}.refetchOutcomeUrl: Must be a valid HTTP or HTTPS URL`);
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
|
+
if (delivery.scheduleOutcomeUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.scheduleOutcomeUrl)) {
|
|
316
|
+
try {
|
|
317
|
+
const url = new URL(delivery.scheduleOutcomeUrl);
|
|
318
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
319
|
+
throw new Error('unsupported protocol');
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
errors.push(`${prefix}.scheduleOutcomeUrl: Must be a valid HTTP or HTTPS URL`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
315
325
|
if (delivery.readinessUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.readinessUrl)) {
|
|
316
326
|
try {
|
|
317
327
|
const url = new URL(delivery.readinessUrl);
|
|
@@ -53,8 +53,13 @@ class DeliveryDispatcher {
|
|
|
53
53
|
if (target.type !== 'httpMultipart') {
|
|
54
54
|
throw new errors_1.ConfigError(`Unsupported delivery target type: ${target.type}`);
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const urlKey = request.refetchOutcome
|
|
57
|
+
? 'refetchOutcomeUrl'
|
|
58
|
+
: request.scheduleOutcome
|
|
59
|
+
? 'scheduleOutcomeUrl'
|
|
60
|
+
: 'notificationUrl';
|
|
61
|
+
if (!target[urlKey]?.trim()) {
|
|
62
|
+
throw new errors_1.ConfigError(`Delivery target does not configure ${urlKey}: ${name}`);
|
|
58
63
|
}
|
|
59
64
|
return new HttpMultipartDelivery_1.HttpMultipartDelivery(target, this.proxyUrl).notifyOnce(request);
|
|
60
65
|
}
|
|
@@ -39,6 +39,19 @@ export interface RefetchOutcomePayload {
|
|
|
39
39
|
unavailable: number;
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
/** Terminal SCHEDULE occurrence verdict (success/partial/failed), reported via
|
|
43
|
+
* the durable outbox to TelePost, which relays the user-visible summary. */
|
|
44
|
+
export interface ScheduleOutcomePayload {
|
|
45
|
+
scheduleId: string;
|
|
46
|
+
slotId: string;
|
|
47
|
+
status: 'success' | 'partial' | 'failed';
|
|
48
|
+
targets?: Array<{
|
|
49
|
+
targetId: string;
|
|
50
|
+
workType: string;
|
|
51
|
+
status: string;
|
|
52
|
+
workId?: string | null;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
42
55
|
export declare class DeliveryService {
|
|
43
56
|
private readonly database;
|
|
44
57
|
constructor(database: Database);
|
|
@@ -96,7 +109,7 @@ export declare class DeliveryService {
|
|
|
96
109
|
created: boolean;
|
|
97
110
|
};
|
|
98
111
|
/** Enqueue a durable notification (retried independently; never affects content). */
|
|
99
|
-
enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string, refetchOutcome?: RefetchOutcomePayload): void;
|
|
112
|
+
enqueueNotification(deliveryTarget: string, text: string, idempotencyKey: string, refetchOutcome?: RefetchOutcomePayload, scheduleOutcome?: ScheduleOutcomePayload): void;
|
|
100
113
|
private guardCell;
|
|
101
114
|
private contextFrom;
|
|
102
115
|
}
|
|
@@ -175,12 +175,16 @@ 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, refetchOutcome) {
|
|
178
|
+
enqueueNotification(deliveryTarget, text, idempotencyKey, refetchOutcome, scheduleOutcome) {
|
|
179
179
|
this.database.outbox.enqueue({
|
|
180
180
|
kind: 'notification',
|
|
181
181
|
deliveryTarget,
|
|
182
182
|
idempotencyKey,
|
|
183
|
-
payload: refetchOutcome
|
|
183
|
+
payload: refetchOutcome
|
|
184
|
+
? { text, refetchOutcome }
|
|
185
|
+
: scheduleOutcome
|
|
186
|
+
? { text, scheduleOutcome }
|
|
187
|
+
: { text },
|
|
184
188
|
});
|
|
185
189
|
}
|
|
186
190
|
guardCell(slotId, targetId, next) {
|
|
@@ -134,18 +134,23 @@ class HttpMultipartDelivery {
|
|
|
134
134
|
/** Single notification attempt; the outbox owns retries. */
|
|
135
135
|
async notifyOnce(request) {
|
|
136
136
|
const outcome = request.refetchOutcome;
|
|
137
|
-
const
|
|
137
|
+
const scheduleOutcome = request.scheduleOutcome;
|
|
138
|
+
const url = (outcome ? this.config.refetchOutcomeUrl?.trim()
|
|
139
|
+
: scheduleOutcome ? this.config.scheduleOutcomeUrl?.trim()
|
|
140
|
+
: this.config.notificationUrl?.trim());
|
|
138
141
|
if (!url) {
|
|
139
142
|
throw new Error(outcome
|
|
140
143
|
? 'HTTP delivery refetchOutcomeUrl is not configured'
|
|
141
|
-
:
|
|
144
|
+
: scheduleOutcome
|
|
145
|
+
? 'HTTP delivery scheduleOutcomeUrl is not configured'
|
|
146
|
+
: 'HTTP delivery notificationUrl is not configured');
|
|
142
147
|
}
|
|
143
148
|
const headers = {
|
|
144
149
|
...this.resolveHeaders(this.config.headers ?? {}),
|
|
145
150
|
'Content-Type': 'application/json',
|
|
146
151
|
};
|
|
147
|
-
// Refetch verdicts are machine-readable JSON
|
|
148
|
-
//
|
|
152
|
+
// Refetch verdicts and schedule outcomes are machine-readable JSON for
|
|
153
|
+
// TelePost's state machines/relays. Plain notifications remain
|
|
149
154
|
// {text, idempotency_key}.
|
|
150
155
|
const body = outcome
|
|
151
156
|
? {
|
|
@@ -156,11 +161,24 @@ class HttpMultipartDelivery {
|
|
|
156
161
|
scanned: outcome.scanned,
|
|
157
162
|
skipped: outcome.skipped,
|
|
158
163
|
}
|
|
159
|
-
:
|
|
164
|
+
: scheduleOutcome
|
|
165
|
+
? {
|
|
166
|
+
schedule_id: scheduleOutcome.scheduleId,
|
|
167
|
+
slot_id: scheduleOutcome.slotId,
|
|
168
|
+
status: scheduleOutcome.status,
|
|
169
|
+
targets: (scheduleOutcome.targets ?? []).map((t) => ({
|
|
170
|
+
target_id: t.targetId,
|
|
171
|
+
work_type: t.workType,
|
|
172
|
+
status: t.status,
|
|
173
|
+
work_id: t.workId ?? null,
|
|
174
|
+
})),
|
|
175
|
+
}
|
|
176
|
+
: { text: request.text, idempotency_key: request.idempotencyKey };
|
|
160
177
|
const options = {
|
|
161
178
|
method: 'POST',
|
|
162
179
|
headers,
|
|
163
180
|
body: JSON.stringify(body),
|
|
181
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
164
182
|
};
|
|
165
183
|
if (this.dispatcher)
|
|
166
184
|
options.dispatcher = this.dispatcher;
|
|
@@ -195,6 +213,7 @@ class HttpMultipartDelivery {
|
|
|
195
213
|
body: multipart.body,
|
|
196
214
|
headers,
|
|
197
215
|
duplex: 'half',
|
|
216
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
198
217
|
};
|
|
199
218
|
if (this.dispatcher)
|
|
200
219
|
options.dispatcher = this.dispatcher;
|
|
@@ -255,6 +274,9 @@ class HttpMultipartDelivery {
|
|
|
255
274
|
return Object.fromEntries(Object.entries(fields).map(([name, value]) => {
|
|
256
275
|
const values = Array.isArray(value) ? value : [value];
|
|
257
276
|
const rendered = values.map((item) => renderDeliveryTemplate(String(item), variables));
|
|
277
|
+
if (name === 'refetch_request_id' && rendered.some((item) => /\{\{[^{}]+\}\}/.test(item))) {
|
|
278
|
+
throw new Error('Unresolved refetch_request_id template');
|
|
279
|
+
}
|
|
258
280
|
switch (this.config.arrayFormat ?? 'comma') {
|
|
259
281
|
case 'repeat':
|
|
260
282
|
return [name, rendered];
|
|
@@ -31,6 +31,8 @@ export interface NotificationPayload {
|
|
|
31
31
|
text: string;
|
|
32
32
|
/** Optional structured remote-manual-replacement verdict (refetch outcome). */
|
|
33
33
|
refetchOutcome?: unknown;
|
|
34
|
+
/** Optional structured terminal SCHEDULE occurrence verdict. */
|
|
35
|
+
scheduleOutcome?: unknown;
|
|
34
36
|
}
|
|
35
37
|
/** Exponential backoff with jitter, capped. */
|
|
36
38
|
export declare function backoffDelayMs(attempt: number, base: number, max: number): number;
|
|
@@ -214,6 +214,9 @@ class OutboxWorker {
|
|
|
214
214
|
refetchOutcome: payload.refetchOutcome !== undefined
|
|
215
215
|
? payload.refetchOutcome
|
|
216
216
|
: undefined,
|
|
217
|
+
scheduleOutcome: payload.scheduleOutcome !== undefined
|
|
218
|
+
? payload.scheduleOutcome
|
|
219
|
+
: undefined,
|
|
217
220
|
});
|
|
218
221
|
}
|
|
219
222
|
else {
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -113,6 +113,22 @@ export interface DeliveryNotificationRequest {
|
|
|
113
113
|
unavailable: number;
|
|
114
114
|
};
|
|
115
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* Structured terminal schedule verdict for a SCHEDULED occurrence. When
|
|
118
|
+
* present the delivery posts JSON to `scheduleOutcomeUrl` instead of
|
|
119
|
+
* `notificationUrl` (auth reuses `headers`).
|
|
120
|
+
*/
|
|
121
|
+
scheduleOutcome?: {
|
|
122
|
+
scheduleId: string;
|
|
123
|
+
slotId: string;
|
|
124
|
+
status: 'success' | 'partial' | 'failed';
|
|
125
|
+
targets?: Array<{
|
|
126
|
+
targetId: string;
|
|
127
|
+
workType: string;
|
|
128
|
+
status: string;
|
|
129
|
+
workId?: string | null;
|
|
130
|
+
}>;
|
|
131
|
+
};
|
|
116
132
|
}
|
|
117
133
|
export interface DeliveryProvider {
|
|
118
134
|
deliver(request: DeliveryRequest): Promise<DeliveryResult>;
|
|
@@ -45,6 +45,12 @@ export declare class NotificationPolicy {
|
|
|
45
45
|
workId: string | null;
|
|
46
46
|
error: string | null;
|
|
47
47
|
}>): void;
|
|
48
|
+
/**
|
|
49
|
+
* Delivery targets whose HTTP target declares the given outcome URL.
|
|
50
|
+
* Schedule summaries require `scheduleOutcomeUrl`; manual refetch outcomes
|
|
51
|
+
* use `refetchOutcomeUrl`; generic notifications use `notificationUrl`.
|
|
52
|
+
*/
|
|
53
|
+
private targetsWithUrl;
|
|
48
54
|
private send;
|
|
49
55
|
/**
|
|
50
56
|
* Report the terminal verdict of a REMOTE MANUAL replacement ("重抓") back to
|
|
@@ -56,6 +62,8 @@ export declare class NotificationPolicy {
|
|
|
56
62
|
* never enqueue a second verdict for the same logical attempt, and helpers
|
|
57
63
|
* that already returned remain idempotent.
|
|
58
64
|
*/
|
|
59
|
-
noteRefetchOutcome(slot: SlotContext, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
|
|
65
|
+
noteRefetchOutcome(slot: Pick<SlotContext, 'slotId'>, _schedule: ScheduleConfig, target: TargetConfig, requestId: string, outcome: TargetOutcome): void;
|
|
66
|
+
/** Report the durable terminal cell, including failures finalized after retry exhaustion. */
|
|
67
|
+
noteTerminalRefetchCell(slotId: string, targetId: string): void;
|
|
60
68
|
}
|
|
61
69
|
//# sourceMappingURL=NotificationPolicy.d.ts.map
|
|
@@ -76,8 +76,8 @@ class NotificationPolicy {
|
|
|
76
76
|
}
|
|
77
77
|
/** One consolidated summary per slot, delivered to every notifying target's endpoint. */
|
|
78
78
|
sendSlotSummary(slot, schedule, rows) {
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
79
|
+
const targets = this.targetsWithUrl('scheduleOutcomeUrl');
|
|
80
|
+
if (targets.size === 0 || rows.length === 0)
|
|
81
81
|
return;
|
|
82
82
|
const icon = (s) => s === 'submitted' ? '✅' : s === 'no_candidate' ? '⚠️' : s === 'duplicate' ? '♱' : s === 'delivery_pending' ? '🕓' : '❌';
|
|
83
83
|
const lines = rows.map((r) => `${icon(r.status)} ${r.label}(${r.workType === 'novel' ? '小说' : '插画'})` +
|
|
@@ -91,10 +91,39 @@ class NotificationPolicy {
|
|
|
91
91
|
...lines,
|
|
92
92
|
`结果:${submitted === rows.length ? 'success' : submitted > 0 ? 'partial' : 'failed'}(${submitted}/${rows.length} 已确认投递)`,
|
|
93
93
|
].join('\n');
|
|
94
|
+
const outcomeStatus = submitted === rows.length ? 'success' : submitted > 0 ? 'partial' : 'failed';
|
|
94
95
|
const service = new DeliveryService_1.DeliveryService(this.database);
|
|
95
|
-
for (const name of
|
|
96
|
-
service.enqueueNotification(name, text, NotificationPolicy.keys.summary(slot.slotId)
|
|
96
|
+
for (const name of targets) {
|
|
97
|
+
service.enqueueNotification(name, text, NotificationPolicy.keys.summary(slot.slotId), undefined, {
|
|
98
|
+
scheduleId: schedule.id,
|
|
99
|
+
slotId: slot.slotId,
|
|
100
|
+
status: outcomeStatus,
|
|
101
|
+
targets: rows.map((r) => ({
|
|
102
|
+
targetId: r.targetId,
|
|
103
|
+
workType: r.workType,
|
|
104
|
+
status: r.status,
|
|
105
|
+
workId: r.workId,
|
|
106
|
+
})),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Delivery targets whose HTTP target declares the given outcome URL.
|
|
112
|
+
* Schedule summaries require `scheduleOutcomeUrl`; manual refetch outcomes
|
|
113
|
+
* use `refetchOutcomeUrl`; generic notifications use `notificationUrl`.
|
|
114
|
+
*/
|
|
115
|
+
targetsWithUrl(urlKey) {
|
|
116
|
+
const result = new Set();
|
|
117
|
+
for (const target of this.config.targets ?? []) {
|
|
118
|
+
const deliveryTarget = target.delivery?.target;
|
|
119
|
+
if (!deliveryTarget)
|
|
120
|
+
continue;
|
|
121
|
+
const delivery = this.config.delivery?.targets?.[deliveryTarget];
|
|
122
|
+
if (delivery?.type === 'httpMultipart' && delivery[urlKey]?.trim()) {
|
|
123
|
+
result.add(deliveryTarget);
|
|
124
|
+
}
|
|
97
125
|
}
|
|
126
|
+
return result;
|
|
98
127
|
}
|
|
99
128
|
send(targetName, key, text) {
|
|
100
129
|
try {
|
|
@@ -172,6 +201,25 @@ class NotificationPolicy {
|
|
|
172
201
|
});
|
|
173
202
|
}
|
|
174
203
|
}
|
|
204
|
+
/** Report the durable terminal cell, including failures finalized after retry exhaustion. */
|
|
205
|
+
noteTerminalRefetchCell(slotId, targetId) {
|
|
206
|
+
const slot = this.database.slots.getSlot(slotId);
|
|
207
|
+
const cell = this.database.slots.getCell(slotId, targetId);
|
|
208
|
+
if (!slot?.manualRequestId || !cell)
|
|
209
|
+
return;
|
|
210
|
+
const target = this.config.targets.find((item) => item.id === targetId);
|
|
211
|
+
if (!target)
|
|
212
|
+
return;
|
|
213
|
+
const outcome = cell.status === 'no_candidate'
|
|
214
|
+
? { kind: 'no_candidate', reason: cell.lastError ?? 'no eligible candidate' }
|
|
215
|
+
: cell.status === 'duplicate'
|
|
216
|
+
? { kind: 'duplicate', workId: cell.workId ?? '', reason: cell.lastError ?? 'historical duplicate' }
|
|
217
|
+
: cell.status === 'failed'
|
|
218
|
+
? { kind: 'failed', retryable: false, error: cell.lastError ?? slot.lastError ?? 'manual refetch failed' }
|
|
219
|
+
: null;
|
|
220
|
+
if (outcome)
|
|
221
|
+
this.noteRefetchOutcome({ slotId }, { id: slot.scheduleId }, target, slot.manualRequestId, outcome);
|
|
222
|
+
}
|
|
175
223
|
}
|
|
176
224
|
exports.NotificationPolicy = NotificationPolicy;
|
|
177
225
|
/** Fold a CandidateScanSummary into the refetch-outcome bookkeeping (bounded). */
|
package/dist/package.json
CHANGED
|
@@ -105,6 +105,12 @@ export interface TriggerHandlers {
|
|
|
105
105
|
slotId: string;
|
|
106
106
|
disposition: string;
|
|
107
107
|
}>;
|
|
108
|
+
refetchStatus?(targetId: string, requestId: string): {
|
|
109
|
+
requestId: string;
|
|
110
|
+
slotId: string;
|
|
111
|
+
state: string;
|
|
112
|
+
slotStatus: string;
|
|
113
|
+
} | null;
|
|
108
114
|
}
|
|
109
115
|
export declare class ScheduleTriggerServer {
|
|
110
116
|
private readonly token;
|
|
@@ -194,6 +194,19 @@ class ScheduleTriggerServer {
|
|
|
194
194
|
res.status(status).json({ status: 'error', error: status === 500 ? 'refetch admission failed' : message });
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
|
+
app.get('/internal/targets/:targetId/refetch/:requestId', this.refetchAuth, (req, res) => {
|
|
198
|
+
const { targetId, requestId } = req.params;
|
|
199
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)) {
|
|
200
|
+
res.status(400).json({ status: 'error', error: 'requestId must be a UUID' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const status = this.handlers.refetchStatus?.(targetId, requestId);
|
|
204
|
+
if (!status) {
|
|
205
|
+
res.status(404).json({ status: 'error', error: 'manual refetch not found' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
res.json(status);
|
|
209
|
+
});
|
|
197
210
|
// Convergence endpoint: after a machine stop/start, an operator or an
|
|
198
211
|
// external watcher can ask the process to flush due deliveries/notifications
|
|
199
212
|
// without running candidate selection. Deployment-agnostic (no platform refs).
|
|
@@ -252,7 +252,20 @@ export declare class SlotCoordinator {
|
|
|
252
252
|
claimRunLease(slotId: string, owner: string, leaseMs: number): boolean;
|
|
253
253
|
heartbeatLease(slotId: string, owner: string, leaseMs: number): void;
|
|
254
254
|
releaseRunLease(slotId: string, owner: string): void;
|
|
255
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* Current durable fallback stage of a cell (0 = primary selection pass).
|
|
257
|
+
* Mirrors the repository accessor so scheduler-runtime reads one surface.
|
|
258
|
+
*/
|
|
259
|
+
cellFallbackStage(slotId: string, targetId: string): number;
|
|
260
|
+
/**
|
|
261
|
+
* Advance a recoverable candidate cell to its next durable fallback stage
|
|
262
|
+
* (§schedule-recovery). no_candidate/duplicate from a non-final stage MUST
|
|
263
|
+
* NOT terminalize: pendingTargets then re-selects the same target with
|
|
264
|
+
* expanded, still-bounded scan parameters. Returns the new stage, or the
|
|
265
|
+
* previous stage when the budget was already exhausted (callers then let the
|
|
266
|
+
* terminal outcome apply).
|
|
267
|
+
*/
|
|
268
|
+
advanceFallback(slotId: string, targetId: string, reason: string, maxStages: number): number;
|
|
256
269
|
finish(slot: SlotContext, schedule: ScheduleConfig, targets: TargetConfig[]): SlotRunSummary;
|
|
257
270
|
/**
|
|
258
271
|
* Project the terminal slot row into the one structured outcome record. Every
|
|
@@ -168,7 +168,7 @@ class SlotCoordinator {
|
|
|
168
168
|
const cell = this.database.slots.getCell(slotId, target.id);
|
|
169
169
|
if (!cell)
|
|
170
170
|
continue;
|
|
171
|
-
if (cell.status === 'submitted' || cell.status === 'no_candidate')
|
|
171
|
+
if (cell.status === 'submitted' || cell.status === 'no_candidate' || cell.status === 'duplicate' || cell.status === 'failed')
|
|
172
172
|
continue;
|
|
173
173
|
// A cell whose work already has a durable delivery intent is NOT the
|
|
174
174
|
// scheduler's to re-run: the OutboxWorker retries the SAME work to a
|
|
@@ -355,7 +355,31 @@ class SlotCoordinator {
|
|
|
355
355
|
releaseRunLease(slotId, owner) {
|
|
356
356
|
this.database.slots.releaseSlotLease(slotId, owner);
|
|
357
357
|
}
|
|
358
|
-
/**
|
|
358
|
+
/**
|
|
359
|
+
* Current durable fallback stage of a cell (0 = primary selection pass).
|
|
360
|
+
* Mirrors the repository accessor so scheduler-runtime reads one surface.
|
|
361
|
+
*/
|
|
362
|
+
cellFallbackStage(slotId, targetId) {
|
|
363
|
+
return this.database.slots.cellFallbackStage(slotId, targetId);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Advance a recoverable candidate cell to its next durable fallback stage
|
|
367
|
+
* (§schedule-recovery). no_candidate/duplicate from a non-final stage MUST
|
|
368
|
+
* NOT terminalize: pendingTargets then re-selects the same target with
|
|
369
|
+
* expanded, still-bounded scan parameters. Returns the new stage, or the
|
|
370
|
+
* previous stage when the budget was already exhausted (callers then let the
|
|
371
|
+
* terminal outcome apply).
|
|
372
|
+
*/
|
|
373
|
+
advanceFallback(slotId, targetId, reason, maxStages) {
|
|
374
|
+
const current = this.database.slots.cellFallbackStage(slotId, targetId);
|
|
375
|
+
if (current >= maxStages)
|
|
376
|
+
return current;
|
|
377
|
+
const next = this.database.slots.bumpFallbackStage(slotId, targetId, reason);
|
|
378
|
+
logger_1.logger.info('Candidate fallback advanced', {
|
|
379
|
+
slot: slotId, target: targetId, stage: next, reason: String(reason).slice(0, 160),
|
|
380
|
+
});
|
|
381
|
+
return next;
|
|
382
|
+
}
|
|
359
383
|
finish(slot, schedule, targets) {
|
|
360
384
|
const membership = this.database.slots.getSlotTargetIds(slot.slotId);
|
|
361
385
|
const ids = membership.length > 0 ? membership : targets.map((t) => t.id).filter(Boolean);
|
|
@@ -124,6 +124,7 @@ class DatabaseMigration {
|
|
|
124
124
|
work_type TEXT,
|
|
125
125
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
126
126
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
127
|
+
fallback_stage INTEGER NOT NULL DEFAULT 0,
|
|
127
128
|
last_error TEXT,
|
|
128
129
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
129
130
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -244,6 +245,10 @@ class DatabaseMigration {
|
|
|
244
245
|
if (!slotCols.includes(col))
|
|
245
246
|
columnAlters.push(sql);
|
|
246
247
|
}
|
|
248
|
+
const itemCols = this.db.prepare(`PRAGMA table_info(schedule_slot_items)`).all().map((c) => c.name);
|
|
249
|
+
if (!itemCols.includes('fallback_stage')) {
|
|
250
|
+
columnAlters.push(`ALTER TABLE schedule_slot_items ADD COLUMN fallback_stage INTEGER NOT NULL DEFAULT 0`);
|
|
251
|
+
}
|
|
247
252
|
// Create indexes for better query performance
|
|
248
253
|
const indexes = [
|
|
249
254
|
`CREATE INDEX IF NOT EXISTS idx_downloads_pixiv_id_type ON downloads(pixiv_id, type)`,
|
|
@@ -38,6 +38,12 @@ export interface SlotItemRecord {
|
|
|
38
38
|
workType: string | null;
|
|
39
39
|
status: CellStatus;
|
|
40
40
|
attemptCount: number;
|
|
41
|
+
/**
|
|
42
|
+
* Bounded candidate-fallback depth for this cell (§schedule-recovery). 0 means
|
|
43
|
+
* the primary selection pass; each advance re-selects the SAME target with
|
|
44
|
+
* expanded scan bounds. Durable so a crash-resume re-enters at the same stage.
|
|
45
|
+
*/
|
|
46
|
+
fallback_stage: number;
|
|
41
47
|
lastError: string | null;
|
|
42
48
|
createdAt: string;
|
|
43
49
|
updatedAt: string;
|
|
@@ -53,6 +59,8 @@ export interface SlotItemRecord {
|
|
|
53
59
|
* the same row instead of emitting a second work for the same slot/target.
|
|
54
60
|
*/
|
|
55
61
|
export declare class SlotRepository extends BaseRepository {
|
|
62
|
+
/** Exact manual request/target lookup for authenticated convergence checks. */
|
|
63
|
+
findManualSlot(requestId: string, targetId: string): SlotRecord | null;
|
|
56
64
|
/**
|
|
57
65
|
* Fetch an existing slot or create it. On creation the schedule's target
|
|
58
66
|
* membership is snapshotted (target_ids); a later config reload never mutates
|
|
@@ -129,6 +137,9 @@ export declare class SlotRepository extends BaseRepository {
|
|
|
129
137
|
releaseCellWork(slotId: string, targetId: string, workId: string): boolean;
|
|
130
138
|
/** Explicit operator action: forget the locked work so a re-run picks another candidate. */
|
|
131
139
|
clearCellWork(slotId: string, targetId: string): void;
|
|
140
|
+
/** Durable candidate-fallback bookkeeping (§schedule-recovery). */
|
|
141
|
+
bumpFallbackStage(slotId: string, targetId: string, reason: string): number;
|
|
142
|
+
cellFallbackStage(slotId: string, targetId: string): number;
|
|
132
143
|
setCellStatus(slotId: string, targetId: string, status: CellStatus, error?: string): void;
|
|
133
144
|
/**
|
|
134
145
|
* Transition a cell with FSM validation. Never downgrades a confirmed cell;
|
|
@@ -12,6 +12,11 @@ const BaseRepository_1 = require("./BaseRepository");
|
|
|
12
12
|
* the same row instead of emitting a second work for the same slot/target.
|
|
13
13
|
*/
|
|
14
14
|
class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
15
|
+
/** Exact manual request/target lookup for authenticated convergence checks. */
|
|
16
|
+
findManualSlot(requestId, targetId) {
|
|
17
|
+
const rows = this.db.prepare(`SELECT * FROM schedule_slots WHERE manual_request_id = ?`).all(requestId);
|
|
18
|
+
return rows.map((row) => this.toSlot(row)).find((slot) => slot.targetIds.includes(targetId)) ?? null;
|
|
19
|
+
}
|
|
15
20
|
/**
|
|
16
21
|
* Fetch an existing slot or create it. On creation the schedule's target
|
|
17
22
|
* membership is snapshotted (target_ids); a later config reload never mutates
|
|
@@ -193,6 +198,28 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
|
193
198
|
WHERE slot_id = @slotId AND target_id = @targetId`)
|
|
194
199
|
.run({ slotId, targetId });
|
|
195
200
|
}
|
|
201
|
+
/** Durable candidate-fallback bookkeeping (§schedule-recovery). */
|
|
202
|
+
bumpFallbackStage(slotId, targetId, reason) {
|
|
203
|
+
const item = this.db
|
|
204
|
+
.prepare(`SELECT fallback_stage FROM schedule_slot_items WHERE slot_id = ? AND target_id = ?`)
|
|
205
|
+
.get(slotId, targetId);
|
|
206
|
+
const stage = Number(item?.fallback_stage ?? 0);
|
|
207
|
+
this.db
|
|
208
|
+
.prepare(`UPDATE schedule_slot_items
|
|
209
|
+
SET fallback_stage = ?, last_error = ?,
|
|
210
|
+
status = 'pending',
|
|
211
|
+
updated_at = CURRENT_TIMESTAMP,
|
|
212
|
+
completed_at = NULL
|
|
213
|
+
WHERE slot_id = ? AND target_id = ?`)
|
|
214
|
+
.run(stage + 1, String(reason).slice(0, 400), slotId, targetId);
|
|
215
|
+
return stage + 1;
|
|
216
|
+
}
|
|
217
|
+
cellFallbackStage(slotId, targetId) {
|
|
218
|
+
const item = this.db
|
|
219
|
+
.prepare(`SELECT fallback_stage FROM schedule_slot_items WHERE slot_id = ? AND target_id = ?`)
|
|
220
|
+
.get(slotId, targetId);
|
|
221
|
+
return Number(item?.fallback_stage ?? 0);
|
|
222
|
+
}
|
|
196
223
|
setCellStatus(slotId, targetId, status, error) {
|
|
197
224
|
const terminal = status === 'submitted' ||
|
|
198
225
|
status === 'no_candidate' ||
|
|
@@ -374,6 +401,7 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
|
374
401
|
workType: row.work_type,
|
|
375
402
|
status: row.status,
|
|
376
403
|
attemptCount: row.attempt_count,
|
|
404
|
+
fallback_stage: Number(row.fallback_stage ?? 0),
|
|
377
405
|
lastError: row.last_error,
|
|
378
406
|
createdAt: row.created_at,
|
|
379
407
|
updatedAt: row.updated_at,
|
|
@@ -261,6 +261,20 @@ class ConfigValidator {
|
|
|
261
261
|
});
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
if (delivery.scheduleOutcomeUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.scheduleOutcomeUrl)) {
|
|
265
|
+
try {
|
|
266
|
+
const url = new URL(delivery.scheduleOutcomeUrl);
|
|
267
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
268
|
+
throw new Error('unsupported protocol');
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
errors.push({
|
|
272
|
+
code: 'CONFIG_VALIDATION_DELIVERY_SCHEDULE_OUTCOME_URL_INVALID',
|
|
273
|
+
field: `${prefix}.scheduleOutcomeUrl`,
|
|
274
|
+
message: `Delivery target '${name}': scheduleOutcomeUrl must be valid HTTP or HTTPS`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
264
278
|
if (delivery.readinessUrl && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(delivery.readinessUrl)) {
|
|
265
279
|
try {
|
|
266
280
|
const url = new URL(delivery.readinessUrl);
|
package/dist/version.js
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BUILD = void 0;
|
|
4
4
|
// GENERATED by scripts/write-version.js — do not edit manually.
|
|
5
|
-
exports.BUILD = { version: '2.20.
|
|
5
|
+
exports.BUILD = { version: '2.20.3', commit: '6ebffc579194' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
package/package.json
CHANGED