@kubun/plugin-workflow 0.15.1 → 0.17.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.
@@ -0,0 +1,16 @@
1
+ import type { WorkflowCommandRefusalReason, WorkflowControlPlaneAPI, WorkflowDiscoverOptions, WorkflowDiscoverReply } from '@kubun/plugin-p2p-api';
2
+ import type { EnqueueOptions, InstanceStatus } from '@kubun/plugin-workflow-api';
3
+ export declare class WorkflowCommandRefusedError extends Error {
4
+ #private;
5
+ constructor(reason: WorkflowCommandRefusalReason);
6
+ get reason(): WorkflowCommandRefusalReason;
7
+ }
8
+ export type WorkflowControlPlaneHelpers = {
9
+ discoverWorkflows(groupID: string, options?: WorkflowDiscoverOptions): Promise<Array<WorkflowDiscoverReply>>;
10
+ listWorkflows(groupID: string, targetDID: string): Promise<Array<InstanceStatus>>;
11
+ workflowStatus(groupID: string, targetDID: string, instanceID: string): Promise<InstanceStatus>;
12
+ enqueueWorkflow(groupID: string, targetDID: string, name: string, params?: unknown, opts?: EnqueueOptions): Promise<InstanceStatus>;
13
+ cancelWorkflow(groupID: string, targetDID: string, instanceID: string): Promise<InstanceStatus>;
14
+ retryWorkflow(groupID: string, targetDID: string, instanceID: string): Promise<InstanceStatus>;
15
+ };
16
+ export declare function createWorkflowControlPlaneHelpers(getP2P: () => Promise<WorkflowControlPlaneAPI>): WorkflowControlPlaneHelpers;
@@ -0,0 +1,51 @@
1
+ // A directed command refused by the remote peer. The p2p API returns refusals as
2
+ // DATA (enkaku collapses a handler throw to one generic error); this helper turns
3
+ // them back into a typed local throw, so app code sees a thrown `reason`.
4
+ export class WorkflowCommandRefusedError extends Error {
5
+ #reason;
6
+ constructor(reason){
7
+ super(refusalMessage(reason));
8
+ this.name = 'WorkflowCommandRefusedError';
9
+ this.#reason = reason;
10
+ }
11
+ get reason() {
12
+ return this.#reason;
13
+ }
14
+ }
15
+ // `not_found` avoids "no such instance": the remote refuses a missing instance and
16
+ // a non-commandable one identically, so the message must not claim it doesn't
17
+ // exist — only that it could not be commanded.
18
+ function refusalMessage(reason) {
19
+ switch(reason){
20
+ case 'not_commandable':
21
+ return 'workflow did not opt into remote command';
22
+ case 'not_found':
23
+ return 'workflow instance is not commandable (missing or not opted in)';
24
+ case 'not_failed':
25
+ return 'workflow instance is not in a failed state';
26
+ case 'no_next_action':
27
+ return 'workflow instance has no resumable action to retry';
28
+ case 'singleton':
29
+ return 'a live instance already holds the same singleton key';
30
+ }
31
+ }
32
+ function unwrap(result) {
33
+ if (!result.ok) {
34
+ throw new WorkflowCommandRefusedError(result.reason);
35
+ }
36
+ return result.status;
37
+ }
38
+ // `getP2P` resolves the p2p control plane lazily: the workflow API is built before
39
+ // every plugin factory returns, so `engine.getAPI('p2p')` cannot be awaited at
40
+ // build time. A device with no p2p plugin rejects here — the correct failure for a
41
+ // remote op with no transport.
42
+ export function createWorkflowControlPlaneHelpers(getP2P) {
43
+ return {
44
+ discoverWorkflows: async (groupID, options)=>(await getP2P()).discoverWorkflows(groupID, options),
45
+ listWorkflows: async (groupID, targetDID)=>(await getP2P()).listWorkflows(groupID, targetDID),
46
+ workflowStatus: async (groupID, targetDID, instanceID)=>(await getP2P()).workflowStatus(groupID, targetDID, instanceID),
47
+ enqueueWorkflow: async (groupID, targetDID, name, params, opts)=>unwrap(await (await getP2P()).enqueueWorkflow(groupID, targetDID, name, params, opts)),
48
+ cancelWorkflow: async (groupID, targetDID, instanceID)=>unwrap(await (await getP2P()).cancelWorkflow(groupID, targetDID, instanceID)),
49
+ retryWorkflow: async (groupID, targetDID, instanceID)=>unwrap(await (await getP2P()).retryWorkflow(groupID, targetDID, instanceID))
50
+ };
51
+ }
@@ -1,3 +1,5 @@
1
1
  import type { WorkflowDefinition } from '@kubun/plugin-workflow-api';
