@senpi/runtime 3.0.118 → 3.0.123

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 (89) hide show
  1. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  2. package/dist/actions/open-position/open-position.action.js +9 -0
  3. package/dist/actions/open-position/open-position.action.js.map +1 -1
  4. package/dist/cli/guide-commands.d.ts.map +1 -1
  5. package/dist/cli/guide-commands.js +16 -8
  6. package/dist/cli/guide-commands.js.map +1 -1
  7. package/dist/constants/dsl/index.d.ts +17 -6
  8. package/dist/constants/dsl/index.d.ts.map +1 -1
  9. package/dist/constants/dsl/index.js +17 -6
  10. package/dist/constants/dsl/index.js.map +1 -1
  11. package/dist/dsl/engine/floors.d.ts +25 -1
  12. package/dist/dsl/engine/floors.d.ts.map +1 -1
  13. package/dist/dsl/engine/floors.js +38 -6
  14. package/dist/dsl/engine/floors.js.map +1 -1
  15. package/dist/dsl/engine/index.d.ts +1 -1
  16. package/dist/dsl/engine/index.d.ts.map +1 -1
  17. package/dist/dsl/engine/index.js +1 -1
  18. package/dist/dsl/engine/index.js.map +1 -1
  19. package/dist/dsl/engine/round.d.ts +22 -2
  20. package/dist/dsl/engine/round.d.ts.map +1 -1
  21. package/dist/dsl/engine/round.js +78 -5
  22. package/dist/dsl/engine/round.js.map +1 -1
  23. package/dist/dsl/engine/tick.d.ts.map +1 -1
  24. package/dist/dsl/engine/tick.js +31 -16
  25. package/dist/dsl/engine/tick.js.map +1 -1
  26. package/dist/dsl/events/initial-state.d.ts +1 -1
  27. package/dist/dsl/events/initial-state.d.ts.map +1 -1
  28. package/dist/dsl/events/initial-state.js +4 -7
  29. package/dist/dsl/events/initial-state.js.map +1 -1
  30. package/dist/dsl/monitor/exchange-sync.d.ts +8 -3
  31. package/dist/dsl/monitor/exchange-sync.d.ts.map +1 -1
  32. package/dist/dsl/monitor/exchange-sync.js +16 -4
  33. package/dist/dsl/monitor/exchange-sync.js.map +1 -1
  34. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  35. package/dist/dsl/monitor/process-one-position.js +88 -3
  36. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  37. package/dist/dsl/plugin/index.d.ts +8 -4
  38. package/dist/dsl/plugin/index.d.ts.map +1 -1
  39. package/dist/dsl/plugin/index.js +8 -4
  40. package/dist/dsl/plugin/index.js.map +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +168 -7
  43. package/dist/index.js.map +1 -1
  44. package/dist/runtime/boot-skip.d.ts +73 -0
  45. package/dist/runtime/boot-skip.d.ts.map +1 -0
  46. package/dist/runtime/boot-skip.js +113 -0
  47. package/dist/runtime/boot-skip.js.map +1 -0
  48. package/dist/runtime/create-runtime-context.d.ts +2 -0
  49. package/dist/runtime/create-runtime-context.d.ts.map +1 -1
  50. package/dist/runtime/create-runtime-context.js +1 -0
  51. package/dist/runtime/create-runtime-context.js.map +1 -1
  52. package/dist/runtime/lifecycle-events.d.ts +42 -1
  53. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  54. package/dist/runtime/lifecycle-events.js +81 -1
  55. package/dist/runtime/lifecycle-events.js.map +1 -1
  56. package/dist/runtime/lifecycle-reaper.d.ts +202 -0
  57. package/dist/runtime/lifecycle-reaper.d.ts.map +1 -0
  58. package/dist/runtime/lifecycle-reaper.js +390 -0
  59. package/dist/runtime/lifecycle-reaper.js.map +1 -0
  60. package/dist/runtime/run.d.ts +5 -0
  61. package/dist/runtime/run.d.ts.map +1 -1
  62. package/dist/runtime/run.js +1 -0
  63. package/dist/runtime/run.js.map +1 -1
  64. package/dist/runtime/runtime.d.ts +2 -0
  65. package/dist/runtime/runtime.d.ts.map +1 -1
  66. package/dist/runtime/runtime.js +1 -0
  67. package/dist/runtime/runtime.js.map +1 -1
  68. package/dist/runtime/update-apply-deps.d.ts +23 -2
  69. package/dist/runtime/update-apply-deps.d.ts.map +1 -1
  70. package/dist/runtime/update-apply-deps.js +11 -5
  71. package/dist/runtime/update-apply-deps.js.map +1 -1
  72. package/dist/skills-manager/manifest.d.ts.map +1 -1
  73. package/dist/skills-manager/manifest.js +1 -0
  74. package/dist/skills-manager/manifest.js.map +1 -1
  75. package/dist/skills-manager/skills-manifest.json +1 -0
  76. package/dist/types/dsl/index.d.ts +1 -1
  77. package/dist/types/dsl/index.d.ts.map +1 -1
  78. package/dist/types/runtime.d.ts +8 -0
  79. package/dist/types/runtime.d.ts.map +1 -1
  80. package/dist/utils/event-catalog.d.ts +91 -0
  81. package/dist/utils/event-catalog.d.ts.map +1 -1
  82. package/dist/utils/event-catalog.js +13 -0
  83. package/dist/utils/event-catalog.js.map +1 -1
  84. package/dist/utils/keyed-mutex.d.ts +3 -2
  85. package/dist/utils/keyed-mutex.d.ts.map +1 -1
  86. package/dist/utils/keyed-mutex.js +3 -2
  87. package/dist/utils/keyed-mutex.js.map +1 -1
  88. package/openclaw.plugin.json +1 -1
  89. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16,10 +16,11 @@ import { parse as parseYaml } from "yaml";
