@kubun/plugin-workflow 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/adaptive.d.ts +9 -0
- package/lib/adaptive.js +44 -0
- package/lib/definition.d.ts +2 -24
- package/lib/engine.d.ts +1 -2
- package/lib/engine.js +149 -5
- package/lib/index.d.ts +1 -2
- package/lib/index.js +1 -0
- package/lib/schema.d.ts +5 -1
- package/lib/schema.js +73 -2
- package/package.json +13 -11
- package/lib/types.d.ts +0 -73
- package/lib/types.js +0 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AdaptivePolicy, WorkflowOutcome } from '@kubun/plugin-workflow-api';
|
|
2
|
+
export declare function validateAdaptivePolicy(policy: AdaptivePolicy): void;
|
|
3
|
+
export declare function computeNextDelay(policy: AdaptivePolicy, outcome: WorkflowOutcome, streak: {
|
|
4
|
+
lastOutcome: WorkflowOutcome | null;
|
|
5
|
+
count: number;
|
|
6
|
+
}): {
|
|
7
|
+
delayMs: number;
|
|
8
|
+
count: number;
|
|
9
|
+
};
|
package/lib/adaptive.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Adaptive schedule policy: the pure core that maps a recurring sync's last
|
|
2
|
+
// outcome to the delay before its next run. No DB, no engine state — the store
|
|
3
|
+
// persists the streak, the engine calls this to advance it. The policy types
|
|
4
|
+
// are the cross-plugin contract; this module keeps only the pure math.
|
|
5
|
+
const DEFAULT_BACKOFF = {
|
|
6
|
+
idle: 'fixed',
|
|
7
|
+
error: 'exponential',
|
|
8
|
+
offline: 'exponential'
|
|
9
|
+
};
|
|
10
|
+
function assertPositive(value, label) {
|
|
11
|
+
if (!(value > 0)) throw new Error(`adaptive policy: ${label} must be > 0, got ${value}`);
|
|
12
|
+
}
|
|
13
|
+
function validateBranch(branch, label) {
|
|
14
|
+
assertPositive(branch.base, `${label}.base`);
|
|
15
|
+
assertPositive(branch.max, `${label}.max`);
|
|
16
|
+
if (branch.base > branch.max) {
|
|
17
|
+
throw new Error(`adaptive policy: ${label}.base (${branch.base}) exceeds max (${branch.max})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function validateAdaptivePolicy(policy) {
|
|
21
|
+
assertPositive(policy.changed, 'changed');
|
|
22
|
+
validateBranch(policy.idle, 'idle');
|
|
23
|
+
validateBranch(policy.error, 'error');
|
|
24
|
+
validateBranch(policy.offline, 'offline');
|
|
25
|
+
}
|
|
26
|
+
export function computeNextDelay(policy, outcome, streak) {
|
|
27
|
+
if (outcome === 'changed') {
|
|
28
|
+
return {
|
|
29
|
+
delayMs: policy.changed,
|
|
30
|
+
count: 0
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// A different outcome class (a productive run's stored count of 0 included)
|
|
34
|
+
// starts a fresh streak at 1; same class grows it.
|
|
35
|
+
const count = streak.lastOutcome === outcome ? streak.count + 1 : 1;
|
|
36
|
+
const branch = policy[outcome];
|
|
37
|
+
const kind = branch.backoff ?? DEFAULT_BACKOFF[outcome];
|
|
38
|
+
const raw = kind === 'fixed' ? branch.base : branch.base * 2 ** (count - 1);
|
|
39
|
+
// Clamp is the runtime backstop even if validation was bypassed.
|
|
40
|
+
return {
|
|
41
|
+
delayMs: Math.min(branch.max, raw),
|
|
42
|
+
count
|
|
43
|
+
};
|
|
44
|
+
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
export type RetryPolicy = {
|
|
4
|
-
maxAttempts?: number;
|
|
5
|
-
backoff?: 'exponential' | 'fixed';
|
|
6
|
-
baseMs?: number;
|
|
7
|
-
maxMs?: number;
|
|
8
|
-
};
|
|
9
|
-
export type TerminalStatus = 'completed' | 'failed' | 'cancelled';
|
|
10
|
-
export type TerminalInfo = {
|
|
11
|
-
instanceID: string;
|
|
12
|
-
name: string;
|
|
13
|
-
status: TerminalStatus;
|
|
14
|
-
error?: string;
|
|
15
|
-
};
|
|
16
|
-
export type WorkflowDefinition<State extends Record<string, unknown> = Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>> = {
|
|
17
|
-
name: string;
|
|
18
|
-
handlers: Handlers;
|
|
19
|
-
initialAction: FlowAction<State, Handlers>;
|
|
20
|
-
stateValidator?: Validator<State>;
|
|
21
|
-
queue?: string;
|
|
22
|
-
retry?: RetryPolicy;
|
|
23
|
-
onTerminal?: (info: TerminalInfo) => void;
|
|
24
|
-
};
|
|
1
|
+
import type { WorkflowDefinition } from '@kubun/plugin-workflow-api';
|
|
2
|
+
import type { HandlersRecord } from '@sozai/flow';
|
|
25
3
|
export declare function defineWorkflow<State extends Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>>(def: WorkflowDefinition<State, Handlers>): WorkflowDefinition<State, Handlers>;
|
package/lib/engine.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { RetryPolicy, WorkflowAPI } from '@kubun/plugin-workflow-api';
|
|
2
3
|
import type { WorkflowStoreAPI } from '@kubun/store-workflow';
|
|
3
4
|
import type { GetRandomID } from '@sozai/runtime';
|
|
4
|
-
import type { RetryPolicy } from './definition.js';
|
|
5
|
-
import type { WorkflowAPI } from './types.js';
|
|
6
5
|
export declare function backoff(attempts: number, policy?: RetryPolicy): number;
|
|
7
6
|
export declare function parseEvery(spec: string): number;
|
|
8
7
|
export type WorkflowEngineDeps = {
|
package/lib/engine.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from '@sozai/event';
|
|
2
2
|
import { createFlow } from '@sozai/flow';
|
|
3
|
+
import { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
3
4
|
// One runner may hold an instance's lease for this long before another boot may
|
|
4
5
|
// reclaim it as stranded.
|
|
5
6
|
const LEASE_MS = 30_000;
|
|
@@ -43,6 +44,35 @@ function toStatus(row) {
|
|
|
43
44
|
updatedAt: row.updated_at
|
|
44
45
|
};
|
|
45
46
|
}
|
|
47
|
+
// The active (pending/running) instance for (name, singletonKey) if one exists,
|
|
48
|
+
// else the most recent terminal by updated_at, else null. Instances are promoted
|
|
49
|
+
// with singleton_key = subject, so the subject keys the lookup.
|
|
50
|
+
async function currentInstanceRow(s, name, singletonKey) {
|
|
51
|
+
const rows = await s.list({
|
|
52
|
+
name
|
|
53
|
+
});
|
|
54
|
+
const forSubject = rows.filter((r)=>r.singleton_key === singletonKey);
|
|
55
|
+
const active = forSubject.find((r)=>r.status === 'pending' || r.status === 'running');
|
|
56
|
+
return active ?? forSubject.reduce((best, r)=>best == null || r.updated_at > best.updated_at ? r : best, null);
|
|
57
|
+
}
|
|
58
|
+
// Project an adaptive schedule row as a PeriodicSync. `currentStatus` is the
|
|
59
|
+
// schedule subject's current instance (see `currentInstanceRow`), null if never
|
|
60
|
+
// run.
|
|
61
|
+
async function buildPeriodicSync(s, sched) {
|
|
62
|
+
const subject = sched.id.slice(sched.workflow_name.length + 1);
|
|
63
|
+
const chosen = await currentInstanceRow(s, sched.workflow_name, subject);
|
|
64
|
+
return {
|
|
65
|
+
scheduleID: sched.id,
|
|
66
|
+
subject,
|
|
67
|
+
workflowName: sched.workflow_name,
|
|
68
|
+
enabled: sched.enabled,
|
|
69
|
+
nextRun: sched.next_run,
|
|
70
|
+
lastOutcome: sched.last_outcome,
|
|
71
|
+
consecutiveCount: sched.consecutive_count,
|
|
72
|
+
policy: sched.policy,
|
|
73
|
+
currentStatus: chosen != null ? toStatus(chosen) : null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
46
76
|
export function createWorkflowEngine(deps) {
|
|
47
77
|
const now = deps.now ?? Date.now;
|
|
48
78
|
const logger = deps.logger;
|
|
@@ -65,7 +95,7 @@ export function createWorkflowEngine(deps) {
|
|
|
65
95
|
inflight.add(promise);
|
|
66
96
|
void promise.finally(()=>inflight.delete(promise));
|
|
67
97
|
}
|
|
68
|
-
function fireTerminal(def, instanceID, status, error) {
|
|
98
|
+
function fireTerminal(def, instanceID, status, error, outcome) {
|
|
69
99
|
events.fire(status, {
|
|
70
100
|
instanceID,
|
|
71
101
|
name: def.name
|
|
@@ -76,7 +106,8 @@ export function createWorkflowEngine(deps) {
|
|
|
76
106
|
instanceID,
|
|
77
107
|
name: def.name,
|
|
78
108
|
status,
|
|
79
|
-
error
|
|
109
|
+
error,
|
|
110
|
+
outcome
|
|
80
111
|
});
|
|
81
112
|
} catch (cause) {
|
|
82
113
|
logger.warn('workflow onTerminal threw', {
|
|
@@ -87,6 +118,24 @@ export function createWorkflowEngine(deps) {
|
|
|
87
118
|
}
|
|
88
119
|
}
|
|
89
120
|
}
|
|
121
|
+
// Advance the adaptive schedule that owns this instance, if any. Keyed by the
|
|
122
|
+
// instance's singleton_key: an interval-promoted or one-shot run has none and
|
|
123
|
+
// is a no-op. Called only after a fenced terminal write succeeded.
|
|
124
|
+
async function advanceAdaptive(row, outcome, at) {
|
|
125
|
+
if (row.singleton_key == null) return;
|
|
126
|
+
const s = await store();
|
|
127
|
+
const sched = await s.getSchedule(`${row.name}:${row.singleton_key}`);
|
|
128
|
+
if (sched == null || sched.kind !== 'adaptive') return;
|
|
129
|
+
const { delayMs, count } = computeNextDelay(sched.policy, outcome, {
|
|
130
|
+
lastOutcome: sched.last_outcome,
|
|
131
|
+
count: sched.consecutive_count
|
|
132
|
+
});
|
|
133
|
+
await s.advanceScheduleAdaptive(sched.id, {
|
|
134
|
+
nextRun: at + delayMs,
|
|
135
|
+
lastOutcome: outcome,
|
|
136
|
+
consecutiveCount: count
|
|
137
|
+
}, at);
|
|
138
|
+
}
|
|
90
139
|
// Runs an already-claimed, already-leased row through the flow loop. The tick's
|
|
91
140
|
// `claimDue` does the claiming, so this path never re-claims.
|
|
92
141
|
async function run(row) {
|
|
@@ -153,7 +202,10 @@ export function createWorkflowEngine(deps) {
|
|
|
153
202
|
state: flow.getState()
|
|
154
203
|
}, now(), bootID);
|
|
155
204
|
if (!wrote) return;
|
|
156
|
-
|
|
205
|
+
// A run reports its outcome via the flow `end` value; absent = idle.
|
|
206
|
+
const outcome = value.outcome ?? 'idle';
|
|
207
|
+
fireTerminal(def, instanceID, 'completed', undefined, outcome);
|
|
208
|
+
await advanceAdaptive(row, outcome, now());
|
|
157
209
|
return;
|
|
158
210
|
}
|
|
159
211
|
if (value.status === 'aborted') {
|
|
@@ -191,7 +243,8 @@ export function createWorkflowEngine(deps) {
|
|
|
191
243
|
status: 'failed'
|
|
192
244
|
}, now(), bootID);
|
|
193
245
|
if (!wrote) return;
|
|
194
|
-
fireTerminal(def, instanceID, 'failed', error);
|
|
246
|
+
fireTerminal(def, instanceID, 'failed', error, 'error');
|
|
247
|
+
await advanceAdaptive(row, 'error', now());
|
|
195
248
|
return;
|
|
196
249
|
}
|
|
197
250
|
} catch (cause) {
|
|
@@ -255,8 +308,21 @@ export function createWorkflowEngine(deps) {
|
|
|
255
308
|
const due = await s.listDueSchedules(at);
|
|
256
309
|
for (const sched of due){
|
|
257
310
|
try {
|
|
311
|
+
if (sched.kind === 'adaptive') {
|
|
312
|
+
// subjectKey may itself contain ':', so slice by the known prefix
|
|
313
|
+
// rather than splitting.
|
|
314
|
+
const subjectKey = sched.id.slice(sched.workflow_name.length + 1);
|
|
315
|
+
// next_run is NOT advanced here: it stays stable until the instance
|
|
316
|
+
// terminates, so the fire-time idempotency key blocks re-enqueue and
|
|
317
|
+
// the singleton key interlocks any manual sync of the same subject.
|
|
318
|
+
await api.enqueue(sched.workflow_name, sched.params, {
|
|
319
|
+
idempotencyKey: `schedule:${sched.id}:${sched.next_run}`,
|
|
320
|
+
singletonKey: subjectKey
|
|
321
|
+
});
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
258
324
|
if (sched.every == null) {
|
|
259
|
-
//
|
|
325
|
+
// A cron-only interval row can't be armed (cron is deferred).
|
|
260
326
|
logger.warn('workflow schedule "{id}" has no interval, skipping', {
|
|
261
327
|
id: sched.id
|
|
262
328
|
});
|
|
@@ -332,6 +398,9 @@ export function createWorkflowEngine(deps) {
|
|
|
332
398
|
}
|
|
333
399
|
registry.set(def.name, erased);
|
|
334
400
|
},
|
|
401
|
+
isManagedWorkflow (name) {
|
|
402
|
+
return registry.get(name)?.managed === true;
|
|
403
|
+
},
|
|
335
404
|
defineQueue,
|
|
336
405
|
async schedule (name, params, when) {
|
|
337
406
|
const s = await store();
|
|
@@ -369,6 +438,48 @@ export function createWorkflowEngine(deps) {
|
|
|
369
438
|
};
|
|
370
439
|
return handle;
|
|
371
440
|
},
|
|
441
|
+
async scheduleAdaptive (name, params, opts) {
|
|
442
|
+
validateAdaptivePolicy(opts.policy);
|
|
443
|
+
const s = await store();
|
|
444
|
+
// Deterministic id so a re-arm across restarts targets the same row.
|
|
445
|
+
const id = `${name}:${opts.subjectKey}`;
|
|
446
|
+
const at = now();
|
|
447
|
+
// Insert-or-arm in one atomic write: a fresh subject is created armed, an
|
|
448
|
+
// existing one is re-armed eagerly with its outcome streak preserved.
|
|
449
|
+
// Concurrent enables of the same subject converge instead of racing to a
|
|
450
|
+
// PK conflict.
|
|
451
|
+
await s.upsertAdaptiveSchedule({
|
|
452
|
+
id,
|
|
453
|
+
workflowName: name,
|
|
454
|
+
params,
|
|
455
|
+
policy: opts.policy,
|
|
456
|
+
nextRun: at + opts.policy.changed,
|
|
457
|
+
enabled: true,
|
|
458
|
+
reactivate: opts.reactivate
|
|
459
|
+
}, at);
|
|
460
|
+
const handle = {
|
|
461
|
+
id,
|
|
462
|
+
async disable () {
|
|
463
|
+
await (await store()).setScheduleEnabled(id, false, now());
|
|
464
|
+
},
|
|
465
|
+
async enable () {
|
|
466
|
+
// Re-arm eagerly (now + changed), mirroring reactivation above.
|
|
467
|
+
const s2 = await store();
|
|
468
|
+
const reAt = now();
|
|
469
|
+
const cur = await s2.getSchedule(id);
|
|
470
|
+
await s2.advanceScheduleAdaptive(id, {
|
|
471
|
+
nextRun: reAt + opts.policy.changed,
|
|
472
|
+
lastOutcome: cur?.last_outcome ?? null,
|
|
473
|
+
consecutiveCount: cur?.consecutive_count ?? 0
|
|
474
|
+
}, reAt);
|
|
475
|
+
await s2.setScheduleEnabled(id, true, reAt);
|
|
476
|
+
},
|
|
477
|
+
async cancel () {
|
|
478
|
+
await (await store()).setScheduleEnabled(id, false, now());
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
return handle;
|
|
482
|
+
},
|
|
372
483
|
async enqueue (name, params, opts) {
|
|
373
484
|
const def = registry.get(name);
|
|
374
485
|
if (def == null) {
|
|
@@ -453,6 +564,39 @@ export function createWorkflowEngine(deps) {
|
|
|
453
564
|
const rows = await s.list(filter);
|
|
454
565
|
return rows.map(toStatus);
|
|
455
566
|
},
|
|
567
|
+
async getCurrentInstance (name, singletonKey) {
|
|
568
|
+
const s = await store();
|
|
569
|
+
const row = await currentInstanceRow(s, name, singletonKey);
|
|
570
|
+
return row != null ? toStatus(row) : null;
|
|
571
|
+
},
|
|
572
|
+
async getPeriodicSync (scheduleID) {
|
|
573
|
+
const s = await store();
|
|
574
|
+
const sched = await s.getSchedule(scheduleID);
|
|
575
|
+
if (sched == null || sched.kind !== 'adaptive') return null;
|
|
576
|
+
return buildPeriodicSync(s, sched);
|
|
577
|
+
},
|
|
578
|
+
async listPeriodicSyncs (workflowName) {
|
|
579
|
+
const s = await store();
|
|
580
|
+
const scheds = (await s.listSchedules()).filter((x)=>x.kind === 'adaptive' && (workflowName == null || x.workflow_name === workflowName));
|
|
581
|
+
return Promise.all(scheds.map((x)=>buildPeriodicSync(s, x)));
|
|
582
|
+
},
|
|
583
|
+
async setPeriodicSyncEnabled (scheduleID, enabled) {
|
|
584
|
+
const s = await store();
|
|
585
|
+
const sched = await s.getSchedule(scheduleID);
|
|
586
|
+
if (sched == null || sched.kind !== 'adaptive') return null;
|
|
587
|
+
await s.setScheduleEnabled(scheduleID, enabled, now());
|
|
588
|
+
return buildPeriodicSync(s, await s.getSchedule(scheduleID) ?? sched);
|
|
589
|
+
},
|
|
590
|
+
async setPeriodicSyncPolicy (scheduleID, policy) {
|
|
591
|
+
// Validate before touching the store: a bad policy throws loudly and
|
|
592
|
+
// nothing is written.
|
|
593
|
+
validateAdaptivePolicy(policy);
|
|
594
|
+
const s = await store();
|
|
595
|
+
const sched = await s.getSchedule(scheduleID);
|
|
596
|
+
if (sched == null || sched.kind !== 'adaptive') return null;
|
|
597
|
+
await s.setSchedulePolicy(scheduleID, policy, now());
|
|
598
|
+
return buildPeriodicSync(s, await s.getSchedule(scheduleID) ?? sched);
|
|
599
|
+
},
|
|
456
600
|
events
|
|
457
601
|
};
|
|
458
602
|
return {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
|
|
2
2
|
export type WorkflowPluginOptions = Record<string, never>;
|
|
3
3
|
export declare function createWorkflowPlugin(_options?: WorkflowPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
|
4
|
-
export
|
|
4
|
+
export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
5
5
|
export { defineWorkflow } from './definition.js';
|
|
6
6
|
export type { WorkflowEngine, WorkflowEngineDeps } from './engine.js';
|
|
7
7
|
export { createWorkflowEngine, parseEvery } from './engine.js';
|
|
8
8
|
export type { WorkflowContext, WorkflowEventResult } from './schema.js';
|
|
9
9
|
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents, } from './schema.js';
|
|
10
|
-
export type { EnqueueOptions, InstanceHandle, InstanceStatus, ListFilter, QueueOptions, ScheduleHandle, ScheduleSpec, WorkflowAPI, WorkflowEvents, } from './types.js';
|
package/lib/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export function createWorkflowPlugin(_options = {}) {
|
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
|
|
28
29
|
export { defineWorkflow } from './definition.js';
|
|
29
30
|
export { createWorkflowEngine, parseEvery } from './engine.js';
|
|
30
31
|
export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents } from './schema.js';
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ContextFactory, SchemaExtension } from '@kubun/engine';
|
|
2
|
+
import type { AdaptivePolicy, InstanceStatus, ListFilter, PeriodicSync, WorkflowAPI, WorkflowEvents } from '@kubun/plugin-workflow-api';
|
|
2
3
|
import type { EventsSource } from '@sozai/event';
|
|
3
|
-
import type { InstanceStatus, ListFilter, WorkflowAPI, WorkflowEvents } from './types.js';
|
|
4
4
|
export type WorkflowEventResult = {
|
|
5
5
|
type: keyof WorkflowEvents;
|
|
6
6
|
instanceID: string;
|
|
@@ -31,6 +31,10 @@ export type WorkflowContext = {
|
|
|
31
31
|
get(instanceID: string): Promise<InstanceStatus | null>;
|
|
32
32
|
list(filter: ListFilter): Promise<Array<InstanceStatus>>;
|
|
33
33
|
subscribeToWorkflowEvents(instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
|
|
34
|
+
getPeriodicSync(scheduleID: string): Promise<PeriodicSync | null>;
|
|
35
|
+
listPeriodicSyncs(workflowName?: string): Promise<Array<PeriodicSync>>;
|
|
36
|
+
setPeriodicSyncEnabled(scheduleID: string, enabled: boolean): Promise<PeriodicSync | null>;
|
|
37
|
+
setPeriodicSyncPolicy(scheduleID: string, policy: AdaptivePolicy): Promise<PeriodicSync | null>;
|
|
34
38
|
};
|
|
35
39
|
declare module '@kubun/graphql' {
|
|
36
40
|
interface PluginContextMap {
|
package/lib/schema.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { PluginNodeID } from '@kubun/id';
|
|
2
|
+
// The plugin identity and Node type name in every PeriodicSync global id.
|
|
3
|
+
const PLUGIN_NAME = 'workflow';
|
|
4
|
+
const PERIODIC_SYNC_TYPE = 'PeriodicSync';
|
|
1
5
|
// Every lifecycle key. The source must listen to all of them and tag each event
|
|
2
6
|
// with its `type`, since one subscription surfaces the whole lifecycle.
|
|
3
7
|
const EVENT_TYPES = [
|
|
@@ -95,11 +99,43 @@ function requireWorkflow(ctx) {
|
|
|
95
99
|
}
|
|
96
100
|
return ctx.workflow;
|
|
97
101
|
}
|
|
102
|
+
// A PeriodicSync's GraphQL `id` is a global PluginNodeID; the schedule id is its
|
|
103
|
+
// localID. `node(id)` decodes centrally, but the typed fields receive the global
|
|
104
|
+
// id and must decode it here.
|
|
105
|
+
function decodeScheduleID(id) {
|
|
106
|
+
return PluginNodeID.fromString(id).localID;
|
|
107
|
+
}
|
|
108
|
+
// Refusal for a managed schedule reached through the generic control surface.
|
|
109
|
+
// A managed schedule is owned by its workflow's plugin and controlled only
|
|
110
|
+
// through that plugin's own owner-scoped path.
|
|
111
|
+
const MANAGED_SCHEDULE_ERROR = 'schedule is managed by its owning plugin';
|
|
98
112
|
export function createWorkflowContextFactory(api) {
|
|
113
|
+
// A managed schedule is excluded from the generic controls: queries omit it and
|
|
114
|
+
// mutations refuse it. The connector reaches its own schedules through the
|
|
115
|
+
// engine WorkflowAPI directly (not this context), so its owner-scoped path is
|
|
116
|
+
// untouched.
|
|
117
|
+
const isManaged = (sync)=>sync != null && api.isManagedWorkflow(sync.workflowName);
|
|
99
118
|
return ()=>({
|
|
100
119
|
get: (instanceID)=>api.get(instanceID),
|
|
101
120
|
list: (filter)=>api.list(filter),
|
|
102
|
-
subscribeToWorkflowEvents: (instanceID)=>subscribeToWorkflowEvents(api.events, instanceID)
|
|
121
|
+
subscribeToWorkflowEvents: (instanceID)=>subscribeToWorkflowEvents(api.events, instanceID),
|
|
122
|
+
getPeriodicSync: async (scheduleID)=>{
|
|
123
|
+
const sync = await api.getPeriodicSync(scheduleID);
|
|
124
|
+
return isManaged(sync) ? null : sync;
|
|
125
|
+
},
|
|
126
|
+
listPeriodicSyncs: async (workflowName)=>(await api.listPeriodicSyncs(workflowName)).filter((sync)=>!api.isManagedWorkflow(sync.workflowName)),
|
|
127
|
+
setPeriodicSyncEnabled: async (scheduleID, enabled)=>{
|
|
128
|
+
if (isManaged(await api.getPeriodicSync(scheduleID))) {
|
|
129
|
+
throw new Error(MANAGED_SCHEDULE_ERROR);
|
|
130
|
+
}
|
|
131
|
+
return api.setPeriodicSyncEnabled(scheduleID, enabled);
|
|
132
|
+
},
|
|
133
|
+
setPeriodicSyncPolicy: async (scheduleID, policy)=>{
|
|
134
|
+
if (isManaged(await api.getPeriodicSync(scheduleID))) {
|
|
135
|
+
throw new Error(MANAGED_SCHEDULE_ERROR);
|
|
136
|
+
}
|
|
137
|
+
return api.setPeriodicSyncPolicy(scheduleID, policy);
|
|
138
|
+
}
|
|
103
139
|
});
|
|
104
140
|
}
|
|
105
141
|
// ms-epoch timestamps are exposed as `Float` (JS number), matching how the
|
|
@@ -124,9 +160,28 @@ type WorkflowEvent {
|
|
|
124
160
|
name: String!
|
|
125
161
|
}
|
|
126
162
|
|
|
163
|
+
type PeriodicSync implements Node {
|
|
164
|
+
id: ID!
|
|
165
|
+
subject: String!
|
|
166
|
+
workflowName: String!
|
|
167
|
+
enabled: Boolean!
|
|
168
|
+
nextRun: Float!
|
|
169
|
+
lastOutcome: String
|
|
170
|
+
consecutiveCount: Int!
|
|
171
|
+
policy: JSON!
|
|
172
|
+
currentStatus: WorkflowInstanceStatus
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
extend type Query {
|
|
128
176
|
workflowInstance(instanceID: ID!): WorkflowInstanceStatus
|
|
129
177
|
workflowInstances(name: String, queue: String, status: String): [WorkflowInstanceStatus!]!
|
|
178
|
+
periodicSync(id: ID!): PeriodicSync
|
|
179
|
+
periodicSyncs(workflowName: String): [PeriodicSync!]!
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
extend type Mutation {
|
|
183
|
+
setPeriodicSyncEnabled(id: ID!, enabled: Boolean!): PeriodicSync!
|
|
184
|
+
setPeriodicSyncPolicy(id: ID!, policy: JSON!): PeriodicSync!
|
|
130
185
|
}
|
|
131
186
|
|
|
132
187
|
extend type Subscription {
|
|
@@ -143,7 +198,23 @@ export function createWorkflowSchemaExtension() {
|
|
|
143
198
|
name: args.name,
|
|
144
199
|
queue: args.queue,
|
|
145
200
|
status: args.status
|
|
146
|
-
})
|
|
201
|
+
}),
|
|
202
|
+
periodicSync: (_source, args, context)=>requireWorkflow(context).getPeriodicSync(decodeScheduleID(args.id)),
|
|
203
|
+
periodicSyncs: (_source, args, context)=>requireWorkflow(context).listPeriodicSyncs(args.workflowName)
|
|
204
|
+
},
|
|
205
|
+
mutationFields: {
|
|
206
|
+
setPeriodicSyncEnabled: (_source, args, context)=>requireWorkflow(context).setPeriodicSyncEnabled(decodeScheduleID(args.id), args.enabled),
|
|
207
|
+
setPeriodicSyncPolicy: (_source, args, context)=>requireWorkflow(context).setPeriodicSyncPolicy(decodeScheduleID(args.id), args.policy)
|
|
208
|
+
},
|
|
209
|
+
typeFields: {
|
|
210
|
+
PeriodicSync: {
|
|
211
|
+
id: (source)=>PluginNodeID.create(PLUGIN_NAME, PERIODIC_SYNC_TYPE, source.scheduleID).toString()
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
nodeResolvers: {
|
|
215
|
+
PeriodicSync: {
|
|
216
|
+
resolve: (localID, ctx)=>requireWorkflow(ctx).getPeriodicSync(localID)
|
|
217
|
+
}
|
|
147
218
|
},
|
|
148
219
|
subscriptionFields: {
|
|
149
220
|
workflowEvents: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,20 +15,22 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@kubun/db": "^0.14.0",
|
|
19
|
+
"@kubun/engine": "^0.14.1",
|
|
20
|
+
"@kubun/graphql": "^0.14.1",
|
|
21
|
+
"@kubun/id": "^0.14.0",
|
|
22
|
+
"@kubun/logger": "^0.14.0",
|
|
23
|
+
"@kubun/plugin-workflow-api": "^0.14.1",
|
|
24
|
+
"@kubun/store-workflow": "^0.14.1",
|
|
18
25
|
"@sozai/event": "^0.1.3",
|
|
19
|
-
"@sozai/flow": "^0.3.
|
|
26
|
+
"@sozai/flow": "^0.3.1",
|
|
20
27
|
"@sozai/runtime": "^0.1.0",
|
|
21
|
-
"@sozai/schema": "^0.1.
|
|
22
|
-
"@kubun/engine": "^0.13.0",
|
|
23
|
-
"@kubun/logger": "^0.13.0",
|
|
24
|
-
"@kubun/store-workflow": "^0.13.0",
|
|
25
|
-
"@kubun/graphql": "^0.13.0",
|
|
26
|
-
"@kubun/db": "^0.13.0"
|
|
28
|
+
"@sozai/schema": "^0.1.2"
|
|
27
29
|
},
|
|
28
30
|
"devDependencies": {
|
|
29
|
-
"@
|
|
30
|
-
"@kubun/db-postgres": "^0.
|
|
31
|
-
"@
|
|
31
|
+
"@kubun/db-better-sqlite": "^0.14.0",
|
|
32
|
+
"@kubun/db-postgres": "^0.14.0",
|
|
33
|
+
"@testcontainers/postgresql": "^12.1.0"
|
|
32
34
|
},
|
|
33
35
|
"publishConfig": {
|
|
34
36
|
"access": "public"
|
package/lib/types.d.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { WorkflowStatus } from '@kubun/store-workflow';
|
|
2
|
-
import type { EventsSource } from '@sozai/event';
|
|
3
|
-
import type { HandlersRecord } from '@sozai/flow';
|
|
4
|
-
import type { WorkflowDefinition } from './definition.js';
|
|
5
|
-
export type WorkflowEvents = {
|
|
6
|
-
started: {
|
|
7
|
-
instanceID: string;
|
|
8
|
-
name: string;
|
|
9
|
-
};
|
|
10
|
-
checkpoint: {
|
|
11
|
-
instanceID: string;
|
|
12
|
-
name: string;
|
|
13
|
-
};
|
|
14
|
-
failed: {
|
|
15
|
-
instanceID: string;
|
|
16
|
-
name: string;
|
|
17
|
-
};
|
|
18
|
-
completed: {
|
|
19
|
-
instanceID: string;
|
|
20
|
-
name: string;
|
|
21
|
-
};
|
|
22
|
-
cancelled: {
|
|
23
|
-
instanceID: string;
|
|
24
|
-
name: string;
|
|
25
|
-
};
|
|
26
|
-
};
|
|
27
|
-
export type InstanceStatus = {
|
|
28
|
-
instanceID: string;
|
|
29
|
-
name: string;
|
|
30
|
-
queue: string;
|
|
31
|
-
status: WorkflowStatus;
|
|
32
|
-
attempts: number;
|
|
33
|
-
lastError: string | null;
|
|
34
|
-
createdAt: number;
|
|
35
|
-
updatedAt: number;
|
|
36
|
-
};
|
|
37
|
-
export type InstanceHandle = {
|
|
38
|
-
instanceID: string;
|
|
39
|
-
};
|
|
40
|
-
export type EnqueueOptions = {
|
|
41
|
-
queue?: string;
|
|
42
|
-
priority?: number;
|
|
43
|
-
idempotencyKey?: string;
|
|
44
|
-
singletonKey?: string;
|
|
45
|
-
};
|
|
46
|
-
export type ListFilter = {
|
|
47
|
-
name?: string;
|
|
48
|
-
queue?: string;
|
|
49
|
-
status?: WorkflowStatus;
|
|
50
|
-
};
|
|
51
|
-
export type QueueOptions = {
|
|
52
|
-
concurrency: number;
|
|
53
|
-
ratePerMinute?: number;
|
|
54
|
-
};
|
|
55
|
-
export type ScheduleSpec = {
|
|
56
|
-
every: string;
|
|
57
|
-
};
|
|
58
|
-
export type ScheduleHandle = {
|
|
59
|
-
id: string;
|
|
60
|
-
disable(): Promise<void>;
|
|
61
|
-
enable(): Promise<void>;
|
|
62
|
-
cancel(): Promise<void>;
|
|
63
|
-
};
|
|
64
|
-
export type WorkflowAPI = {
|
|
65
|
-
register<State extends Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>>(def: WorkflowDefinition<State, Handlers>): void;
|
|
66
|
-
defineQueue(name: string, opts: QueueOptions): void;
|
|
67
|
-
enqueue(name: string, params?: unknown, opts?: EnqueueOptions): Promise<InstanceHandle>;
|
|
68
|
-
schedule(name: string, params: unknown, when: ScheduleSpec): Promise<ScheduleHandle>;
|
|
69
|
-
cancel(instanceID: string): Promise<void>;
|
|
70
|
-
get(instanceID: string): Promise<InstanceStatus | null>;
|
|
71
|
-
list(filter?: ListFilter): Promise<Array<InstanceStatus>>;
|
|
72
|
-
events: EventsSource<WorkflowEvents>;
|
|
73
|
-
};
|
package/lib/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { };
|