2
2
  import type { HandlersRecord } from '@sozai/flow';
3
3
  export declare function defineWorkflow<State extends Record<string, unknown>, Handlers extends HandlersRecord<State> = HandlersRecord<State>>(def: WorkflowDefinition<State, Handlers>): WorkflowDefinition<State, Handlers>;
4
+ export declare function isRemotelyObservable(def: Pick<WorkflowDefinition, 'remote'>): boolean;
5
+ export declare function isRemotelyCommandable(def: Pick<WorkflowDefinition, 'remote'>): boolean;
package/lib/definition.js CHANGED
@@ -6,3 +6,13 @@ export function defineWorkflow(def) {
6
6
  }
7
7
  return def;
8
8
  }
9
+ // Single normalize point for def.remote: absent field/sub-field, or a
10
+ // non-true value (including null, which optional booleans can read as),
11
+ // default closed. Typed on just the `remote` field so any WorkflowDefinition
12
+ // (whatever its State/Handlers) is accepted.
13
+ export function isRemotelyObservable(def) {
14
+ return def.remote?.observe === true;
15
+ }
16
+ export function isRemotelyCommandable(def) {
17
+ return def.remote?.command === true;
18
+ }
package/lib/engine.d.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  import type { Logger } from '@kubun/logger';
2
- import type { RetryPolicy, WorkflowAPI } from '@kubun/plugin-workflow-api';
2
+ import type { RetryPolicy, RetryRefusalReason, WorkflowAPI } from '@kubun/plugin-workflow-api';
3
3
  import type { WorkflowStoreAPI } from '@kubun/store-workflow';
4
4
  import type { GetRandomID } from '@sozai/runtime';
5
5
  export declare function backoff(attempts: number, policy?: RetryPolicy): number;
6
6
  export declare function parseEvery(spec: string): number;
