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.
Files changed (61) 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 +76 -0
  16. package/dist/scheduler/CandidateSearchParams.js +94 -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 +298 -0
  22. package/dist/scheduler/JobFacade.js +622 -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 +126 -0
  28. package/dist/scheduler/ManualJobAdmission.js +310 -0
  29. package/dist/scheduler/ManualJobService.d.ts +57 -0
  30. package/dist/scheduler/ManualJobService.js +91 -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/examples/gateway/README.md +4 -0
  59. package/examples/onebot-adapter/README.md +139 -0
  60. package/examples/onebot-adapter/server.mjs +612 -0
  61. 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