16
16
  import { join, dirname } from "node:path";
17
17
  import { SenpiClient } from "./senpi/client.js";
18
18
  import { buildWalletStatusHint } from "./senpi/strategy-status.js";
19
+ import { bootSkippedStatusLabel, partitionByWalletStatus, readBootWalletStatuses, trackableWalletKeys, } from "./runtime/boot-skip.js";
19
20
  import { DeployJobController } from "./deploy/job.js";
20
21
  import { registerDeployGatewayMethods } from "./deploy/register.js";
21
22
  import { registerUpdateGatewayMethod } from "./runtime/update-register.js";
22
- import { checkProofCoversRecipe, makeApplyToRuntime, makeWriteRegistry, } from "./runtime/update-apply-deps.js";
23
+ import { checkProofCoversRecipe, makeApplyToRuntime, makeWriteRegistry, runtimeChangeLock, } from "./runtime/update-apply-deps.js";
23
24
  import { INSTALL_DEADLINE_MS, WALLET_INSTALL_IN_FLIGHT_CODE } from "./deploy/deadlines.js";
24
25
  import { AuditQueryError } from "./runtime/audit-query.types.js";
25
26
  import { buildRuntimeStateDir, archiveStateDir } from "./runtime/state-layout.js";
@@ -52,7 +53,8 @@ import { registerToolApprovalGate, resolveToolApprovalsMode, } from "./approvals
52
53
  import { registerShowWidgetTool } from "./widgets/index.js";
53
54
  import { EVENT_NAME, SENPI_ERROR_CODE, eventAttrs, scannerUnwiredErrorCode } from "./utils/event-catalog.js";
54
55
  import { emitEvent } from "./actions/event-derivation.js";
55
- import { buildRuntimeCreatedEvent, buildRuntimeDeletedEvent, buildRuntimeUpdatedEvent } from "./runtime/lifecycle-events.js";
56
+ import { createLifecycleReaper, resolveReaperBootDelayMs, resolveReaperEnforcement, } from "./runtime/lifecycle-reaper.js";
57
+ import { buildRuntimeBootSkippedEvent, buildRuntimeBootStatusUnreadEvent, buildRuntimeCreatedEvent, buildRuntimeDeletedEvent, buildRuntimeUpdatedEvent, } from "./runtime/lifecycle-events.js";
56
58
  import { isWalletAddress } from "./utils/wallet-address.js";
57
59
  import { fileURLToPath } from "node:url";