7
+ export declare class WorkflowRetryError extends Error {
8
+ #private;
9
+ constructor(reason: RetryRefusalReason, message: string, options?: {
10
+ cause?: unknown;
11
+ });
12
+ get reason(): RetryRefusalReason;
13
+ }
7
14
  export type WorkflowEngineDeps = {
8
15
  getStore: () => Promise<WorkflowStoreAPI>;
9
16
  logger: Logger;
@@ -11,6 +18,7 @@ export type WorkflowEngineDeps = {
11
18
  getRandomID?: GetRandomID;
12
19
  tickMs?: number;
13
20
  disposeTimeoutMs?: number;
21
+ ready?: () => Promise<void>;
14
22
  };
15
23
  export type WorkflowEngine = {
16
24
  api: WorkflowAPI;
package/lib/engine.js CHANGED
@@ -27,13 +27,28 @@ const INTERVAL_UNITS = {
27
27
  export function parseEvery(spec) {
28
28
  const match = /^(\d+)(ms|s|m|h)?$/.exec(spec.trim());
29
29
  if (match == null) throw new Error(`Invalid interval "${spec}"`);
30
- return Number(match[1]) * (match[2] != null ? INTERVAL_UNITS[match[2]] : 1);
30
+ const unit = match[2];
31
+ const multiplier = unit != null ? INTERVAL_UNITS[unit] ?? 1 : 1;
32
+ return Number(match[1]) * multiplier;
31
33
  }
32
34
  const TERMINAL = new Set([
33
35
  'completed',
34
36
  'failed',
35
37
  'cancelled'
36
38
  ]);
39
+ // Typed refusal thrown by `retry` when it does not re-arm. `reason` lets a caller
40
+ // (or a GraphQL error consumer) branch on why, rather than parsing a message.
41
+ export class WorkflowRetryError extends Error {
42
+ #reason;
43
+ constructor(reason, message, options){
44
+ super(message, options);
45
+ this.name = 'WorkflowRetryError';
46
+ this.#reason = reason;
47
+ }
48
+ get reason() {
49
+ return this.#reason;
50
+ }
51
+ }
37
52
  function toStatus(row) {
38
53
  return {
39
54
  instanceID: row.instance_id,
@@ -91,6 +106,10 @@ export function createWorkflowEngine(deps) {
91
106
  // Set by dispose(): stops the scheduler from claiming/driving new work while
92
107
  // the existing in-flight drives are drained.
93
108
  let disposed = false;
109
+ // Cleared once the host engine's identity readiness gate resolves. While
110
+ // false the scheduler claims nothing; a mismatched identity leaves it false
111
+ // forever (see the ready() wiring below).
112
+ let identityReady = deps.ready == null;
94
113
  let storePromise;
95
114
  function store() {
96
115
  storePromise ??= deps.getStore();
@@ -347,16 +366,17 @@ export function createWorkflowEngine(deps) {
347
366
  }
348
367
  }
349
368
  async function tick() {
350
- // After dispose() no new work is claimed: a straggler tick (a queued nudge
351
- // or the interval firing before it was cleared) must not start fresh drives
352
- // while shutdown drains the in-flight ones.
353
- if (disposed) return;
369
+ // After dispose() no new work is claimed: a straggler tick (a queued nudge or
370
+ // the interval firing before it was cleared) must not start fresh drives while
371
+ // shutdown drains the in-flight ones. `identityReady` gates the same way — the
372
+ // scheduler claims nothing until the host engine verifies its database identity,
373
+ // and a mismatch bars it permanently.
374
+ if (disposed || !identityReady) return;
354
375
  try {
355
376
  const s = await store();
356
377
  // Re-check after every await that precedes a claim: dispose() may have run
357
- // during the gap. Bailing here — rather than only at entry — is what keeps
358
- // a tick that was already in-flight when dispose() fired from claiming and
359
- // driving fresh work after shutdown began.
378
+ // during the gap. Bailing here, not only at entry, keeps a tick already
379
+ // in-flight when dispose() fired from claiming fresh work after shutdown began.
360
380
  if (disposed) return;
361
381
  const at = now();
362
382
  // Promote due schedules first so their instances are claimable this pass.
@@ -407,6 +427,22 @@ export function createWorkflowEngine(deps) {
407
427
  // Allow the process to exit even while the scheduler interval is pending.
408
428
  const intervalHandle = interval;
409
429
  if (typeof intervalHandle?.unref === 'function') intervalHandle.unref();
430
+ // Wire the identity readiness gate. On resolve, open the gate and drive any work
431
+ // enqueued while it was closed. On rejection (an identity mismatch) keep the gate
432
+ // shut forever AND drop the interval, so the engine is quiescent rather than merely
433
+ // barred at the HLC mint — which remains the fail-closed backstop if a drive slips
434
+ // through.
435
+ if (deps.ready != null) {
436
+ void deps.ready().then(()=>{
437
+ identityReady = true;
438
+ scheduleTick();
439
+ }, ()=>{
440
+ if (interval != null) {
441
+ clearInterval(interval);
442
+ interval = null;
443
+ }
444
+ });
445
+ }
410
446
  const api = {
411
447
  register (def) {
412
448
  const erased = def;
@@ -424,6 +460,12 @@ export function createWorkflowEngine(deps) {
424
460
  isManagedWorkflow (name) {
425
461
  return registry.get(name)?.managed === true;
426
462
  },
463
+ isRemotelyObservable (name) {
464
+ return registry.get(name)?.remote?.observe === true;
465
+ },
466
+ isRemotelyCommandable (name) {
467
+ return registry.get(name)?.remote?.command === true;
468
+ },
427
469
  defineQueue,
428
470
  async schedule (name, params, when) {
429
471
  const s = await store();
@@ -527,23 +569,10 @@ export function createWorkflowEngine(deps) {
527
569
  runAfter: createdAt,
528
570
  createdAt
529
571
  };
530
- let result;
531
- try {
532
- result = await s.insertInstance(input);
533
- } catch (cause) {
534
- // A concurrent insert may lose the singleton race and surface the
535
- // partial-unique constraint as a thrown error. Treat it as "singleton
536
- // already active": return the existing active instance rather than throw.
537
- if (opts?.singletonKey != null) {
538
- const active = (await s.list({
539
- name
540
- })).find((r)=>r.singleton_key === opts.singletonKey && (r.status === 'pending' || r.status === 'running'));
541
- if (active != null) return {
542
- instanceID: active.instance_id
543
- };
544
- }
545
- throw cause;
546
- }
572
+ // insertInstance is atomic against a concurrent racer: an idempotency_key
573
+ // or active-singleton collision returns the winner's row as created:false
574
+ // rather than throwing, so no constraint-race catch is needed here.
575
+ const result = await s.insertInstance(input);
547
576
  // Idempotent or singleton hit: return the existing instance, drive nothing.
548
577
  if (!result.created) {
549
578
  return {
@@ -577,6 +606,54 @@ export function createWorkflowEngine(deps) {
577
606
  name: row.name
578
607
  });
579
608
  },
609
+ async retry (instanceID) {
610
+ const s = await store();
611
+ // Read first to classify a would-be no-op into a precise refusal; the
612
+ // store's guarded UPDATE remains the source of truth (a status flipped
613
+ // between this read and the write surfaces as an unapplied re-arm below).
614
+ const row = await s.get(instanceID);
615
+ if (row == null) {
616
+ throw new WorkflowRetryError('not_found', `Workflow instance "${instanceID}" not found`);
617
+ }
618
+ if (row.status !== 'failed') {
619
+ throw new WorkflowRetryError('not_failed', `Workflow instance "${instanceID}" is ${row.status}, not failed`);
620
+ }
621
+ if (row.next_action == null) {
622
+ throw new WorkflowRetryError('no_next_action', `Workflow instance "${instanceID}" has no resumable action to retry`);
623
+ }
624
+ if (row.singleton_key != null) {
625
+ const live = (await s.list({
626
+ name: row.name
627
+ })).find((r)=>r.singleton_key === row.singleton_key && r.instance_id !== instanceID && (r.status === 'pending' || r.status === 'running'));
628
+ if (live != null) {
629
+ throw new WorkflowRetryError('singleton', `Workflow "${row.name}" already has a live instance for singleton "${row.singleton_key}"`);
630
+ }
631
+ }
632
+ let rearmed;
633
+ try {
634
+ rearmed = await s.retryFailed(instanceID, now());
635
+ } catch (cause) {
636
+ // The only constraint the guarded UPDATE can violate is the singleton
637
+ // partial-unique index: a concurrent enqueue won the race after our
638
+ // pre-check. Surface it as the singleton refusal, not a raw driver error.
639
+ if (row.singleton_key != null) {
640
+ // biome-ignore lint/style/useErrorCause: cause is forwarded to super via the third options arg
641
+ throw new WorkflowRetryError('singleton', `Workflow "${row.name}" already has a live instance for singleton "${row.singleton_key}"`, {
642
+ cause
643
+ });
644
+ }
645
+ throw cause;
646
+ }
647
+ if (rearmed == null) {
648
+ // The status guard did not match: a concurrent retry (or reclaim) flipped
649
+ // it out of `failed` first. Never a false success.
650
+ throw new WorkflowRetryError('not_failed', `Workflow instance "${instanceID}" was no longer failed when re-armed`);
651
+ }
652
+ // Wake the scheduler so the re-armed instance is claimed without waiting
653
+ // for the poll interval, exactly as enqueue does.
654
+ scheduleTick();
655
+ return toStatus(rearmed);
656
+ },
580
657
  async get (instanceID) {
581
658
  const s = await store();
582
659
  const row = await s.get(instanceID);
@@ -651,11 +728,10 @@ export function createWorkflowEngine(deps) {
651
728
  }
652
729
  // Abort every live drive, then drain to quiescence: an aborted run still
653
730
  // issues its `cancelled` terminal write, and aborting frees a lane so a
654
- // straggler drive may appear — so loop until nothing is in flight rather
655
- // than awaiting a single snapshot. `disposed` keeps tick() from adding new
656
- // work, so a cooperative drive set terminates. When the drain wins the
657
- // race below, no scheduled write is still in flight and the caller can
658
- // safely close the store.
731
+ // straggler drive may appear — so loop until nothing is in flight rather than
732
+ // awaiting a single snapshot. `disposed` keeps tick() from adding new work, so
733
+ // a cooperative drive set terminates and the caller can then safely close the
734
+ // store.
659
735
  for (const controller of running.values())controller.abort();
660
736
  running.clear();
661
737
  let drainedDone = false;
@@ -666,12 +742,12 @@ export function createWorkflowEngine(deps) {
666
742
  drainedDone = true;
667
743
  })();
668
744
  // Bound the wait: a handler that never observes its abort signal leaves an
669
- // un-settling drive in `inflight`, and an unbounded drain would wedge
670
- // shutdown forever. Resolve best-effort on the deadline instead; the timer
671
- // is unref'd so it never itself holds the process open. NOTE: on timeout,
672
- // genuine writes may still be in flight, so a caller that then closes the
673
- // store can race them — the deadline trades that residual for a bounded
674
- // shutdown. It only fires when a drive is not settling, which is logged.
745
+ // un-settling drive in `inflight`, and an unbounded drain would wedge shutdown
746
+ // forever. Resolve best-effort on the deadline instead; the timer is unref'd so
747
+ // it never holds the process open. On timeout genuine writes may still be in
748
+ // flight, so a caller that then closes the store can race them — the deadline
749
+ // trades that residual for a bounded shutdown, and only fires for a
750
+ // non-settling drive (logged).
675
751
  let timer;
676
752
  const deadline = new Promise((resolve)=>{
677
753
  timer = setTimeout(resolve, disposeTimeoutMs);
package/lib/index.d.ts CHANGED
@@ -2,8 +2,9 @@ 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
4
  export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
5
- export { defineWorkflow } from './definition.js';
5
+ export { createWorkflowControlPlaneHelpers, WorkflowCommandRefusedError, type WorkflowControlPlaneHelpers, } from './control-plane.js';
6
+ export { defineWorkflow, isRemotelyCommandable, isRemotelyObservable } from './definition.js';
6
7
  export type { WorkflowEngine, WorkflowEngineDeps } from './engine.js';
7
- export { createWorkflowEngine, parseEvery } from './engine.js';
8
+ export { createWorkflowEngine, parseEvery, WorkflowRetryError } from './engine.js';
8
9
  export type { WorkflowContext, WorkflowEventResult } from './schema.js';
9
10
  export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents, } from './schema.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getWorkflowStore, workflowStoreDefinition } from '@kubun/store-workflow';
2
+ import { createWorkflowControlPlaneHelpers } from './control-plane.js';
2
3
  import { createWorkflowEngine } from './engine.js';
3
4
  import { createWorkflowContextFactory, createWorkflowSchemaExtension } from './schema.js';
4
5
  export function createWorkflowPlugin(_options = {}) {
@@ -8,17 +9,31 @@ export function createWorkflowPlugin(_options = {}) {
8
9
  const engine = createWorkflowEngine({
9
10
  getStore: ()=>getWorkflowStore(params.db),
10
11
  logger,
11
- getRandomID: ()=>params.runtime.getRandomID()
12
+ getRandomID: ()=>params.runtime.getRandomID(),
13
+ // Gate the scheduler on the host engine's identity verification: nudges and
14
+ // the interval claim no work until `ready()` resolves, and an identity
15
+ // mismatch leaves the engine inert rather than driving jobs on a database it
16
+ // was rejected from.
17
+ ready: ()=>params.engine.ready()
12
18
  });
13
- // No scheduler in Phase 1: re-drive stranded instances once on boot.
14
- void engine.recover().catch((error)=>{
19
+ // Re-drive stranded instances once on boot, but only after the host engine
20
+ // verifies its database identity — a re-driven instance mints under the engine's
21
+ // clock, so recovery waits behind `ready()` rather than tripping the mint gate.
22
+ void params.engine.ready().then(()=>engine.recover()).catch((error)=>{
15
23
  logger.error('workflow recovery failed', {
16
24
  error
17
25
  });
18
26
  });
27
+ // Caller-side control-plane helpers over the p2p plugin's typed API. Resolved
28
+ // lazily (getAPI cannot be awaited while factories are still returning); a device
29
+ // with no p2p plugin rejects on first use.
30
+ const controlPlane = createWorkflowControlPlaneHelpers(()=>params.engine.getAPI('p2p'));
19
31
  return {
20
32
  name: 'workflow',
21
- api: engine.api,
33
+ api: {
34
+ ...engine.api,
35
+ ...controlPlane
36
+ },
22
37
  schemaExtension: ()=>createWorkflowSchemaExtension(),
23
38
  createContextFactory: ()=>createWorkflowContextFactory(engine.api),
24
39
  dispose: ()=>engine.dispose()
@@ -26,6 +41,7 @@ export function createWorkflowPlugin(_options = {}) {
26
41
  };
27
42
  }
28
43
  export { computeNextDelay, validateAdaptivePolicy } from './adaptive.js';
29
- export { defineWorkflow } from './definition.js';
30
- export { createWorkflowEngine, parseEvery } from './engine.js';
44
+ export { createWorkflowControlPlaneHelpers, WorkflowCommandRefusedError } from './control-plane.js';
45
+ export { defineWorkflow, isRemotelyCommandable, isRemotelyObservable } from './definition.js';
46
+ export { createWorkflowEngine, parseEvery, WorkflowRetryError } from './engine.js';
31
47
  export { createWorkflowContextFactory, createWorkflowSchemaExtension, subscribeToWorkflowEvents } from './schema.js';
package/lib/schema.d.ts CHANGED
@@ -9,14 +9,13 @@ export type WorkflowEventResult = {
9
9
  /**
10
10
  * Subscribe to a workflow's lifecycle events, yielding GraphQL result shapes.
11
11
  *
12
- * Implemented as a manual async iterator rather than an `async function*` so a
13
- * consumer's `return()` removes all listeners immediately. A `for await` wrapper
14
- * would queue the `return()` behind a parked `next()` between events, leaking the
15
- * listeners until the next event — for an idle subscription, potentially never.
12
+ * A manual async iterator, not an `async function*`, so a consumer's `return()`
13
+ * removes all listeners immediately; a `for await` wrapper would queue `return()`
14
+ * behind a parked `next()` and leak the listeners until the next event — for an
15
+ * idle subscription, potentially never.
16
16
  *
17
- * `fromEmitter` (the connector template) handles a single event name over an
18
- * `EventEmitter`; `WorkflowEvents` has five keys and the API exposes only the
19
- * listen-only `EventsSource`, so the fan-in is done by hand over `on`.
17
+ * `fromEmitter` handles a single event name; `WorkflowEvents` has five keys and the
18
+ * API exposes only the listen-only `EventsSource`, so the fan-in is done by hand.
20
19
  */
21
20
  export declare function subscribeToWorkflowEvents(events: EventsSource<WorkflowEvents>, instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
22
21
  /**
@@ -30,6 +29,8 @@ export declare function subscribeToWorkflowEvents(events: EventsSource<WorkflowE
30
29
  export type WorkflowContext = {
31
30
  get(instanceID: string): Promise<InstanceStatus | null>;
32
31
  list(filter: ListFilter): Promise<Array<InstanceStatus>>;
32
+ retry(instanceID: string): Promise<InstanceStatus>;
33
+ cancel(instanceID: string): Promise<void>;
33
34
  subscribeToWorkflowEvents(instanceID?: string | null): AsyncIterableIterator<WorkflowEventResult>;
34
35
  getPeriodicSync(scheduleID: string): Promise<PeriodicSync | null>;
35
36
  listPeriodicSyncs(workflowName?: string): Promise<Array<PeriodicSync>>;
package/lib/schema.js CHANGED
@@ -14,14 +14,13 @@ const EVENT_TYPES = [
14
14
  /**
15
15
  * Subscribe to a workflow's lifecycle events, yielding GraphQL result shapes.
16
16
  *
17
- * Implemented as a manual async iterator rather than an `async function*` so a
18
- * consumer's `return()` removes all listeners immediately. A `for await` wrapper
19
- * would queue the `return()` behind a parked `next()` between events, leaking the
20
- * listeners until the next event — for an idle subscription, potentially never.
17
+ * A manual async iterator, not an `async function*`, so a consumer's `return()`
18
+ * removes all listeners immediately; a `for await` wrapper would queue `return()`
19
+ * behind a parked `next()` and leak the listeners until the next event — for an
20
+ * idle subscription, potentially never.
21
21
  *
22
- * `fromEmitter` (the connector template) handles a single event name over an
23
- * `EventEmitter`; `WorkflowEvents` has five keys and the API exposes only the
24
- * listen-only `EventsSource`, so the fan-in is done by hand over `on`.
22
+ * `fromEmitter` handles a single event name; `WorkflowEvents` has five keys and the
23
+ * API exposes only the listen-only `EventsSource`, so the fan-in is done by hand.
25
24
  */ export function subscribeToWorkflowEvents(events, instanceID) {
26
25
  const filter = instanceID != null ? (data)=>data.instanceID === instanceID : undefined;
27
26
  let isDone = false;
@@ -118,6 +117,8 @@ export function createWorkflowContextFactory(api) {
118
117
  return ()=>({
119
118
  get: (instanceID)=>api.get(instanceID),
120
119
  list: (filter)=>api.list(filter),
120
+ retry: (instanceID)=>api.retry(instanceID),
121
+ cancel: (instanceID)=>api.cancel(instanceID),
121
122
  subscribeToWorkflowEvents: (instanceID)=>subscribeToWorkflowEvents(api.events, instanceID),
122
123
  getPeriodicSync: async (scheduleID)=>{
123
124
  const sync = await api.getPeriodicSync(scheduleID);
@@ -182,6 +183,13 @@ extend type Query {
182
183
  extend type Mutation {
183
184
  setPeriodicSyncEnabled(id: ID!, enabled: Boolean!): PeriodicSync!
184
185
  setPeriodicSyncPolicy(id: ID!, policy: JSON!): PeriodicSync!
186
+ # Operator re-arm of a failed instance. Errors (not a null result) when the
187
+ # instance is missing, not failed, has no resumable action, or a live
188
+ # singleton already holds its key.
189
+ retryWorkflow(instanceID: ID!): WorkflowInstanceStatus!
190
+ # Operator-initiated cancel. Async: cancel() only requests cancellation, so the
191
+ # returned status is a re-fetch right after and may not yet be terminal.
192
+ cancelWorkflow(instanceID: ID!): WorkflowInstanceStatus!
185
193
  }
186
194
 
187
195
  extend type Subscription {
@@ -204,7 +212,18 @@ export function createWorkflowSchemaExtension() {
204
212
  },
205
213
  mutationFields: {
206
214
  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)
215
+ setPeriodicSyncPolicy: (_source, args, context)=>requireWorkflow(context).setPeriodicSyncPolicy(decodeScheduleID(args.id), args.policy),
216
+ retryWorkflow: (_source, args, context)=>requireWorkflow(context).retry(args.instanceID),
217
+ cancelWorkflow: async (_source, args, context)=>{
218
+ const ctx = requireWorkflow(context);
219
+ const instanceID = args.instanceID;
220
+ await ctx.cancel(instanceID);
221
+ const status = await ctx.get(instanceID);
222
+ if (status == null) {
223
+ throw new Error(`Workflow instance "${instanceID}" not found`);
224
+ }
225
+ return status;
226
+ }
208
227
  },
209
228
  typeFields: {
210
229
  PeriodicSync: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-workflow",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,21 +15,22 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/db": "^0.15.0",
19
- "@kubun/engine": "^0.15.0",
20
- "@kubun/graphql": "^0.15.0",
21
- "@kubun/id": "^0.15.0",
22
- "@kubun/logger": "^0.15.0",
23
- "@kubun/plugin-workflow-api": "^0.15.0",
24
- "@kubun/store-workflow": "^0.15.0",
18
+ "@kubun/db": "^0.17.0",
19
+ "@kubun/engine": "^0.17.0",
20
+ "@kubun/graphql": "^0.17.0",
21
+ "@kubun/id": "^0.17.0",
22
+ "@kubun/logger": "^0.17.0",
23
+ "@kubun/plugin-p2p-api": "^0.17.0",
24
+ "@kubun/plugin-workflow-api": "^0.17.0",
25
+ "@kubun/store-workflow": "^0.17.0",
25
26
  "@sozai/event": "^0.1.3",
26
27
  "@sozai/flow": "^0.3.1",
27
28
  "@sozai/runtime": "^0.1.0",
28
29
  "@sozai/schema": "^0.1.2"
29
30
  },
30
31
  "devDependencies": {
31
- "@kubun/db-better-sqlite": "^0.15.0",
32
- "@kubun/db-postgres": "^0.15.0",
32
+ "@kubun/db-better-sqlite": "^0.17.0",
33
+ "@kubun/db-postgres": "^0.17.0",
33
34
  "@testcontainers/postgresql": "^12.1.0"
34
35
  },
35
36
  "publishConfig": {
@@ -38,8 +39,8 @@
38
39
  "scripts": {
39
40
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
40
41
  "build:clean": "del lib",
41
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
42
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
42
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
43
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
43
44
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
44
45
  "test": "pnpm run test:types && pnpm run test:unit",
45
46
  "test:types": "tsc --noEmit -p tsconfig.test.json",