pixivflow 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/commands/SchedulerCommand.js +31 -51
- package/dist/commands/scheduler-runtime.js +47 -7
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +6 -0
- package/dist/config/types.d.ts +20 -0
- package/dist/config/validation.js +11 -0
- package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
- package/dist/delivery/DeliveryDispatcher.js +14 -0
- package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
- package/dist/delivery/EventCallbackDelivery.js +89 -0
- package/dist/delivery/OutboxWorker.d.ts +11 -0
- package/dist/delivery/OutboxWorker.js +51 -1
- package/dist/package.json +1 -1
- package/dist/scheduler/CandidateSearchParams.d.ts +76 -0
- package/dist/scheduler/CandidateSearchParams.js +94 -0
- package/dist/scheduler/JobCancellation.d.ts +36 -0
- package/dist/scheduler/JobCancellation.js +73 -0
- package/dist/scheduler/JobEventStream.d.ts +94 -0
- package/dist/scheduler/JobEventStream.js +251 -0
- package/dist/scheduler/JobFacade.d.ts +298 -0
- package/dist/scheduler/JobFacade.js +622 -0
- package/dist/scheduler/JobProjection.d.ts +67 -0
- package/dist/scheduler/JobProjection.js +32 -0
- package/dist/scheduler/JobView.d.ts +34 -0
- package/dist/scheduler/JobView.js +82 -0
- package/dist/scheduler/ManualJobAdmission.d.ts +126 -0
- package/dist/scheduler/ManualJobAdmission.js +310 -0
- package/dist/scheduler/ManualJobService.d.ts +57 -0
- package/dist/scheduler/ManualJobService.js +91 -0
- package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
- package/dist/scheduler/ManualRefetchAdapter.js +41 -0
- package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
- package/dist/scheduler/MultiScheduleManager.js +68 -6
- package/dist/scheduler/ProtocolErrors.d.ts +68 -0
- package/dist/scheduler/ProtocolErrors.js +94 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
- package/dist/scheduler/ScheduleTriggerServer.js +166 -1
- package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
- package/dist/scheduler/SlotBusinessStatus.js +6 -0
- package/dist/scheduler/SlotCoordinator.d.ts +49 -5
- package/dist/scheduler/SlotCoordinator.js +89 -22
- package/dist/scheduler/StallSweep.d.ts +65 -0
- package/dist/scheduler/StallSweep.js +105 -0
- package/dist/scheduler/TargetOutcome.d.ts +39 -1
- package/dist/scheduler/TargetOutcome.js +51 -1
- package/dist/scheduler/ledger-time.d.ts +23 -0
- package/dist/scheduler/ledger-time.js +37 -0
- package/dist/storage/DatabaseMigration.js +48 -0
- package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
- package/dist/storage/repositories/DeliveryRepository.js +13 -0
- package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
- package/dist/storage/repositories/OutboxRepository.js +142 -0
- package/dist/storage/repositories/SlotRepository.d.ts +34 -0
- package/dist/storage/repositories/SlotRepository.js +61 -2
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/examples/gateway/README.md +4 -0
- package/examples/onebot-adapter/README.md +139 -0
- package/examples/onebot-adapter/server.mjs +612 -0
- package/package.json +2 -1
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The producer-side projection layer of Workflow Protocol v1 (§11.1).
|
|
4
|
+
*
|
|
5
|
+
* Everything a consumer sees about a job is built here from the SAME durable
|
|
6
|
+
* ledger the legacy endpoints read: no second state store, no parallel status
|
|
7
|
+
* machine, no consumer vocabulary. The projection deliberately speaks only
|
|
8
|
+
* protocol words — `job_id`, `status`, `progress`, `error` — while the internal
|
|
9
|
+
* `TerminalReasonCode` is preserved inside `error.detail.internal_code` for
|
|
10
|
+
* diagnosis and mapped to the closed protocol code at this single site.
|
|
11
|
+
*
|
|
12
|
+
* `GET /capabilities` is likewise derived from the live configuration, so a
|
|
13
|
+
* config change (budget, deadline ceiling) is reflected without a code change.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND = exports.TERMINAL_JOB_EVENT_KINDS = exports.JOB_EVENT_KINDS = exports.PROTOCOL_EVENT_TYPES = exports.PROTOCOL_VERSIONS = exports.JOB_TYPE_CANDIDATE_SEARCH = void 0;
|
|
17
|
+
exports.protocolJobStatus = protocolJobStatus;
|
|
18
|
+
exports.protocolProgressStage = protocolProgressStage;
|
|
19
|
+
exports.buildProtocolJob = buildProtocolJob;
|
|
20
|
+
exports.resolveDeadlineMs = resolveDeadlineMs;
|
|
21
|
+
exports.resolveDefaultDeadlineMs = resolveDefaultDeadlineMs;
|
|
22
|
+
exports.parseTaskBody = parseTaskBody;
|
|
23
|
+
exports.buildCapabilities = buildCapabilities;
|
|
24
|
+
exports.terminalJobEventKind = terminalJobEventKind;
|
|
25
|
+
exports.projectedInternalKinds = projectedInternalKinds;
|
|
26
|
+
exports.isProjectedInternalKind = isProjectedInternalKind;
|
|
27
|
+
exports.protocolEventTypeFor = protocolEventTypeFor;
|
|
28
|
+
exports.protocolEventId = protocolEventId;
|
|
29
|
+
exports.parseProtocolEventId = parseProtocolEventId;
|
|
30
|
+
exports.buildProtocolEvent = buildProtocolEvent;
|
|
31
|
+
exports.buildEventPage = buildEventPage;
|
|
32
|
+
exports.buildAckResult = buildAckResult;
|
|
33
|
+
exports.parseAckBody = parseAckBody;
|
|
34
|
+
exports.parseEventQuery = parseEventQuery;
|
|
35
|
+
const logger_1 = require("../logger");
|
|
36
|
+
const CandidateSearchParams_1 = require("./CandidateSearchParams");
|
|
37
|
+
const ProtocolErrors_1 = require("./ProtocolErrors");
|
|
38
|
+
const Scheduler_1 = require("./Scheduler");
|
|
39
|
+
const SlotCoordinator_1 = require("./SlotCoordinator");
|
|
40
|
+
const StallSweep_1 = require("./StallSweep");
|
|
41
|
+
const TargetOutcome_1 = require("./TargetOutcome");
|
|
42
|
+
/** Bounds that keep one request from becoming an unbounded allocation. */
|
|
43
|
+
const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
|
|
44
|
+
const MAX_CORRELATION_ID_LENGTH = 200;
|
|
45
|
+
const MAX_CALLBACK_URL_LENGTH = 2000;
|
|
46
|
+
const MAX_TAG_LENGTH = 100;
|
|
47
|
+
const MAX_TAGS = 20;
|
|
48
|
+
const MAX_EXCLUSIONS = 100;
|
|
49
|
+
/** The only job type this producer serves today. */
|
|
50
|
+
exports.JOB_TYPE_CANDIDATE_SEARCH = 'candidate_search';
|
|
51
|
+
/** `$defs/ProtocolVersion` — this producer speaks exactly one version. */
|
|
52
|
+
exports.PROTOCOL_VERSIONS = ['1'];
|
|
53
|
+
/**
|
|
54
|
+
* Classify the durable cell state.
|
|
55
|
+
*
|
|
56
|
+
* The CELL is the truth: it is the unit that executes and the unit the manual
|
|
57
|
+
* surface submits. Budget verdicts (`queued_too_long`, `stalled_no_heartbeat`,
|
|
58
|
+
* `execution_timeout`) end as protocol `expired` per §4 — they describe a lost
|
|
59
|
+
* liveness, not a Pixiv failure — while a consumer cancel is `cancelled`.
|
|
60
|
+
*/
|
|
61
|
+
function protocolJobStatus(projection) {
|
|
62
|
+
const cell = projection.state;
|
|
63
|
+
if (cell === 'submitted')
|
|
64
|
+
return 'succeeded';
|
|
65
|
+
if (cell === 'pending')
|
|
66
|
+
return 'queued';
|
|
67
|
+
if (cell === 'selected' || cell === 'artifact_ready' || cell === 'delivery_pending') {
|
|
68
|
+
return 'running';
|
|
69
|
+
}
|
|
70
|
+
const reason = projection.terminalReasonCode;
|
|
71
|
+
if (reason === 'cancelled_by_consumer')
|
|
72
|
+
return 'cancelled';
|
|
73
|
+
if (projection.slotStatus === 'expired' ||
|
|
74
|
+
reason === 'queued_too_long' ||
|
|
75
|
+
reason === 'stalled_no_heartbeat' ||
|
|
76
|
+
reason === 'execution_timeout') {
|
|
77
|
+
return 'expired';
|
|
78
|
+
}
|
|
79
|
+
return 'failed';
|
|
80
|
+
}
|
|
81
|
+
/** The protocol-visible stage label for a job status (§2 `Progress.stage`). */
|
|
82
|
+
function protocolProgressStage(status, cellStatus) {
|
|
83
|
+
switch (status) {
|
|
84
|
+
case 'queued':
|
|
85
|
+
return 'queued';
|
|
86
|
+
case 'running':
|
|
87
|
+
return cellStatus === 'selected' ? 'searching' : 'delivering';
|
|
88
|
+
case 'succeeded':
|
|
89
|
+
return 'done';
|
|
90
|
+
default:
|
|
91
|
+
return status;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** `$defs/Candidate.work_type` is a closed enum; anything else is `unknown`. */
|
|
95
|
+
function protocolWorkType(workType) {
|
|
96
|
+
return workType === 'illustration' || workType === 'novel' ? workType : 'unknown';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build the `$defs/Job` body for one job.
|
|
100
|
+
*
|
|
101
|
+
* A terminal job always carries `result` or `error` (§4). The result is built
|
|
102
|
+
* from what the ledger actually knows — the delivered work item and the
|
|
103
|
+
* persisted supply counts — never from a re-derived guestimate: `filtered` is
|
|
104
|
+
* omitted because per-work rejection reasons are not persisted (only counts).
|
|
105
|
+
*/
|
|
106
|
+
function buildProtocolJob(input) {
|
|
107
|
+
const now = input.now ?? Date.now();
|
|
108
|
+
const projection = input.projection;
|
|
109
|
+
const status = protocolJobStatus(projection);
|
|
110
|
+
const created = projection.createdAt ?? now;
|
|
111
|
+
const updated = projection.updatedAt ?? created;
|
|
112
|
+
const job = {
|
|
113
|
+
protocol_version: '1',
|
|
114
|
+
job_id: projection.slotId,
|
|
115
|
+
job_type: input.jobType ?? exports.JOB_TYPE_CANDIDATE_SEARCH,
|
|
116
|
+
status,
|
|
117
|
+
created_at: created,
|
|
118
|
+
updated_at: updated,
|
|
119
|
+
started_at: projection.startedAt,
|
|
120
|
+
heartbeat_at: projection.heartbeatAt,
|
|
121
|
+
deadline_at: input.deadlineAt ?? null,
|
|
122
|
+
lease_active: projection.leaseActive,
|
|
123
|
+
lease_expires_at: projection.leaseExpiresAt,
|
|
124
|
+
attempt: projection.attemptCount,
|
|
125
|
+
progress: {
|
|
126
|
+
stage: protocolProgressStage(status, projection.state),
|
|
127
|
+
at: updated,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
if (projection.idempotencyKey)
|
|
131
|
+
job.idempotency_key = projection.idempotencyKey;
|
|
132
|
+
if (projection.correlationId)
|
|
133
|
+
job.correlation_id = projection.correlationId;
|
|
134
|
+
job.events_url = `/jobs/${job.job_id}/events`;
|
|
135
|
+
if (status === 'succeeded') {
|
|
136
|
+
job.result = buildCandidateSearchResult(input.delivered ?? null, input.supply ?? null);
|
|
137
|
+
}
|
|
138
|
+
else if (status === 'failed' || status === 'cancelled' || status === 'expired') {
|
|
139
|
+
const internal = projection.terminalReasonCode;
|
|
140
|
+
const code = (0, TargetOutcome_1.protocolErrorCodeForTerminalReason)(internal) ?? 'internal_error';
|
|
141
|
+
job.error = (0, ProtocolErrors_1.protocolErrorBody)(code, {
|
|
142
|
+
...(projection.terminalReasonMessage ? { message: projection.terminalReasonMessage } : {}),
|
|
143
|
+
detail: internal ? { internal_code: internal } : undefined,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return job;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `$defs/Result_CandidateSearch` from the durable outcome.
|
|
150
|
+
*
|
|
151
|
+
* `scanned` is the persisted pre-filter count (`fetched`); when the ledger has
|
|
152
|
+
* no supply report the delivered candidate count is the only honest lower
|
|
153
|
+
* bound.
|
|
154
|
+
*/
|
|
155
|
+
function buildCandidateSearchResult(delivered, supply) {
|
|
156
|
+
const candidates = [];
|
|
157
|
+
if (delivered?.workId) {
|
|
158
|
+
candidates.push({
|
|
159
|
+
candidate_id: delivered.workId,
|
|
160
|
+
platform: 'pixiv',
|
|
161
|
+
work_id: delivered.workId,
|
|
162
|
+
work_type: protocolWorkType(delivered.workType),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { candidates, scanned: supply?.fetched ?? candidates.length };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The execution ceiling this deployment enforces for one schedule's work:
|
|
169
|
+
* the plan's configured `timeout`, else the scheduler-wide default.
|
|
170
|
+
*/
|
|
171
|
+
function resolveDeadlineMs(config, scheduleId) {
|
|
172
|
+
const plan = (config.schedules ?? []).find((item) => item.id === scheduleId);
|
|
173
|
+
const timeout = plan?.timeout;
|
|
174
|
+
return typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0
|
|
175
|
+
? timeout
|
|
176
|
+
: Scheduler_1.DEFAULT_SCHEDULE_TIMEOUT_MS;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The declared `default_deadline_ms`: the widest ceiling any enabled plan
|
|
180
|
+
* grants, so the declaration never promises more time than config allows, and
|
|
181
|
+
* never less than a plan that does grant it.
|
|
182
|
+
*/
|
|
183
|
+
function resolveDefaultDeadlineMs(config) {
|
|
184
|
+
const declared = (config.schedules ?? [])
|
|
185
|
+
.filter((plan) => plan.enabled !== false)
|
|
186
|
+
.map((plan) => plan.timeout)
|
|
187
|
+
.filter((timeout) => typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0);
|
|
188
|
+
return declared.length > 0 ? Math.max(...declared) : Scheduler_1.DEFAULT_SCHEDULE_TIMEOUT_MS;
|
|
189
|
+
}
|
|
190
|
+
function isPlainObject(value) {
|
|
191
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
192
|
+
}
|
|
193
|
+
function invalid(message, detail) {
|
|
194
|
+
return new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, { message, detail });
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Parse and validate a `$defs/Task` body (§2/§3).
|
|
198
|
+
*
|
|
199
|
+
* Strict about the documented fields — a wrong type or an out-of-range value is
|
|
200
|
+
* a 400 `invalid_params`, never a silently coerced default — and deliberately
|
|
201
|
+
* tolerant about unknown keys, which §3 requires to be ignored so a newer
|
|
202
|
+
* consumer can talk to an older producer.
|
|
203
|
+
*
|
|
204
|
+
* `protocol_version` is judged by kind: absent is a malformed request
|
|
205
|
+
* (`invalid_params`), present-but-different is a version mismatch
|
|
206
|
+
* (`unsupported_protocol_version`). `job_type` uses `invalid_params`; the
|
|
207
|
+
* published error enum has no `unsupported_job_type` member and this producer
|
|
208
|
+
* does not invent protocol codes.
|
|
209
|
+
*/
|
|
210
|
+
function parseTaskBody(body) {
|
|
211
|
+
if (!isPlainObject(body)) {
|
|
212
|
+
throw invalid('body must be a JSON object');
|
|
213
|
+
}
|
|
214
|
+
const version = body.protocol_version;
|
|
215
|
+
if (version === undefined || version === null) {
|
|
216
|
+
throw invalid('protocol_version is required');
|
|
217
|
+
}
|
|
218
|
+
if (version !== exports.PROTOCOL_VERSIONS[0]) {
|
|
219
|
+
throw new ProtocolErrors_1.ProtocolRequestError('unsupported_protocol_version', 400, {
|
|
220
|
+
message: `unsupported protocol_version ${JSON.stringify(version)}`,
|
|
221
|
+
detail: { received: version, supported: [...exports.PROTOCOL_VERSIONS] },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (body.job_type !== exports.JOB_TYPE_CANDIDATE_SEARCH) {
|
|
225
|
+
throw invalid(`unsupported job_type ${JSON.stringify(body.job_type ?? null)}`, {
|
|
226
|
+
reason: 'unsupported_job_type',
|
|
227
|
+
job_type: body.job_type ?? null,
|
|
228
|
+
supported: [exports.JOB_TYPE_CANDIDATE_SEARCH],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const rawKey = body.idempotency_key;
|
|
232
|
+
if (typeof rawKey !== 'string' || rawKey.trim() === '') {
|
|
233
|
+
throw invalid('idempotency_key must be a non-empty string');
|
|
234
|
+
}
|
|
235
|
+
const idempotencyKey = rawKey.trim();
|
|
236
|
+
if (idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
|
|
237
|
+
throw invalid(`idempotency_key must be at most ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`);
|
|
238
|
+
}
|
|
239
|
+
let correlationId;
|
|
240
|
+
if (body.correlation_id !== undefined && body.correlation_id !== null) {
|
|
241
|
+
if (typeof body.correlation_id !== 'string' || body.correlation_id.length > MAX_CORRELATION_ID_LENGTH) {
|
|
242
|
+
throw invalid(`correlation_id must be a string of at most ${MAX_CORRELATION_ID_LENGTH} chars`);
|
|
243
|
+
}
|
|
244
|
+
correlationId = body.correlation_id;
|
|
245
|
+
}
|
|
246
|
+
let callbackUrl;
|
|
247
|
+
if (body.callback_url !== undefined && body.callback_url !== null) {
|
|
248
|
+
if (typeof body.callback_url !== 'string' || body.callback_url.length > MAX_CALLBACK_URL_LENGTH) {
|
|
249
|
+
throw invalid(`callback_url must be a string of at most ${MAX_CALLBACK_URL_LENGTH} chars`);
|
|
250
|
+
}
|
|
251
|
+
callbackUrl = body.callback_url;
|
|
252
|
+
}
|
|
253
|
+
let deadlineMs;
|
|
254
|
+
if (body.deadline_ms !== undefined && body.deadline_ms !== null) {
|
|
255
|
+
if (!Number.isInteger(body.deadline_ms) || body.deadline_ms < 1000) {
|
|
256
|
+
throw invalid('deadline_ms must be an integer of at least 1000 ms');
|
|
257
|
+
}
|
|
258
|
+
deadlineMs = body.deadline_ms;
|
|
259
|
+
}
|
|
260
|
+
if (body.labels !== undefined && body.labels !== null) {
|
|
261
|
+
if (!isPlainObject(body.labels)) {
|
|
262
|
+
throw invalid('labels must be an object of strings');
|
|
263
|
+
}
|
|
264
|
+
for (const [name, value] of Object.entries(body.labels)) {
|
|
265
|
+
if (typeof value !== 'string') {
|
|
266
|
+
throw invalid(`labels.${name} must be a string`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const params = parseCandidateSearchParams(body.params);
|
|
271
|
+
return {
|
|
272
|
+
idempotencyKey,
|
|
273
|
+
...(correlationId !== undefined ? { correlationId } : {}),
|
|
274
|
+
...(params.source?.account !== undefined ? { account: params.source.account } : {}),
|
|
275
|
+
params,
|
|
276
|
+
...(deadlineMs !== undefined ? { deadlineMs } : {}),
|
|
277
|
+
...(callbackUrl !== undefined ? { callbackUrl } : {}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** `$defs/CandidateSearchParams`, validated field by field. */
|
|
281
|
+
function parseCandidateSearchParams(raw) {
|
|
282
|
+
if (!isPlainObject(raw)) {
|
|
283
|
+
throw invalid('params must be a JSON object');
|
|
284
|
+
}
|
|
285
|
+
let source;
|
|
286
|
+
if (raw.source !== undefined && raw.source !== null) {
|
|
287
|
+
if (!isPlainObject(raw.source)) {
|
|
288
|
+
throw invalid('params.source must be a JSON object');
|
|
289
|
+
}
|
|
290
|
+
const platform = raw.source.platform;
|
|
291
|
+
if (platform !== undefined && platform !== null && platform !== 'pixiv') {
|
|
292
|
+
throw invalid(`params.source.platform must be 'pixiv'`, { received: platform });
|
|
293
|
+
}
|
|
294
|
+
const account = raw.source.account;
|
|
295
|
+
if (account !== undefined && account !== null) {
|
|
296
|
+
if (typeof account !== 'string' || account.trim() === '' || account.length > MAX_TAG_LENGTH) {
|
|
297
|
+
throw invalid('params.source.account must be a non-empty string');
|
|
298
|
+
}
|
|
299
|
+
source = { ...(platform === 'pixiv' ? { platform } : {}), account };
|
|
300
|
+
}
|
|
301
|
+
else if (platform === 'pixiv') {
|
|
302
|
+
source = { platform };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const query = raw.query;
|
|
306
|
+
if (!isPlainObject(query)) {
|
|
307
|
+
throw invalid('params.query must be a JSON object');
|
|
308
|
+
}
|
|
309
|
+
const tags = query.tags;
|
|
310
|
+
if (!Array.isArray(tags) || tags.length === 0) {
|
|
311
|
+
throw invalid('params.query.tags must be a non-empty array of strings');
|
|
312
|
+
}
|
|
313
|
+
if (tags.length > MAX_TAGS) {
|
|
314
|
+
throw invalid(`params.query.tags must contain at most ${MAX_TAGS} tags`);
|
|
315
|
+
}
|
|
316
|
+
for (const tag of tags) {
|
|
317
|
+
if (typeof tag !== 'string' || tag.trim() === '' || tag.length > MAX_TAG_LENGTH) {
|
|
318
|
+
throw invalid('params.query.tags must be a non-empty array of strings');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (query.expand !== undefined && query.expand !== null && typeof query.expand !== 'boolean') {
|
|
322
|
+
throw invalid('params.query.expand must be a boolean');
|
|
323
|
+
}
|
|
324
|
+
const constraints = parseConstraints(raw.constraints);
|
|
325
|
+
return {
|
|
326
|
+
...(source !== undefined ? { source } : {}),
|
|
327
|
+
query: {
|
|
328
|
+
tags: tags.map((tag) => tag.trim()),
|
|
329
|
+
...(query.expand === true ? { expand: true } : {}),
|
|
330
|
+
},
|
|
331
|
+
...(constraints !== undefined ? { constraints } : {}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function parseConstraints(raw) {
|
|
335
|
+
if (raw === undefined || raw === null)
|
|
336
|
+
return undefined;
|
|
337
|
+
if (!isPlainObject(raw)) {
|
|
338
|
+
throw invalid('params.constraints must be a JSON object');
|
|
339
|
+
}
|
|
340
|
+
let exclude;
|
|
341
|
+
if (raw.exclude !== undefined && raw.exclude !== null) {
|
|
342
|
+
if (!Array.isArray(raw.exclude) || raw.exclude.length > MAX_EXCLUSIONS) {
|
|
343
|
+
throw invalid(`params.constraints.exclude must be an array of at most ${MAX_EXCLUSIONS} items`);
|
|
344
|
+
}
|
|
345
|
+
exclude = raw.exclude.map((entry) => {
|
|
346
|
+
if (!isPlainObject(entry) || !['work', 'candidate', 'tag'].includes(String(entry.kind))) {
|
|
347
|
+
throw invalid('params.constraints.exclude[].kind must be one of work, candidate, tag');
|
|
348
|
+
}
|
|
349
|
+
if (typeof entry.id !== 'string' || entry.id.trim() === '' || entry.id.length > MAX_TAG_LENGTH) {
|
|
350
|
+
throw invalid('params.constraints.exclude[].id must be a non-empty string');
|
|
351
|
+
}
|
|
352
|
+
return { kind: entry.kind, id: entry.id.trim() };
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const limit = parsePositiveInteger(raw.limit, 'params.constraints.limit');
|
|
356
|
+
const scanLimit = parsePositiveInteger(raw.scan_limit, 'params.constraints.scan_limit');
|
|
357
|
+
let workTypes;
|
|
358
|
+
if (raw.work_types !== undefined && raw.work_types !== null) {
|
|
359
|
+
if (!Array.isArray(raw.work_types) || raw.work_types.some((type) => typeof type !== 'string' || type === '')) {
|
|
360
|
+
throw invalid('params.constraints.work_types must be an array of non-empty strings');
|
|
361
|
+
}
|
|
362
|
+
workTypes = [...raw.work_types];
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
...(exclude !== undefined ? { exclude } : {}),
|
|
366
|
+
...(limit !== undefined ? { limit } : {}),
|
|
367
|
+
...(scanLimit !== undefined ? { scan_limit: Math.min(scanLimit, CandidateSearchParams_1.CANDIDATE_SEARCH_SCAN_LIMIT_MAX) } : {}),
|
|
368
|
+
...(workTypes !== undefined ? { work_types: workTypes } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function parsePositiveInteger(value, name) {
|
|
372
|
+
if (value === undefined || value === null)
|
|
373
|
+
return undefined;
|
|
374
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
375
|
+
throw invalid(`${name} must be an integer of at least 1`);
|
|
376
|
+
}
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* `$defs/Capabilities`, derived from the live configuration: change a budget in
|
|
381
|
+
* config and this declaration follows without a code change.
|
|
382
|
+
*/
|
|
383
|
+
function buildCapabilities(config, now = Date.now()) {
|
|
384
|
+
const budgets = (0, StallSweep_1.resolveStallTimeouts)(config.schedulerRuntime);
|
|
385
|
+
return {
|
|
386
|
+
protocol_versions: [...exports.PROTOCOL_VERSIONS],
|
|
387
|
+
job_types: [
|
|
388
|
+
{
|
|
389
|
+
name: exports.JOB_TYPE_CANDIDATE_SEARCH,
|
|
390
|
+
params_schema: '#/$defs/CandidateSearchParams',
|
|
391
|
+
result_schema: '#/$defs/Result_CandidateSearch',
|
|
392
|
+
// Honest feature list. `exclude` and `tag_expansion` are real: the
|
|
393
|
+
// occurrence-scoped retrieval view applies `constraints.exclude` and
|
|
394
|
+
// `query.expand`. `events` is real too: `GET /jobs/:id/events` serves the
|
|
395
|
+
// durable stream and `POST /jobs/:id/events/ack` persists the cursor
|
|
396
|
+
// (`src/scheduler/JobEventStream.ts`), and a Task's `callback_url`
|
|
397
|
+
// receives `$defs/Event` bodies through the existing outbox.
|
|
398
|
+
features: ['events', 'progress', 'cancel', 'idempotency', 'exclude', 'tag_expansion'],
|
|
399
|
+
queued_timeout_ms: budgets.queuedTimeoutMs,
|
|
400
|
+
stall_timeout_ms: budgets.stallTimeoutMs,
|
|
401
|
+
heartbeat_interval_ms: SlotCoordinator_1.SLOT_HEARTBEAT_MS,
|
|
402
|
+
default_deadline_ms: resolveDefaultDeadlineMs(config),
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
server_time: now,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// The event stream (§events)
|
|
410
|
+
//
|
|
411
|
+
// Same durable log, protocol vocabulary. `delivery_events` already records every
|
|
412
|
+
// lifecycle step; this section is the ONE place that decides which internal
|
|
413
|
+
// kinds a consumer may see and what they are called. Nothing here invents state:
|
|
414
|
+
// an event exists only because a durable row exists.
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
exports.PROTOCOL_EVENT_TYPES = [
|
|
417
|
+
'job.accepted',
|
|
418
|
+
'job.started',
|
|
419
|
+
'job.progress',
|
|
420
|
+
'job.succeeded',
|
|
421
|
+
'job.failed',
|
|
422
|
+
'job.expired',
|
|
423
|
+
'job.cancelled',
|
|
424
|
+
];
|
|
425
|
+
/**
|
|
426
|
+
* The internal job-lifecycle kinds PixivFlow persists for a job. They are
|
|
427
|
+
* deliberately NOT spelled like the protocol enum: the translation is a
|
|
428
|
+
* decision made here, never a string passthrough.
|
|
429
|
+
*/
|
|
430
|
+
exports.JOB_EVENT_KINDS = [
|
|
431
|
+
'job.requested',
|
|
432
|
+
'job.execution_started',
|
|
433
|
+
'job.progressed',
|
|
434
|
+
'job.outcome_succeeded',
|
|
435
|
+
'job.outcome_failed',
|
|
436
|
+
'job.outcome_expired',
|
|
437
|
+
'job.outcome_cancelled',
|
|
438
|
+
];
|
|
439
|
+
exports.TERMINAL_JOB_EVENT_KINDS = [
|
|
440
|
+
'job.outcome_succeeded',
|
|
441
|
+
'job.outcome_failed',
|
|
442
|
+
'job.outcome_expired',
|
|
443
|
+
'job.outcome_cancelled',
|
|
444
|
+
];
|
|
445
|
+
/** Protocol type for one durable terminal job status. */
|
|
446
|
+
const TERMINAL_EVENT_FOR_STATUS = {
|
|
447
|
+
succeeded: 'job.outcome_succeeded',
|
|
448
|
+
failed: 'job.outcome_failed',
|
|
449
|
+
expired: 'job.outcome_expired',
|
|
450
|
+
cancelled: 'job.outcome_cancelled',
|
|
451
|
+
};
|
|
452
|
+
/** The internal kind that materialises the terminal event of a finished job. */
|
|
453
|
+
function terminalJobEventKind(status) {
|
|
454
|
+
if (status === 'succeeded' || status === 'failed' || status === 'expired' || status === 'cancelled') {
|
|
455
|
+
return TERMINAL_EVENT_FOR_STATUS[status];
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The ONE mapping from durable internal kinds to protocol event types.
|
|
461
|
+
*
|
|
462
|
+
* `null` means internal-only: the row stays in `delivery_events` for operators
|
|
463
|
+
* and `runs show`, and never reaches a consumer. Any kind absent from this map
|
|
464
|
+
* is DROPPED with a warning — an internal kind can never become an unknown
|
|
465
|
+
* protocol type by accident. The intersection type below makes the mapping
|
|
466
|
+
* exhaustive over `JobEventKind` at compile time, so adding a new lifecycle kind
|
|
467
|
+
* without mapping it fails `tsc` instead of shipping.
|
|
468
|
+
*/
|
|
469
|
+
exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND = {
|
|
470
|
+
'job.requested': 'job.accepted',
|
|
471
|
+
'job.execution_started': 'job.started',
|
|
472
|
+
'job.progressed': 'job.progress',
|
|
473
|
+
'job.outcome_succeeded': 'job.succeeded',
|
|
474
|
+
'job.outcome_failed': 'job.failed',
|
|
475
|
+
'job.outcome_expired': 'job.expired',
|
|
476
|
+
'job.outcome_cancelled': 'job.cancelled',
|
|
477
|
+
// Internal-only: outbox/ledger telemetry that happens to carry a slot_id.
|
|
478
|
+
'execution.summary': null,
|
|
479
|
+
'delivery.duplicate': null,
|
|
480
|
+
'media.fallback': null,
|
|
481
|
+
'outbox.claimed': null,
|
|
482
|
+
'outbox.deferred': null,
|
|
483
|
+
'outbox.delivered': null,
|
|
484
|
+
'outbox.retry_scheduled': null,
|
|
485
|
+
'outbox.dead': null,
|
|
486
|
+
'outbox.cancelled': null,
|
|
487
|
+
'outbox.replay_requested': null,
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* The internal kinds a consumer is allowed to see. Callers use this as the SQL
|
|
491
|
+
* filter so `unacked` can never be pinned by rows that are not projectable.
|
|
492
|
+
*/
|
|
493
|
+
function projectedInternalKinds() {
|
|
494
|
+
return Object.keys(exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND).filter((kind) => exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND[kind] !== null);
|
|
495
|
+
}
|
|
496
|
+
/** True when an internal kind is visible to consumers (has a protocol type). */
|
|
497
|
+
function isProjectedInternalKind(internalKind) {
|
|
498
|
+
return (Object.prototype.hasOwnProperty.call(exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND, internalKind) &&
|
|
499
|
+
exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND[internalKind] !== null);
|
|
500
|
+
}
|
|
501
|
+
/** Map one internal kind to its protocol type; null when internal-only. */
|
|
502
|
+
function protocolEventTypeFor(internalKind) {
|
|
503
|
+
if (!Object.prototype.hasOwnProperty.call(exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND, internalKind)) {
|
|
504
|
+
logger_1.logger.warn('Unmapped internal event kind dropped from the protocol event stream', {
|
|
505
|
+
event: internalKind,
|
|
506
|
+
});
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
return exports.PROTOCOL_EVENT_FOR_INTERNAL_KIND[internalKind];
|
|
510
|
+
}
|
|
511
|
+
/** `evt-<at>-<row id>`: opaque, unique, and resolvable back to the row. */
|
|
512
|
+
function protocolEventId(at, rowId) {
|
|
513
|
+
return `evt-${at}-${rowId}`;
|
|
514
|
+
}
|
|
515
|
+
/** Resolve an opaque event_id to a durable position; null when unrecognised. */
|
|
516
|
+
function parseProtocolEventId(value) {
|
|
517
|
+
const match = /^evt-(\d+)-(\d+)$/.exec(value);
|
|
518
|
+
if (!match)
|
|
519
|
+
return null;
|
|
520
|
+
return { at: Number(match[1]), rowId: Number(match[2]) };
|
|
521
|
+
}
|
|
522
|
+
/** Build one protocol event, or null when the internal kind is internal-only. */
|
|
523
|
+
function buildProtocolEvent(input) {
|
|
524
|
+
const type = protocolEventTypeFor(input.source.internalKind);
|
|
525
|
+
if (!type)
|
|
526
|
+
return null;
|
|
527
|
+
const payload = {
|
|
528
|
+
detail: { internal_kind: input.source.internalKind },
|
|
529
|
+
};
|
|
530
|
+
// Terminal events carry the outcome, exactly like the vendored fixtures:
|
|
531
|
+
// `event.job.succeeded` carries `payload.job` + `payload.result`, and
|
|
532
|
+
// `event.job.expired` carries `payload.job` + `payload.error`.
|
|
533
|
+
const job = input.job ?? null;
|
|
534
|
+
if (type === 'job.succeeded' || type === 'job.failed' || type === 'job.expired' || type === 'job.cancelled') {
|
|
535
|
+
payload.job = job;
|
|
536
|
+
if (type === 'job.succeeded')
|
|
537
|
+
payload.result = job?.result ?? null;
|
|
538
|
+
else
|
|
539
|
+
payload.error = job?.error ?? null;
|
|
540
|
+
}
|
|
541
|
+
const event = {
|
|
542
|
+
protocol_version: '1',
|
|
543
|
+
event_id: protocolEventId(input.source.at, input.source.rowId),
|
|
544
|
+
job_id: input.jobId,
|
|
545
|
+
type,
|
|
546
|
+
at: input.source.at,
|
|
547
|
+
payload,
|
|
548
|
+
};
|
|
549
|
+
if (input.correlationId)
|
|
550
|
+
event.correlation_id = input.correlationId;
|
|
551
|
+
return event;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* `$defs/EventPage` from durable rows. `events` is ordered by `at` (then by the
|
|
555
|
+
* durable row order, which is the same order because writers never move `at`
|
|
556
|
+
* backwards).
|
|
557
|
+
*/
|
|
558
|
+
function buildEventPage(input) {
|
|
559
|
+
const events = [];
|
|
560
|
+
for (const source of input.sources) {
|
|
561
|
+
const event = buildProtocolEvent({
|
|
562
|
+
jobId: input.jobId,
|
|
563
|
+
source,
|
|
564
|
+
job: input.job ?? null,
|
|
565
|
+
correlationId: input.correlationId ?? null,
|
|
566
|
+
});
|
|
567
|
+
if (event)
|
|
568
|
+
events.push(event);
|
|
569
|
+
}
|
|
570
|
+
const page = {
|
|
571
|
+
job_id: input.jobId,
|
|
572
|
+
events,
|
|
573
|
+
unacked: Math.max(0, input.unacked),
|
|
574
|
+
server_time: input.serverTime,
|
|
575
|
+
};
|
|
576
|
+
const last = events[events.length - 1];
|
|
577
|
+
if (input.hasMore && last)
|
|
578
|
+
page.next_after = last.event_id;
|
|
579
|
+
return page;
|
|
580
|
+
}
|
|
581
|
+
/** `$defs/AckResult` from the durable cursor. */
|
|
582
|
+
function buildAckResult(input) {
|
|
583
|
+
return {
|
|
584
|
+
job_id: input.jobId,
|
|
585
|
+
acked: Math.max(0, input.acked),
|
|
586
|
+
unacked: Math.max(0, input.unacked),
|
|
587
|
+
server_time: input.serverTime,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
/** `$defs/AckRequest`. */
|
|
591
|
+
function parseAckBody(body) {
|
|
592
|
+
if (!isPlainObject(body))
|
|
593
|
+
throw invalid('request body must be a JSON object');
|
|
594
|
+
const ackThrough = body.ack_through;
|
|
595
|
+
if (typeof ackThrough !== 'string' || ackThrough.length === 0) {
|
|
596
|
+
throw invalid('ack_through is required', { field: 'ack_through' });
|
|
597
|
+
}
|
|
598
|
+
return { ackThrough };
|
|
599
|
+
}
|
|
600
|
+
/** Query parameters of `GET /jobs/:jobId/events`. */
|
|
601
|
+
function parseEventQuery(query) {
|
|
602
|
+
const raw = query.after;
|
|
603
|
+
let after = null;
|
|
604
|
+
if (raw !== undefined && raw !== null) {
|
|
605
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
606
|
+
throw invalid('after must be a non-empty event_id', { field: 'after' });
|
|
607
|
+
}
|
|
608
|
+
after = raw;
|
|
609
|
+
}
|
|
610
|
+
const rawUnacked = query.unacked;
|
|
611
|
+
let unackedOnly = false;
|
|
612
|
+
if (rawUnacked !== undefined && rawUnacked !== null && rawUnacked !== '') {
|
|
613
|
+
const value = String(rawUnacked);
|
|
614
|
+
if (value === '1' || value === 'true')
|
|
615
|
+
unackedOnly = true;
|
|
616
|
+
else if (value !== '0' && value !== 'false') {
|
|
617
|
+
throw invalid('unacked must be 1 or 0', { field: 'unacked' });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { after, unackedOnly };
|
|
621
|
+
}
|
|
622
|
+
//# sourceMappingURL=JobFacade.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generic Job projection (§liveness).
|
|
3
|
+
*
|
|
4
|
+
* One durable Slot cell, described in terms a consumer can act on WITHOUT
|
|
5
|
+
* knowing this service's internal column names, table layout or scheduler
|
|
6
|
+
* topology. This is the body a job-status endpoint returns; the manual-refetch
|
|
7
|
+
* GET endpoint is the first such consumer (and declares the legacy
|
|
8
|
+
* `requestId`/`slotId`/`state`/`slotStatus` aliases on top of it).
|
|
9
|
+
*
|
|
10
|
+
* Why the extra fields exist: a caller that receives only a coarse state cannot
|
|
11
|
+
* tell "queued two seconds ago" from "pending for three days behind a stopped
|
|
12
|
+
* scheduler", so it cannot implement a heartbeat-based watchdog — it can only
|
|
13
|
+
* poll forever. Timestamps + lease liveness + attempt count make the difference
|
|
14
|
+
* decidable, and `terminalReasonCode`/`terminalReasonMessage` give the
|
|
15
|
+
* first-level cause once the job is terminal.
|
|
16
|
+
*
|
|
17
|
+
* Timestamp normalisation (the columns do NOT share one shape):
|
|
18
|
+
* - `schedule_slots.created_at` / `started_at` and
|
|
19
|
+
* `schedule_slot_items.updated_at` are SQLite `CURRENT_TIMESTAMP` UTC
|
|
20
|
+
* datetimes without a zone marker -> parsed as UTC (see `ledger-time`).
|
|
21
|
+
* - `schedule_slots.heartbeat_at` / `lease_until` are already epoch ms.
|
|
22
|
+
* - Every timestamp in this projection is epoch milliseconds UTC, or null when
|
|
23
|
+
* the ledger has no such instant.
|
|
24
|
+
*/
|
|
25
|
+
import type { SlotItemRecord, SlotRecord } from '../storage/repositories/SlotRepository';
|
|
26
|
+
export interface JobStatusProjection {
|
|
27
|
+
/** The caller's own request id, echoed back. */
|
|
28
|
+
requestId: string;
|
|
29
|
+
slotId: string;
|
|
30
|
+
/** Cell state from the Slot FSM (pending|selected|artifact_ready|delivery_pending|submitted|no_candidate|duplicate|failed). */
|
|
31
|
+
state: string;
|
|
32
|
+
/** Rolled-up Slot status (pending|running|success|partial|failed|expired). */
|
|
33
|
+
slotStatus: string;
|
|
34
|
+
createdAt: number | null;
|
|
35
|
+
/** When a worker first marked the slot running. */
|
|
36
|
+
startedAt: number | null;
|
|
37
|
+
/** Last cell update: the freshest durable progress signal for this target. */
|
|
38
|
+
updatedAt: number | null;
|
|
39
|
+
/** Last lease heartbeat written by the owning run. */
|
|
40
|
+
heartbeatAt: number | null;
|
|
41
|
+
/** When the current lease expires (null when nobody holds one). */
|
|
42
|
+
leaseExpiresAt: number | null;
|
|
43
|
+
/** A lease is held AND unexpired: some run owns this slot right now. */
|
|
44
|
+
leaseActive: boolean;
|
|
45
|
+
/** The slot left `pending` (claimed by a run or already terminal). */
|
|
46
|
+
claimed: boolean;
|
|
47
|
+
/** Delivery/execution attempts recorded on this cell. */
|
|
48
|
+
attemptCount: number;
|
|
49
|
+
terminalReasonCode: string | null;
|
|
50
|
+
terminalReasonMessage: string | null;
|
|
51
|
+
/** Internal name of the request that opened this job. */
|
|
52
|
+
manualRequestId: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Consumer-facing opaque key for the same request. A consumer groups work by
|
|
55
|
+
* this value and never needs to learn the column it came from.
|
|
56
|
+
*/
|
|
57
|
+
idempotencyKey: string | null;
|
|
58
|
+
/** Opaque caller correlation (review chain / review id). */
|
|
59
|
+
correlationId: string | null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Project one (slot, cell) pair. Pure: no clock reads beyond the injectable
|
|
63
|
+
* `now`, so the same ledger row always projects the same way for a given
|
|
64
|
+
* instant.
|
|
65
|
+
*/
|
|
66
|
+
export declare function buildJobProjection(requestId: string, slot: SlotRecord, cell: SlotItemRecord, now?: number): JobStatusProjection;
|
|
67
|
+
//# sourceMappingURL=JobProjection.d.ts.map
|