pixivflow 3.2.0 → 3.4.0

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