pixivflow 2.19.5 → 2.20.1
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 +33 -8
- package/dist/delivery/OutboxWorker.d.ts +2 -0
- package/dist/delivery/OutboxWorker.js +3 -0
- package/dist/delivery/types.d.ts +24 -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
|
@@ -25,6 +25,31 @@ function deliveryTargetOf(target) {
|
|
|
25
25
|
const name = target.delivery?.target;
|
|
26
26
|
return typeof name === 'string' && name.trim() ? name.trim() : null;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* SQLite `CURRENT_TIMESTAMP` is UTC "YYYY-MM-DD HH:MM:SS". It carries no zone
|
|
30
|
+
* marker, and `Date.parse` reads that shape as LOCAL time — so the zone is
|
|
31
|
+
* added explicitly instead of being trusted to the engine.
|
|
32
|
+
*/
|
|
33
|
+
function sqliteUtcMs(value) {
|
|
34
|
+
if (!value)
|
|
35
|
+
return undefined;
|
|
36
|
+
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
|
|
37
|
+
? `${value.replace(' ', 'T')}Z`
|
|
38
|
+
: value;
|
|
39
|
+
const ms = Date.parse(normalized);
|
|
40
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Epoch ms -> ISO 8601 UTC, or undefined when it is not a usable instant.
|
|
44
|
+
* `toISOString` throws on an out-of-range Date, and a throw here would abort the
|
|
45
|
+
* terminal rollup — a logging field must never be able to break dispatch.
|
|
46
|
+
*/
|
|
47
|
+
function isoUtcOrUndefined(epochMs) {
|
|
48
|
+
if (typeof epochMs !== 'number' || !Number.isFinite(epochMs))
|
|
49
|
+
return undefined;
|
|
50
|
+
const date = new Date(epochMs);
|
|
51
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
52
|
+
}
|
|
28
53
|
/**
|
|
29
54
|
* Owns the Schedule Slot ledger for one scheduler run. A Slot is one durable
|
|
30
55
|
* execution occurrence of a Schedule (NOT a morning/evening row). It ensures
|
|
@@ -102,6 +127,8 @@ class SlotCoordinator {
|
|
|
102
127
|
triggerSource: slot.triggerSource,
|
|
103
128
|
slotDate: slot.slotDate,
|
|
104
129
|
slotName: slot.slotName,
|
|
130
|
+
manualRequestId: slot.manualRequestId ?? null,
|
|
131
|
+
correlationId: slot.correlationId ?? null,
|
|
105
132
|
});
|
|
106
133
|
if (created) {
|
|
107
134
|
// Freeze membership: materialize one cell per target id from the snapshot.
|
|
@@ -347,25 +374,139 @@ class SlotCoordinator {
|
|
|
347
374
|
}
|
|
348
375
|
const status = this.database.slots.deriveSlotStatus(slot.slotId);
|
|
349
376
|
this.database.slots.markSlotStatus(slot.slotId, status);
|
|
350
|
-
this.persistExecutionSummary(slot.slotId, status);
|
|
351
377
|
const cells = this.database.slots.getCells(slot.slotId).map((c) => ({
|
|
352
378
|
targetId: c.targetId,
|
|
353
379
|
status: c.status,
|
|
354
380
|
workId: c.workId,
|
|
355
381
|
error: c.lastError,
|
|
356
382
|
}));
|
|
383
|
+
// Rolled up AFTER the slot row carries its terminal status/completed_at, so
|
|
384
|
+
// the outcome reports the durable timestamps rather than a fresh clock read.
|
|
385
|
+
this.persistExecutionSummary(slot.slotId, status, this.scheduleOutcome(slot, status, targets));
|
|
357
386
|
const icon = (s) => s === 'submitted' ? '✅' : s === 'no_candidate' ? '⚠️ no_candidate' : '❌ failed';
|
|
358
387
|
logger_1.logger.info(`Slot ${slot.slotId} ${status}\n` +
|
|
359
388
|
cells.map((c) => ` ${c.targetId.padEnd(28)} ${icon(c.status)} ${c.workId ? '#' + c.workId : ''} ${c.error ? '(' + c.error + ')' : ''}`).join('\n'), { slot: slot.slotId, status });
|
|
360
389
|
return { scheduleId: schedule.id, slotId: slot.slotId, status, alreadyCompleted: false, cells };
|
|
361
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Project the terminal slot row into the one structured outcome record. Every
|
|
393
|
+
* field comes from durable state — the slot row, its cells, and the delivery
|
|
394
|
+
* ledger. Nothing is inferred from error-message text.
|
|
395
|
+
*/
|
|
396
|
+
scheduleOutcome(slot, status, targets) {
|
|
397
|
+
const slotRec = this.database.slots.getSlot(slot.slotId);
|
|
398
|
+
const cellRows = this.database.slots.getCells(slot.slotId);
|
|
399
|
+
// Only targets the caller still knows about can name a delivery channel; a
|
|
400
|
+
// cell with no known channel has no delivery fact to consult.
|
|
401
|
+
const deliveryTargetByTargetId = new Map();
|
|
402
|
+
for (const target of targets) {
|
|
403
|
+
const deliveryTarget = deliveryTargetOf(target);
|
|
404
|
+
if (target.id && deliveryTarget)
|
|
405
|
+
deliveryTargetByTargetId.set(target.id, deliveryTarget);
|
|
406
|
+
}
|
|
407
|
+
let submitted = 0;
|
|
408
|
+
let no_match = 0;
|
|
409
|
+
let duplicate = 0;
|
|
410
|
+
let executor_failed = 0;
|
|
411
|
+
let delivery_failed = 0;
|
|
412
|
+
const targetsDetail = cellRows.map((cell) => {
|
|
413
|
+
const classified = this.classifyCell(cell.status, slot.slotId, cell.targetId, deliveryTargetByTargetId);
|
|
414
|
+
if (classified === 'submitted')
|
|
415
|
+
submitted += 1;
|
|
416
|
+
else if (classified === 'no_match')
|
|
417
|
+
no_match += 1;
|
|
418
|
+
else if (classified === 'duplicate')
|
|
419
|
+
duplicate += 1;
|
|
420
|
+
else if (classified === 'delivery_failed')
|
|
421
|
+
delivery_failed += 1;
|
|
422
|
+
else if (classified === 'executor_failed')
|
|
423
|
+
executor_failed += 1;
|
|
424
|
+
return {
|
|
425
|
+
target_id: cell.targetId,
|
|
426
|
+
status: cell.status,
|
|
427
|
+
work_id: cell.workId,
|
|
428
|
+
error: cell.lastError,
|
|
429
|
+
};
|
|
430
|
+
});
|
|
431
|
+
// A fully-submitted slot has no non-submitted cell at all, and reporting
|
|
432
|
+
// `all_duplicates` there would be a lie — so the count must be non-zero.
|
|
433
|
+
const nonSubmitted = cellRows.length - submitted;
|
|
434
|
+
const all_duplicates = nonSubmitted > 0 && duplicate === nonSubmitted;
|
|
435
|
+
const startedMs = sqliteUtcMs(slotRec?.startedAt);
|
|
436
|
+
const completedMs = sqliteUtcMs(slotRec?.completedAt);
|
|
437
|
+
const duration_ms = startedMs !== undefined && completedMs !== undefined
|
|
438
|
+
? Math.max(0, completedMs - startedMs)
|
|
439
|
+
: undefined;
|
|
440
|
+
const occurrenceAt = slotRec?.occurrenceAt ?? slot.occurrenceAt;
|
|
441
|
+
return {
|
|
442
|
+
event: 'schedule.outcome',
|
|
443
|
+
schedule_id: slotRec?.scheduleId ?? slot.scheduleId,
|
|
444
|
+
slot_id: slot.slotId,
|
|
445
|
+
occurrence_at: isoUtcOrUndefined(occurrenceAt),
|
|
446
|
+
occurrence_date: slotRec?.occurrenceDate ?? slot.occurrenceDate,
|
|
447
|
+
status: status,
|
|
448
|
+
duration_ms,
|
|
449
|
+
cells: {
|
|
450
|
+
total: cellRows.length,
|
|
451
|
+
submitted,
|
|
452
|
+
no_match,
|
|
453
|
+
duplicate,
|
|
454
|
+
all_duplicates,
|
|
455
|
+
executor_failed,
|
|
456
|
+
delivery_failed,
|
|
457
|
+
targets: targetsDetail,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* One cell -> one business category. `delivery_failed` is derived from the
|
|
463
|
+
* durable delivery ledger through the SAME port the FSM already uses, never
|
|
464
|
+
* from an error string, and it is decided BEFORE `executor_failed` because a
|
|
465
|
+
* terminally lost delivery also leaves the cell `failed`: one cause, one count.
|
|
466
|
+
*/
|
|
467
|
+
classifyCell(cellStatus, slotId, targetId, deliveryTargetByTargetId) {
|
|
468
|
+
if (cellStatus === 'submitted')
|
|
469
|
+
return 'submitted';
|
|
470
|
+
if (cellStatus === 'no_candidate')
|
|
471
|
+
return 'no_match';
|
|
472
|
+
if (cellStatus === 'duplicate')
|
|
473
|
+
return 'duplicate';
|
|
474
|
+
const deliveryTarget = deliveryTargetByTargetId.get(targetId);
|
|
475
|
+
if (deliveryTarget && this.delivery) {
|
|
476
|
+
try {
|
|
477
|
+
const state = this.delivery.stateFor({ deliveryTarget, slotId, targetId });
|
|
478
|
+
if (state.kind === 'lost')
|
|
479
|
+
return 'delivery_failed';
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
// Observability must never break the rollup.
|
|
483
|
+
logger_1.logger.debug('Delivery state unavailable for outcome rollup', {
|
|
484
|
+
slot: slotId,
|
|
485
|
+
target: targetId,
|
|
486
|
+
error: error.message,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (cellStatus === 'failed')
|
|
491
|
+
return 'executor_failed';
|
|
492
|
+
// Non-terminal (delivery_pending / artifact_ready): such a slot is not
|
|
493
|
+
// terminal either, so no outcome record is written for it at all.
|
|
494
|
+
return 'other';
|
|
495
|
+
}
|
|
362
496
|
/**
|
|
363
497
|
* Persist a one-row incident/execution summary into delivery_events
|
|
364
498
|
* (event='execution.summary') at the terminal rollup only. Event-row storage
|
|
365
499
|
* reuses the audit table + `runs show` read path instead of inventing a
|
|
366
500
|
* summary table or overloading execution_log's illustration/novel typing.
|
|
501
|
+
*
|
|
502
|
+
* The `schedule.outcome` line is emitted HERE, immediately after that durable
|
|
503
|
+
* write and behind the SAME `hasExecutionSummary` dedupe, because the summary
|
|
504
|
+
* row is the occurrence-level terminal identity that already exists. Recovery
|
|
505
|
+
* that re-rolls the same terminal slot therefore logs nothing a second time,
|
|
506
|
+
* with no shadow ledger and no second event name. The identical object goes to
|
|
507
|
+
* both sinks, so the line and the row cannot disagree.
|
|
367
508
|
*/
|
|
368
|
-
persistExecutionSummary(slotId, status) {
|
|
509
|
+
persistExecutionSummary(slotId, status, outcome) {
|
|
369
510
|
if (status !== 'success' && status !== 'partial' && status !== 'failed')
|
|
370
511
|
return;
|
|
371
512
|
try {
|
|
@@ -377,8 +518,9 @@ class SlotCoordinator {
|
|
|
377
518
|
slotId,
|
|
378
519
|
event: 'execution.summary',
|
|
379
520
|
countsAsAttempt: 0,
|
|
380
|
-
detail: { summary },
|
|
521
|
+
detail: { summary, outcome },
|
|
381
522
|
});
|
|
523
|
+
logger_1.logger.info('Schedule occurrence reached a terminal outcome', outcome);
|
|
382
524
|
}
|
|
383
525
|
catch (error) {
|
|
384
526
|
logger_1.logger.debug('Failed to persist execution summary', { slot: slotId, error: error.message });
|
|
@@ -99,6 +99,15 @@ class DatabaseMigration {
|
|
|
99
99
|
lease_owner TEXT,
|
|
100
100
|
lease_until INTEGER,
|
|
101
101
|
heartbeat_at INTEGER,
|
|
102
|
+
-- Request UUID of a remote manual replacement ("重抓"). NULL for a
|
|
103
|
+
-- scheduled occurrence. Persisted in the SAME transaction that
|
|
104
|
+
-- opens the manual slot, so the acceptance ACK and the caller's
|
|
105
|
+
-- retry converge on one row instead of racing a second slot.
|
|
106
|
+
manual_request_id TEXT,
|
|
107
|
+
-- Opaque caller correlation (review chain / review id). Recorded so
|
|
108
|
+
-- the terminal outcome can be reported back to the requester
|
|
109
|
+
-- without this service learning anything about the review.
|
|
110
|
+
correlation_id TEXT,
|
|
102
111
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
103
112
|
started_at DATETIME,
|
|
104
113
|
completed_at DATETIME,
|
|
@@ -227,6 +236,8 @@ class DatabaseMigration {
|
|
|
227
236
|
lease_owner: 'ALTER TABLE schedule_slots ADD COLUMN lease_owner TEXT',
|
|
228
237
|
lease_until: 'ALTER TABLE schedule_slots ADD COLUMN lease_until INTEGER',
|
|
229
238
|
heartbeat_at: 'ALTER TABLE schedule_slots ADD COLUMN heartbeat_at INTEGER',
|
|
239
|
+
manual_request_id: 'ALTER TABLE schedule_slots ADD COLUMN manual_request_id TEXT',
|
|
240
|
+
correlation_id: 'ALTER TABLE schedule_slots ADD COLUMN correlation_id TEXT',
|
|
230
241
|
};
|
|
231
242
|
const columnAlters = [];
|
|
232
243
|
for (const [col, sql] of Object.entries(slotColumnMigrations)) {
|
|
@@ -25,6 +25,10 @@ export interface SlotRecord {
|
|
|
25
25
|
leaseOwner: string | null;
|
|
26
26
|
leaseUntil: number | null;
|
|
27
27
|
heartbeatAt: number | null;
|
|
28
|
+
/** Request UUID of a remote manual replacement; null for scheduled slots. */
|
|
29
|
+
manualRequestId: string | null;
|
|
30
|
+
/** Opaque caller correlation (review chain / review id); null unless manual. */
|
|
31
|
+
correlationId: string | null;
|
|
28
32
|
}
|
|
29
33
|
export interface SlotItemRecord {
|
|
30
34
|
id: number;
|
|
@@ -65,6 +69,10 @@ export declare class SlotRepository extends BaseRepository {
|
|
|
65
69
|
triggerSource?: string;
|
|
66
70
|
slotDate?: string;
|
|
67
71
|
slotName?: string;
|
|
72
|
+
/** Remote manual replacement request UUID (opens a `manual-` slot). */
|
|
73
|
+
manualRequestId?: string | null;
|
|
74
|
+
/** Opaque caller correlation recorded with a manual slot. */
|
|
75
|
+
correlationId?: string | null;
|
|
68
76
|
}): {
|
|
69
77
|
slot: SlotRecord;
|
|
70
78
|
created: boolean;
|
|
@@ -21,10 +21,10 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
|
21
21
|
getOrCreateSlot(id, data) {
|
|
22
22
|
const insert = this.db.prepare(`INSERT INTO schedule_slots
|
|
23
23
|
(id, schedule_id, occurrence_at, occurrence_date, occurrence_label, timezone, target_ids,
|
|
24
|
-
status, trigger_source, slot_date, slot_name)
|
|
24
|
+
status, trigger_source, slot_date, slot_name, manual_request_id, correlation_id)
|
|
25
25
|
VALUES
|
|
26
26
|
(@id, @scheduleId, @occurrenceAt, @occurrenceDate, @occurrenceLabel, @timezone, @targetIds,
|
|
27
|
-
'pending', @triggerSource, @slotDate, @slotName)
|
|
27
|
+
'pending', @triggerSource, @slotDate, @slotName, @manualRequestId, @correlationId)
|
|
28
28
|
ON CONFLICT(id) DO NOTHING`);
|
|
29
29
|
const info = insert.run({
|
|
30
30
|
id,
|
|
@@ -37,6 +37,8 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
|
37
37
|
triggerSource: data.triggerSource ?? null,
|
|
38
38
|
slotDate: data.slotDate ?? data.occurrenceDate ?? '',
|
|
39
39
|
slotName: data.slotName ?? '',
|
|
40
|
+
manualRequestId: data.manualRequestId ?? null,
|
|
41
|
+
correlationId: data.correlationId ?? null,
|
|
40
42
|
});
|
|
41
43
|
const created = info.changes > 0;
|
|
42
44
|
return { slot: this.getSlot(id), created };
|
|
@@ -359,6 +361,8 @@ class SlotRepository extends BaseRepository_1.BaseRepository {
|
|
|
359
361
|
leaseOwner: row.lease_owner ?? null,
|
|
360
362
|
leaseUntil: row.lease_until ?? null,
|
|
361
363
|
heartbeatAt: row.heartbeat_at ?? null,
|
|
364
|
+
manualRequestId: row.manual_request_id ?? null,
|
|
365
|
+
correlationId: row.correlation_id ?? null,
|
|
362
366
|
};
|
|
363
367
|
}
|
|
364
368
|
toItem(row) {
|
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.
|
|
5
|
+
exports.BUILD = { version: '2.20.1', commit: '834a545f7368' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
package/package.json
CHANGED