58
60
  import { loadApiConfig } from "./runtime-api/config.js";
@@ -374,6 +376,10 @@ const senpiRuntimePlugin = {
374
376
  // cleared on a successful wire/install. senpi.listRuntimes reads it to render
375
377
  // "running — NO ENTRY SCANNERS" instead of a bare "running" for a blind runtime.
376
378
  const unwiredRuntimes = new Map();
379
+ // Registered runtimes boot left down because the platform reports their strategy wallet dead,
380
+ // keyed runtimeId → that status. senpi.listRuntimes reads it so the row says why it is stopped;
381
+ // cleared when the row is deleted.
382
+ const bootSkippedRuntimes = new Map();
377
383
  /**
378
384
  * Wallets with an install IN FLIGHT in this process, keyed by lowercased address.
379
385
  *
@@ -638,6 +644,39 @@ const senpiRuntimePlugin = {
638
644
  now: () => Date.now(),
639
645
  log: (line) => api.logger.info(`[senpi-runtime] ${line}`),
640
646
  });
647
+ // ── lifecycle reaper ─────────────────────────────────────────────────────────────────────────
648
+ // A runtime exists iff its strategy is open. Every close path ends at the backend and none of
649
+ // them tells this box, so the reaper asks: once after boot, hourly, and whenever the backend
650
+ // rejects an order with STRATEGY_NOT_ACTIVE. Observe-only unless enforcement is on (see
651
+ // runtime/lifecycle-reaper.ts for the safety rule). Constructed here so both `run()` call sites
652
+ // (boot and install) can hand it their not-active rejections; it does no I/O until started.
653
+ const reaperBootDelayOverride = resolveReaperBootDelayMs(process.env);
654
+ const reaper = createLifecycleReaper({
655
+ readRegistry: async () => {
656
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
657
+ return readRegistry(baseStateDir);
658
+ },
659
+ readStatuses: async (wallets, signal) => {
660
+ const client = mcpTransport ? new SenpiClient(mcpTransport) : new SenpiClient({ apiKey });
661
+ if (!mcpTransport)
662
+ await client.connect();
663
+ try {
664
+ return await client.listStrategies({ strategyAddresses: wallets }, { signal });
665
+ }
666
+ finally {
667
+ if (!mcpTransport)
668
+ await client.disconnect().catch(() => { });
669
+ }
670
+ },
671
+ deleteRuntime: async (id, reason, wallet) => {
672
+ const result = await deleteRuntimeCore({ id, deleteReason: reason, expectWallet: wallet });
673
+ return result.ok ? { ok: true } : { ok: false, code: result.error.code, message: result.error.message };
674
+ },
675
+ emit: (build) => emitEvent(apiLogger, build),
676
+ enforce: resolveReaperEnforcement(process.env),
677
+ logger: pluginLogger,
678
+ ...(reaperBootDelayOverride !== undefined ? { bootDelayMs: reaperBootDelayOverride } : {}),
679
+ });
641
680
  api.registerService({
642
681
  id: "senpi-runtime",
643
682
  async start() {
@@ -714,9 +753,48 @@ const senpiRuntimePlugin = {
714
753
  // publishes a level rather than recording a run.
715
754
  routeScaffoldTickToTelemetry = buildScaffoldTickRouter(ctx);
716
755
  }
756
+ // One bounded read: which registered wallets the platform says are dead, so a restart does
757
+ // not re-scaffold runtimes on closed wallets (fail-open — see runtime/boot-skip.ts).
758
+ let walletStatuses = null;
759
+ // Lower-cased and trackable-only: the backend matches addresses case-sensitively against
760
+ // lower-case storage, and rejects the whole batch on one malformed address.
761
+ const registeredWallets = trackableWalletKeys(registry.runtimes);
762
+ if (registeredWallets.length > 0) {
763
+ const read = await readBootWalletStatuses(registeredWallets, async (wallets, signal) => {
764
+ const client = mcpTransport ? new SenpiClient(mcpTransport) : new SenpiClient({ apiKey });
765
+ if (!mcpTransport)
766
+ await client.connect();
767
+ try {
768
+ return await client.listStrategies({ strategyAddresses: wallets }, { signal });
769
+ }
770
+ finally {
771
+ if (!mcpTransport)
772
+ await client.disconnect().catch(() => { });
773
+ }
774
+ });
775
+ if (read.ok) {
776
+ walletStatuses = read.statuses;
777
+ reaper.recordStatuses(registeredWallets, read.statuses);
778
+ }
779
+ else {
780
+ pluginLogger.warn("[senpi-runtime] Could not read strategy statuses at boot; booting every registered runtime", { reason: read.reason, err: read.message });
781
+ emitEvent(apiLogger, () => buildRuntimeBootStatusUnreadEvent({ reason: read.reason, registeredCount: registeredWallets.length }));
782
+ }
783
+ }
784
+ const { boot: entriesToBoot, skipped } = partitionByWalletStatus(registry.runtimes, walletStatuses);
785
+ bootSkippedRuntimes.clear();
786
+ for (const { entry, status } of skipped) {
787
+ bootSkippedRuntimes.set(entry.id, status);
788
+ pluginLogger.info("[senpi-runtime] Not booting runtime: its strategy wallet is closed", {
789
+ runtimeId: entry.id,
790
+ wallet: entry.wallet,
791
+ status,
792
+ });
793
+ emitEvent(apiLogger, () => buildRuntimeBootSkippedEvent({ runtimeId: entry.id, address: entry.wallet ?? "", status }));
794
+ }
717
795
  const started = [];
718
796
  try {
719
- for (const entry of registry.runtimes) {
797
+ for (const entry of entriesToBoot) {
720
798
  const hasPath = typeof entry.runtimeYamlPath === "string" && entry.runtimeYamlPath.length > 0;
721
799
  const hasContent = typeof entry.runtimeYamlContent === "string";
722
800
  if (hasPath && hasContent)
@@ -735,6 +813,7 @@ const senpiRuntimePlugin = {
735
813
  apiKey,
736
814
  scannerProviders,
737
815
  runtimeId: entry.id,
816
+ onStrategyNotActive: (address) => reaper.checkWallet(address),
738
817
  });
739
818
  runtimeHandles.set(entry.id, handle);
740
819
  started.push(entry.id);
@@ -911,6 +990,21 @@ const senpiRuntimePlugin = {
911
990
  if (process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
912
991
  liveTelemetry.start();
913
992
  }
993
+ // Last, once everything above is up: the reaper's post-boot pass and hourly sweep. Its
994
+ // not-active escalation is also off until here, so no delete can land while the boot loop is
995
+ // still wiring runtimes and launchers. Nothing here may fail start() — a throw from inside the boot loop takes every runtime down, which
996
+ // is exactly the failure a lifecycle check must never cause. The unit-test seam
997
+ // SENPI_RUNTIME_API_DISABLED keeps its timers off unless a boot delay is set explicitly.
998
+ try {
999
+ if (process.env.SENPI_RUNTIME_API_DISABLED !== "true" || reaperBootDelayOverride !== undefined) {
1000
+ reaper.start();
1001
+ }
1002
+ }
1003
+ catch (err) {
1004
+ pluginLogger.warn("[senpi-runtime] lifecycle reaper did not start", {
1005
+ err: err instanceof Error ? err.message : String(err),
1006
+ });
1007
+ }
914
1008
  },
915
1009
  async stop() {
916
1010
  // Live telemetry goes first: it owns a timer and an in-memory buffer, nothing downstream
@@ -918,6 +1012,10 @@ const senpiRuntimePlugin = {
918
1012
  // runtime handle. Records emitted during the teardown below land in a buffer nobody drains,
919
1013
  // which is the right trade for a process on its way out.
920
1014
  liveTelemetry.stop();
1015
+ // Then the reaper, before the runtimes are torn down below, so no pass starts deleting
1016
+ // while they go away. The wait is bounded, so a pass already under way cannot stop a handle
1017
+ // the teardown below is stopping too.
1018
+ await reaper.stop();
921
1019
  // Drain the HTTP server FIRST so in-flight POST /signals requests
922
1020
  // finish before the underlying runtime they target is torn down.
923
1021
  // `apiHandle.close()` never throws.
@@ -1250,6 +1348,7 @@ const senpiRuntimePlugin = {
1250
1348
  apiKey,
1251
1349
  scannerProviders,
1252
1350
  runtimeId: resolvedId,
1351
+ onStrategyNotActive: (address) => reaper.checkWallet(address),
1253
1352
  });
1254
1353
  runtimeHandles.set(resolvedId, handle);
1255
1354
  if (parsedConfig)
@@ -1385,8 +1484,11 @@ const senpiRuntimePlugin = {
1385
1484
  // say so rather than reporting a bare "running" the operator would read as healthy.
1386
1485
  // unwiredRuntimes is populated at the four wire/launch failure sites (and is
1387
1486
  // exactly the set of "declared external scanners but has no launcher").
1487
+ const bootSkipStatus = bootSkippedRuntimes.get(e.id);
1388
1488
  const status = !runtimeHandles.has(e.id)
1389
- ? "stopped"
1489
+ ? bootSkipStatus !== undefined
1490
+ ? bootSkippedStatusLabel(bootSkipStatus)
1491
+ : "stopped"
1390
1492
  : unwiredRuntimes.has(e.id)
1391
1493
  ? "running — NO ENTRY SCANNERS"
1392
1494
  : "running";
@@ -1397,6 +1499,10 @@ const senpiRuntimePlugin = {
1397
1499
  ? e.runtimeYamlPath
1398
1500
  : "(inline)",
1399
1501
  status,
1502
+ // What the strategy behind this runtime last read as (open / closing / closed / absent /
1503
+ // unchecked / unknown) — separate from `status`, whose running…/stopped… prefixes other
1504
+ // readers key on.
1505
+ ...reaper.lifecycleOf(e.wallet),
1400
1506
  // The deploy path installs by CONTENT, not path (orchestrator.ts installRuntime), so
1401
1507
  // `source` is "(inline)" for every deployed strategy and the YAML is unreachable through
1402
1508
  // it. The engine renders the facts instead of shipping a blob to re-interpret.
@@ -1411,13 +1517,19 @@ const senpiRuntimePlugin = {
1411
1517
  * unwired-verdict cleanup all happen exactly once, here.
1412
1518
  */
1413
1519
  async function deleteRuntimeCore(params) {
1414
- const { readRegistry, writeRegistry, withRegistryLock } = await import("./runtime/strategy-registry.js");
1415
- const registry = await readRegistry(baseStateDir);
1416
1520
  let targetId = typeof params?.id === "string" ? params.id : undefined;
1417
1521
  const targetAddress = typeof params?.address === "string" ? params.address : undefined;
1522
+ // Set only by the lifecycle reaper: why the strategy counts as gone (stamped on runtime.deleted)
1523
+ // and which wallet that verdict was about (the concurrency guard below).
1524
+ const deleteReason = params?.deleteReason === "closed_status" ? params.deleteReason
1525
+ : undefined;
1526
+ const expectWallet = typeof params?.expectWallet === "string" && params.expectWallet.trim().length > 0
1527
+ ? params.expectWallet.trim().toLowerCase()
1528
+ : undefined;
1418
1529
  // Resolve target by address if id not provided
1419
1530
  if (!targetId && targetAddress) {
1420
- const entry = registry.runtimes.find((e) => e.wallet === targetAddress);
1531
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
1532
+ const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.wallet === targetAddress);
1421
1533
  if (!entry) {
1422
1534
  return { ok: false, error: {
1423
1535
  code: "NOT_FOUND",
@@ -1432,6 +1544,24 @@ const senpiRuntimePlugin = {
1432
1544
  message: "Please provide the runtime id or wallet address. Example: openclaw senpi runtime delete --id my-runtime",
1433
1545
  } };
1434
1546
  }
1547
+ // One delete or update apply per runtime at a time (see `runtimeChangeLock`). Operator, agent
1548
+ // and deploy deletes wait their turn; the lifecycle reaper never queues behind someone else's
1549
+ // change to the runtime it judged — it backs off and the next lifecycle check re-reads it.
1550
+ // The check and the acquire below run in one synchronous step.
1551
+ const id = targetId;
1552
+ if (expectWallet !== undefined && runtimeChangeLock.hasKey(id)) {
1553
+ return { ok: false, error: {
1554
+ code: "CONFLICT",
1555
+ message: `Runtime "${id}" was not removed: another delete or an update is already running on it. Nothing was changed; the next lifecycle check re-reads it.`,
1556
+ } };
1557
+ }
1558
+ return runtimeChangeLock.withKeyLock(id, () => deleteRuntimeLocked(id, deleteReason, expectWallet));
1559
+ }
1560
+ /** {@link deleteRuntimeCore} past target resolution, under the runtime's change lock. */
1561
+ async function deleteRuntimeLocked(targetId, deleteReason, expectWallet) {
1562
+ const { readRegistry, writeRegistry, withRegistryLock } = await import("./runtime/strategy-registry.js");
1563
+ // Read under the lock: a delete that waited behind another change sees what that change left.
1564
+ const registry = await readRegistry(baseStateDir);
1435
1565
  const deletedEntry = registry.runtimes.find((e) => e.id === targetId);
1436
1566
  const handle = runtimeHandles.get(targetId);
1437
1567
  if (!deletedEntry && !handle) {
@@ -1440,6 +1570,35 @@ const senpiRuntimePlugin = {
1440
1570
  message: `No runtime found with id "${targetId}".`,
1441
1571
  } };
1442
1572
  }
1573
+ /**
1574
+ * The lifecycle reaper's guard. Its verdict was about one wallet at one moment; the delete
1575
+ * below must not land on anything else. Evaluated synchronously between the registry read
1576
+ * above and the first teardown step, so nothing can interleave between check and act:
1577
+ * - an install on that wallet is running (it could land a live runtime under this id);
1578
+ * - a deploy job holds the single-flight slot (it retires and reinstalls runtimes);
1579
+ * - the row under this id no longer carries that wallet (ids derive from name + wallet and
1580
+ * are reused, so a reinstall onto a new wallet can take the old id);
1581
+ * - a live handle under this id trades a different wallet.
1582
+ * Operator, agent and deploy deletes pass no `expectWallet` and are unaffected.
1583
+ */
1584
+ if (expectWallet !== undefined) {
1585
+ const fold = (w) => (w ?? "").trim().toLowerCase();
1586
+ const conflict = installsInFlight.has(expectWallet)
1587
+ ? "an install is running on that wallet"
1588
+ : deployController.running() !== null
1589
+ ? "a deploy is in progress"
1590
+ : !deletedEntry || fold(deletedEntry.wallet) !== expectWallet
1591
+ ? "the registry row under that id no longer carries that wallet"
1592
+ : handle && fold(handle.wallet) !== expectWallet
1593
+ ? "the running runtime under that id trades a different wallet"
1594
+ : undefined;
1595
+ if (conflict !== undefined) {
1596
+ return { ok: false, error: {
1597
+ code: "CONFLICT",
1598
+ message: `Runtime "${targetId}" was not removed: ${conflict}. Nothing was changed; the next lifecycle check re-reads it.`,
1599
+ } };
1600
+ }
1601
+ }
1443
1602
  if (handle) {
1444
1603
  await handle.teardownDslForRuntimeDeletion();
1445
1604
  await handle.stop();
@@ -1462,6 +1621,7 @@ const senpiRuntimePlugin = {
1462
1621
  // Same reuse hazard: a deleted id left in the index would keep publishing a strategy that no
1463
1622
  // longer exists, and would hand a reinstall the old name and scanner list.
1464
1623
  telemetryStrategies.delete(targetId);
1624
+ bootSkippedRuntimes.delete(targetId);
1465
1625
  // Archive state directory before removing from registry
1466
1626
  if (deletedEntry) {
1467
1627
  const runtimeStateDir = buildRuntimeStateDir(baseStateDir, targetId, deletedEntry.wallet ?? "");
@@ -1487,6 +1647,7 @@ const senpiRuntimePlugin = {
1487
1647
  runtimeId: targetId,
1488
1648
  address: deletedEntry?.wallet,
1489
1649
  hadLiveHandle: Boolean(handle),
1650
+ deleteReason,
1490
1651
  }));
1491
1652
  return { ok: true, payload: { id: targetId } };
1492
1653
  }