@capybara-hq/kernel 0.1.0-rc.21 → 0.1.0-rc.23

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 (263) hide show
  1. package/dist/admin/admin.controller.d.ts +3 -3
  2. package/dist/admin/admin.controller.js +6 -2
  3. package/dist/admin/admin.schemas.d.ts +13 -0
  4. package/dist/agents/admin-agents.d.ts +16 -3
  5. package/dist/agents/admin-agents.js +65 -7
  6. package/dist/agents/agents.schemas.d.ts +24 -6
  7. package/dist/agents/agents.service.d.ts +4 -3
  8. package/dist/agents/agents.service.js +39 -26
  9. package/dist/agents/definition.d.ts +44 -6
  10. package/dist/agents/definition.js +86 -18
  11. package/dist/agents/from-template.d.ts +6 -3
  12. package/dist/agents/from-template.js +57 -44
  13. package/dist/agents/org-templates.js +97 -41
  14. package/dist/agents/templates.d.ts +6 -0
  15. package/dist/agents/templates.js +15 -1
  16. package/dist/background/launch.d.ts +43 -5
  17. package/dist/background/launch.js +88 -8
  18. package/dist/background/session.d.ts +14 -2
  19. package/dist/background/session.js +26 -4
  20. package/dist/background/status.d.ts +1 -1
  21. package/dist/background/status.js +3 -3
  22. package/dist/background/stop.d.ts +0 -2
  23. package/dist/background/stop.js +1 -21
  24. package/dist/catalog/admin-catalog.controller.d.ts +2 -4
  25. package/dist/catalog/admin-catalog.controller.js +2 -6
  26. package/dist/catalog/catalog-content.d.ts +5 -0
  27. package/dist/catalog/catalog-content.js +1 -0
  28. package/dist/catalog/catalog.module.js +3 -0
  29. package/dist/catalog/claude/report.d.ts +9 -2
  30. package/dist/catalog/claude/report.js +1 -0
  31. package/dist/catalog/claude/translate.js +1 -1
  32. package/dist/catalog/component-promotion.js +1 -2
  33. package/dist/catalog/component-state.d.ts +13 -1
  34. package/dist/catalog/component-state.js +14 -5
  35. package/dist/catalog/marketplaces.service.d.ts +1 -1
  36. package/dist/catalog/marketplaces.service.js +2 -1
  37. package/dist/catalog/org-catalog.controller.d.ts +13 -3
  38. package/dist/catalog/org-catalog.controller.js +26 -4
  39. package/dist/catalog/org-catalog.schemas.d.ts +13 -0
  40. package/dist/catalog/org-catalog.schemas.js +34 -0
  41. package/dist/catalog/org-plugin-uploads.controller.js +0 -1
  42. package/dist/catalog/org-plugin-uploads.d.ts +0 -1
  43. package/dist/catalog/org-plugin-uploads.js +0 -3
  44. package/dist/catalog/org-plugin-uploads.schemas.d.ts +0 -1
  45. package/dist/catalog/org-plugin-uploads.schemas.js +0 -2
  46. package/dist/catalog/platform-components.d.ts +0 -1
  47. package/dist/catalog/platform-components.js +3 -8
  48. package/dist/catalog/plugin-import.d.ts +0 -8
  49. package/dist/catalog/plugin-import.js +4 -35
  50. package/dist/catalog/plugin-install.d.ts +2 -2
  51. package/dist/catalog/plugin-install.js +2 -2
  52. package/dist/catalog/plugin-package.d.ts +7 -0
  53. package/dist/catalog/plugin-package.js +27 -1
  54. package/dist/catalog/plugin-upgrade.d.ts +5 -6
  55. package/dist/catalog/plugin-upgrade.js +5 -23
  56. package/dist/catalog/requests.service.d.ts +66 -10
  57. package/dist/catalog/requests.service.js +196 -15
  58. package/dist/channels/actions.d.ts +2 -0
  59. package/dist/channels/actions.js +19 -4
  60. package/dist/channels/cards.d.ts +16 -2
  61. package/dist/channels/cards.js +40 -7
  62. package/dist/channels/channels.module.js +3 -0
  63. package/dist/channels/outbound.d.ts +22 -1
  64. package/dist/channels/outbound.js +116 -7
  65. package/dist/channels/phrases.d.ts +25 -0
  66. package/dist/channels/phrases.js +19 -0
  67. package/dist/channels/threads.d.ts +12 -0
  68. package/dist/channels/threads.js +34 -0
  69. package/dist/config/schema.d.ts +23 -1
  70. package/dist/config/schema.js +107 -5
  71. package/dist/conversations/conversations.service.d.ts +42 -9
  72. package/dist/conversations/conversations.service.js +57 -25
  73. package/dist/conversations/events.d.ts +106 -11
  74. package/dist/conversations/events.js +121 -18
  75. package/dist/conversations/message-context.d.ts +6 -3
  76. package/dist/conversations/message-context.js +14 -10
  77. package/dist/conversations/queue.d.ts +15 -2
  78. package/dist/conversations/queue.js +22 -3
  79. package/dist/conversations/queue.service.d.ts +12 -6
  80. package/dist/conversations/queue.service.js +36 -13
  81. package/dist/db/kernel-schema.d.ts +105 -3
  82. package/dist/devices/conversation-devices.d.ts +8 -1
  83. package/dist/devices/conversation-devices.js +8 -0
  84. package/dist/devices/devices.controller.d.ts +21 -4
  85. package/dist/devices/devices.controller.js +46 -8
  86. package/dist/devices/devices.schemas.d.ts +9 -1
  87. package/dist/devices/devices.schemas.js +11 -3
  88. package/dist/devices/devices.service.d.ts +176 -4
  89. package/dist/devices/devices.service.js +347 -6
  90. package/dist/devices/dto.d.ts +56 -3
  91. package/dist/devices/dto.js +51 -1
  92. package/dist/devices/public-suffix.d.ts +6 -0
  93. package/dist/devices/public-suffix.js +79 -0
  94. package/dist/errors.d.ts +50 -5
  95. package/dist/errors.js +50 -5
  96. package/dist/evals/reconstruct.d.ts +18 -13
  97. package/dist/evals/reconstruct.js +50 -36
  98. package/dist/evals/recorded-tools.d.ts +7 -7
  99. package/dist/evals/recorded-tools.js +4 -3
  100. package/dist/evals/trajectory.d.ts +6 -1
  101. package/dist/evals/trajectory.js +7 -0
  102. package/dist/extensions/package.js +2 -2
  103. package/dist/http/api.module.js +5 -0
  104. package/dist/identity/settings.d.ts +53 -3
  105. package/dist/identity/settings.js +92 -9
  106. package/dist/inbox/inbox.module.js +2 -1
  107. package/dist/inbox/inbox.service.d.ts +19 -4
  108. package/dist/inbox/inbox.service.js +14 -3
  109. package/dist/infra/config/kernel-config.service.d.ts +7 -0
  110. package/dist/infra/config/kernel-config.service.js +31 -0
  111. package/dist/infra/events/kernel-events.d.ts +2 -1
  112. package/dist/local-devices/device-policy.service.d.ts +65 -0
  113. package/dist/local-devices/device-policy.service.js +217 -0
  114. package/dist/local-devices/local-device-gateway.d.ts +4 -2
  115. package/dist/local-devices/local-device-gateway.js +39 -11
  116. package/dist/local-devices/local-device-provider.d.ts +40 -3
  117. package/dist/local-devices/local-device-provider.js +109 -7
  118. package/dist/local-devices/local-devices.module.d.ts +2 -1
  119. package/dist/local-devices/local-devices.module.js +19 -3
  120. package/dist/local-devices/machines.d.ts +30 -3
  121. package/dist/local-devices/machines.js +79 -3
  122. package/dist/local-devices/network-grants.d.ts +104 -0
  123. package/dist/local-devices/network-grants.js +288 -0
  124. package/dist/local-devices/policy-summary.d.ts +20 -0
  125. package/dist/local-devices/policy-summary.js +63 -0
  126. package/dist/orgs/orgs.module.js +4 -10
  127. package/dist/orgs/roles.d.ts +1 -1
  128. package/dist/orgs/roles.js +4 -0
  129. package/dist/orgs/settings.controller.js +4 -3
  130. package/dist/orgs/settings.service.d.ts +172 -14
  131. package/dist/orgs/settings.service.js +229 -27
  132. package/dist/platform/exclusions.js +11 -1
  133. package/dist/platform/operation.js +4 -0
  134. package/dist/platform/ops/admin-items.ops.js +1 -1
  135. package/dist/platform/ops/admin.ops.js +32 -7
  136. package/dist/platform/ops/agent-skills.ops.js +2 -2
  137. package/dist/platform/ops/agent-templates.ops.js +2 -2
  138. package/dist/platform/ops/agents.ops.js +11 -15
  139. package/dist/platform/ops/devices.ops.js +3 -1
  140. package/dist/platform/ops/inbox.ops.js +19 -0
  141. package/dist/platform/ops/org-settings.ops.js +15 -6
  142. package/dist/platform/ops/orgs.ops.js +1 -0
  143. package/dist/platform/ops/platform.ops.d.ts +9 -1
  144. package/dist/platform/ops/platform.ops.js +34 -12
  145. package/dist/platform/ops/requests.ops.js +3 -2
  146. package/dist/platform/ops/schedules.ops.d.ts +21 -0
  147. package/dist/platform/ops/schedules.ops.js +470 -0
  148. package/dist/platform/ops/skills.ops.js +3 -3
  149. package/dist/platform/platform-ops.module.d.ts +2 -1
  150. package/dist/platform/platform-ops.module.js +5 -0
  151. package/dist/platform/route-schemas.js +3 -0
  152. package/dist/roles/worker.module.js +3 -0
  153. package/dist/runtime/context/build.js +13 -4
  154. package/dist/runtime/context/compaction.js +6 -3
  155. package/dist/runtime/context/device-paths.d.ts +43 -21
  156. package/dist/runtime/context/device-paths.js +104 -21
  157. package/dist/runtime/context/run-context.d.ts +9 -0
  158. package/dist/runtime/context/run-context.js +8 -0
  159. package/dist/runtime/context/skills.d.ts +14 -1
  160. package/dist/runtime/context/skills.js +36 -5
  161. package/dist/runtime/maintenance.d.ts +4 -2
  162. package/dist/runtime/maintenance.js +16 -5
  163. package/dist/runtime/run-finish.d.ts +6 -1
  164. package/dist/runtime/run-finish.js +16 -2
  165. package/dist/runtime/run-queue.js +21 -2
  166. package/dist/runtime/run-worker.js +14 -33
  167. package/dist/runtime/runtime.module.js +3 -0
  168. package/dist/runtime/subagents/definition.d.ts +32 -14
  169. package/dist/runtime/subagents/definition.js +106 -109
  170. package/dist/runtime/subagents/dispatch.js +4 -3
  171. package/dist/runtime/subagents/preamble.d.ts +2 -1
  172. package/dist/runtime/subagents/preamble.js +2 -1
  173. package/dist/runtime/waits.js +14 -4
  174. package/dist/sandbox/platform-tiers.d.ts +86 -0
  175. package/dist/sandbox/platform-tiers.js +97 -0
  176. package/dist/sandbox/sandbox.service.d.ts +121 -11
  177. package/dist/sandbox/sandbox.service.js +351 -30
  178. package/dist/sandbox/tiers.d.ts +98 -0
  179. package/dist/sandbox/tiers.js +104 -0
  180. package/dist/schedules/effective-settings.d.ts +16 -0
  181. package/dist/schedules/effective-settings.js +17 -0
  182. package/dist/schedules/errors.d.ts +8 -0
  183. package/dist/schedules/errors.js +8 -0
  184. package/dist/schedules/fire.d.ts +118 -0
  185. package/dist/schedules/fire.js +625 -0
  186. package/dist/schedules/grant-rules.d.ts +94 -0
  187. package/dist/schedules/grant-rules.js +126 -0
  188. package/dist/schedules/job-ids.d.ts +32 -0
  189. package/dist/schedules/job-ids.js +27 -0
  190. package/dist/schedules/pause.d.ts +9 -0
  191. package/dist/schedules/pause.js +33 -0
  192. package/dist/schedules/run-tracker.d.ts +81 -0
  193. package/dist/schedules/run-tracker.js +333 -0
  194. package/dist/schedules/scan.job.d.ts +64 -0
  195. package/dist/schedules/scan.job.js +303 -0
  196. package/dist/schedules/schedule-math.d.ts +31 -0
  197. package/dist/schedules/schedule-math.js +223 -0
  198. package/dist/schedules/scheduled-tasks.service.d.ts +351 -0
  199. package/dist/schedules/scheduled-tasks.service.js +1193 -0
  200. package/dist/schedules/schedules.controller.d.ts +68 -0
  201. package/dist/schedules/schedules.controller.js +242 -0
  202. package/dist/schedules/schedules.module.d.ts +16 -0
  203. package/dist/schedules/schedules.module.js +65 -0
  204. package/dist/schedules/schedules.schemas.d.ts +316 -0
  205. package/dist/schedules/schedules.schemas.js +186 -0
  206. package/dist/schedules/store.d.ts +81 -0
  207. package/dist/schedules/store.js +390 -0
  208. package/dist/schedules/store.module.d.ts +6 -0
  209. package/dist/schedules/store.module.js +21 -0
  210. package/dist/schedules/types.d.ts +108 -0
  211. package/dist/schedules/types.js +1 -0
  212. package/dist/schedules/unattended.d.ts +117 -0
  213. package/dist/schedules/unattended.js +228 -0
  214. package/dist/setup/oauth-token-probe.js +8 -4
  215. package/dist/skills/agent-skills.service.d.ts +4 -8
  216. package/dist/skills/agent-skills.service.js +18 -53
  217. package/dist/skills/catalog.d.ts +65 -62
  218. package/dist/skills/catalog.js +109 -206
  219. package/dist/skills/conversation-catalog.d.ts +3 -3
  220. package/dist/skills/conversation-catalog.js +5 -12
  221. package/dist/skills/invoke.d.ts +2 -2
  222. package/dist/skills/skills.controller.d.ts +3 -1
  223. package/dist/skills/skills.service.d.ts +38 -37
  224. package/dist/skills/skills.service.js +168 -107
  225. package/dist/skills/usable.d.ts +57 -8
  226. package/dist/skills/usable.js +172 -38
  227. package/dist/tools/approvals.controller.d.ts +4 -2
  228. package/dist/tools/approvals.controller.js +17 -2
  229. package/dist/tools/approvals.schemas.d.ts +1 -0
  230. package/dist/tools/approvals.schemas.js +3 -1
  231. package/dist/tools/approvals.service.d.ts +182 -7
  232. package/dist/tools/approvals.service.js +597 -13
  233. package/dist/tools/builtin-names.d.ts +25 -4
  234. package/dist/tools/builtin-names.js +45 -4
  235. package/dist/tools/builtins/builtin-tools.d.ts +8 -0
  236. package/dist/tools/builtins/builtin-tools.js +23 -2
  237. package/dist/tools/builtins/sandbox/bash.js +16 -7
  238. package/dist/tools/builtins/sandbox/common.d.ts +1 -1
  239. package/dist/tools/builtins/sandbox/common.js +33 -10
  240. package/dist/tools/builtins/sandbox/edit.js +7 -3
  241. package/dist/tools/builtins/sandbox/file-state.d.ts +31 -1
  242. package/dist/tools/builtins/sandbox/file-state.js +33 -3
  243. package/dist/tools/builtins/sandbox/write.js +8 -4
  244. package/dist/tools/builtins/schedules.d.ts +107 -0
  245. package/dist/tools/builtins/schedules.js +789 -0
  246. package/dist/tools/elicitation.d.ts +2 -0
  247. package/dist/tools/elicitation.js +1 -1
  248. package/dist/tools/fixed-ask.d.ts +7 -0
  249. package/dist/tools/fixed-ask.js +9 -0
  250. package/dist/tools/network-asks.d.ts +109 -0
  251. package/dist/tools/network-asks.js +221 -0
  252. package/dist/tools/pipeline.d.ts +53 -6
  253. package/dist/tools/pipeline.js +202 -13
  254. package/dist/tools/policy.d.ts +21 -8
  255. package/dist/tools/policy.js +63 -9
  256. package/dist/tools/registry.d.ts +10 -1
  257. package/dist/tools/tools.module.js +12 -0
  258. package/package.json +2 -2
  259. package/src/migrations/0113_scheduled_tasks.sql +108 -0
  260. package/src/migrations/0114_network_asks.sql +23 -0
  261. package/src/migrations/0115_network_requests.sql +28 -0
  262. package/src/migrations/0116_skills_default_on.sql +37 -0
  263. package/src/migrations/0117_sandbox_tiers.sql +11 -0
@@ -0,0 +1,1193 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { randomUUID } from "node:crypto";
11
+ import { CHANNEL_EXTENSION_POINT } from "@capybara-hq/plugin-sdk";
12
+ import { getQueueToken } from "@nestjs/bullmq";
13
+ import { Inject, Injectable } from "@nestjs/common";
14
+ import { AgentLookup } from "../agents/agent-lookup.js";
15
+ import { ConversationAccess } from "../conversations/access.js";
16
+ import { readMessageContext } from "../conversations/message-context.js";
17
+ import { ApiError } from "../errors.js";
18
+ import { ExtensionRegistry } from "../extensions/registry.js";
19
+ import { audit } from "../identity/audit.js";
20
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
21
+ import { afterCommit } from "../infra/database/after-commit.js";
22
+ import { KernelDb } from "../infra/database/kernel-db.js";
23
+ import { TransactionHost } from "../infra/database/transaction-host.js";
24
+ import { Transactional } from "../infra/database/transactional.decorator.js";
25
+ import { KERNEL_LOGGER } from "../infra/tokens.js";
26
+ import { DevicePolicyService } from "../local-devices/device-policy.service.js";
27
+ import { orgAllows } from "../local-devices/network-grants.js";
28
+ import { OrgLock } from "../orgs/org-lock.js";
29
+ import { can, roleAtLeast } from "../orgs/roles.js";
30
+ import { MAINTENANCE_QUEUE_NAME } from "../runtime/maintenance-queue.js";
31
+ import { ProviderRegistry } from "../runtime/providers.js";
32
+ import { SandboxService } from "../sandbox/sandbox.service.js";
33
+ import { isTimeZone } from "../time-zone.js";
34
+ import { ToolRegistry } from "../tools/registry.js";
35
+ import { readScheduleSettings } from "./effective-settings.js";
36
+ import { grantNotAllowed, scheduleDisabled, scheduleForbidden, scheduleNotFound, targetInvalid, } from "./errors.js";
37
+ import { grantRefusal, grantRefusalOf, isNetworkGrantKey, isPolicyGrantKey, isTierGrantKey, NETWORK_GRANT_PREFIX, NETWORK_GRANT_SPEC_HASH, networkGrantHost, networkGrantRefusal, TIER_GRANT_SPEC_HASH, tierGrantRefusal, } from "./grant-rules.js";
38
+ import { schedFireJob } from "./job-ids.js";
39
+ import { pauseTaskIn } from "./pause.js";
40
+ import { jitterForGap, minGapMinutes, nextTick, validateTrigger } from "./schedule-math.js";
41
+ import { ScheduledTaskStore } from "./store.js";
42
+ export { grantRefusal, grantRefusalOf, networkGrantRefusal, };
43
+ /** The refusal of a network grant written outside a person's web session. */
44
+ const NETWORK_GRANT_NEEDS_PERSON = "Network domains are approved on the task page by a person.";
45
+ /** The refusal of a sandbox tier grant written outside a person's web session. */
46
+ const TIER_GRANT_NEEDS_PERSON = "A sandbox level is approved on the task page by a person.";
47
+ const RUNS_DEFAULT = 50;
48
+ const RUNS_MAX = 200;
49
+ const DAY_MS = 86_400_000;
50
+ /** Longest task name and prompt, in characters. */
51
+ const NAME_MAX = 200;
52
+ const PROMPT_MAX = 20_000;
53
+ /** A DM thread's key on `channel_threads` (`channels/threads.ts` `threadKeyOf`). */
54
+ const DM_THREAD_PREFIX = "dm:";
55
+ /**
56
+ * Scheduled tasks for people (spec §5, §7–§10): create, edit, pause, resume, delete, their runs
57
+ * and grants. Every write runs in one transaction under the org row lock (`OrgLock`), which also
58
+ * serializes the per-member and per-org caps, re-reads the actor's role there, and holds the task
59
+ * row (`FOR UPDATE`) so the scanner skips it until the write commits.
60
+ *
61
+ * Roles: `schedules.use` (member) creates and edits one's own tasks; `schedules.manage` (admin)
62
+ * lists, pauses, resumes and deletes any task of the org, never edits another person's (the task
63
+ * runs as its creator). A task the caller may not see is `schedule.not_found`.
64
+ */
65
+ let ScheduledTasksService = class ScheduledTasksService {
66
+ db;
67
+ txHost;
68
+ config;
69
+ store;
70
+ orgLock;
71
+ agents;
72
+ access;
73
+ tools;
74
+ extensions;
75
+ providers;
76
+ log;
77
+ maintenance;
78
+ sandbox;
79
+ devicePolicy;
80
+ constructor(db, txHost, config, store, orgLock, agents, access, tools, extensions,
81
+ /** A task's model must resolve for the org and declare its effort (as a message's switch). */
82
+ providers, log,
83
+ /** "Run now" enqueues the fire job (consumed by the worker role). */
84
+ maintenance,
85
+ /** Network grants: the org's effective egress (network ask-to-allow spec §8.1). */
86
+ sandbox,
87
+ /** Network grants: the task's device's local policy summary (`remoteGrants`). */
88
+ devicePolicy) {
89
+ this.db = db;
90
+ this.txHost = txHost;
91
+ this.config = config;
92
+ this.store = store;
93
+ this.orgLock = orgLock;
94
+ this.agents = agents;
95
+ this.access = access;
96
+ this.tools = tools;
97
+ this.extensions = extensions;
98
+ this.providers = providers;
99
+ this.log = log;
100
+ this.maintenance = maintenance;
101
+ this.sandbox = sandbox;
102
+ this.devicePolicy = devicePolicy;
103
+ }
104
+ async create(orgId, accountId, input, browserTimeZone, source = {}) {
105
+ const { role } = await this.orgLock.lockAndAuthorize(orgId, accountId, "schedules.use");
106
+ const t = this.db.db;
107
+ const settings = await readScheduleSettings(t, this.config, orgId);
108
+ if (!settings.enabled)
109
+ throw scheduleDisabled();
110
+ if ((await this.store.countActive(t, orgId, accountId)) >= settings.maxTasksPerMember) {
111
+ throw new ApiError("schedule.limit_reached", `You already have ${settings.maxTasksPerMember} scheduled tasks in this organization; delete one to create another`);
112
+ }
113
+ if ((await this.store.countActive(t, orgId)) >= settings.maxTasksPerOrg) {
114
+ throw new ApiError("schedule.limit_reached", `This organization already has ${settings.maxTasksPerOrg} scheduled tasks; delete one to create another`);
115
+ }
116
+ const name = requiredText(input.name, "name", NAME_MAX);
117
+ const prompt = requiredText(input.prompt, "prompt", PROMPT_MAX);
118
+ const agent = await this.agents.get(orgId, input.agentId, accountId);
119
+ const timeZone = input.timeZone ??
120
+ (await readMessageContext(t, {
121
+ orgId,
122
+ accountId,
123
+ ...(browserTimeZone !== undefined && { browser: { timeZone: browserTimeZone } }),
124
+ })).timeZone ??
125
+ "UTC";
126
+ const now = new Date();
127
+ const trigger = withZone(input.trigger, timeZone);
128
+ const gap = checkTrigger(trigger, now, settings);
129
+ const target = {
130
+ mode: input.mode,
131
+ conversationId: input.conversationId ?? null,
132
+ chat: input.chat ?? null,
133
+ };
134
+ await this.checkTarget(orgId, accountId, agent.id, target);
135
+ const pick = {
136
+ model: input.model ?? null,
137
+ effort: input.effort ?? null,
138
+ deviceId: input.deviceId ?? null,
139
+ };
140
+ await this.checkPick(orgId, accountId, agent, target.mode, pick);
141
+ const expiresAt = expiryOf(input.expiresAt ?? null, trigger, now, settings);
142
+ const grants = await this.resolveGrants(orgId, role, agent, settings, input.grants ?? [], await this.taskDeviceOf(orgId, { ...target, deviceId: pick.deviceId }), source);
143
+ const id = randomUUID();
144
+ const jitter = jitterForGap(id, gap);
145
+ const task = await this.store.insertTask(t, {
146
+ id,
147
+ orgId,
148
+ accountId,
149
+ agentId: agent.id,
150
+ name,
151
+ prompt,
152
+ trigger,
153
+ ...target,
154
+ ...pick,
155
+ status: "active",
156
+ nextRunAt: fireAt(trigger, now, jitter),
157
+ jitterSeconds: jitter,
158
+ expiresAt,
159
+ });
160
+ for (const g of grants)
161
+ await this.store.addGrant(t, id, g);
162
+ await audit(t, {
163
+ actorAccountId: accountId,
164
+ orgId,
165
+ action: "schedule.created",
166
+ target: id,
167
+ detail: {
168
+ name,
169
+ agentId: agent.id,
170
+ mode: target.mode,
171
+ trigger,
172
+ grants: grants.map((g) => g.toolKey),
173
+ },
174
+ });
175
+ return task;
176
+ }
177
+ /** Creator only: an org admin gets `schedule.forbidden`, anyone else `schedule.not_found`. */
178
+ async update(orgId, accountId, id, patch) {
179
+ const { role } = await this.orgLock.lockAndAuthorize(orgId, accountId, "schedules.use");
180
+ const t = this.db.db;
181
+ const task = await this.lockOwn(orgId, accountId, role, id);
182
+ const settings = await readScheduleSettings(t, this.config, orgId);
183
+ const now = new Date();
184
+ const changes = {};
185
+ if (patch.name !== undefined)
186
+ changes.name = requiredText(patch.name, "name", NAME_MAX);
187
+ if (patch.prompt !== undefined) {
188
+ changes.prompt = requiredText(patch.prompt, "prompt", PROMPT_MAX);
189
+ }
190
+ const agentId = patch.agentId ?? task.agentId;
191
+ let newAgent;
192
+ if (patch.agentId !== undefined && patch.agentId !== task.agentId) {
193
+ const agent = await this.agents.get(orgId, patch.agentId, accountId);
194
+ newAgent = agent;
195
+ changes.agentId = agent.id;
196
+ // the grants were approved for the old agent's tools: each must hold for the new agent
197
+ // too (refused, so the creator sees which), and takes the new agent's definition; network
198
+ // and sandbox tier grants belong to no tool and stay as they are
199
+ const held = (await this.store.listGrants(t, task.id)).filter((g) => !isPolicyGrantKey(g.toolKey));
200
+ const regranted = await this.resolveGrants(orgId, role, agent, settings, held.map((g) => ({
201
+ toolKey: g.toolKey,
202
+ ...(g.connectorId !== null && { connectorId: g.connectorId }),
203
+ })), null);
204
+ for (const g of regranted)
205
+ await this.store.addGrant(t, task.id, g);
206
+ }
207
+ const target = {
208
+ mode: patch.mode ?? task.mode,
209
+ conversationId: patch.conversationId !== undefined ? patch.conversationId : task.conversationId,
210
+ chat: patch.chat !== undefined ? patch.chat : task.chat,
211
+ };
212
+ const targetChanged = changes.agentId !== undefined ||
213
+ patch.mode !== undefined ||
214
+ patch.conversationId !== undefined ||
215
+ patch.chat !== undefined;
216
+ if (targetChanged) {
217
+ await this.checkTarget(orgId, accountId, agentId, target);
218
+ Object.assign(changes, target);
219
+ }
220
+ if (patch.trigger !== undefined || patch.timeZone !== undefined) {
221
+ const zone = patch.timeZone ?? task.trigger.timeZone;
222
+ const base = patch.trigger ??
223
+ (task.trigger.kind === "cron"
224
+ ? { kind: "cron", expr: task.trigger.expr }
225
+ : { kind: "once", at: task.trigger.at });
226
+ const trigger = withZone(base, zone);
227
+ const gap = checkTrigger(trigger, now, settings);
228
+ changes.trigger = trigger;
229
+ changes.jitterSeconds = jitterForGap(task.id, gap);
230
+ if (task.status === "active") {
231
+ changes.nextRunAt = fireAt(trigger, now, changes.jitterSeconds);
232
+ }
233
+ }
234
+ // model, effort and device: `new` mode only (leaving it clears them), checked when they or
235
+ // the agent change
236
+ const pick = target.mode === "new"
237
+ ? {
238
+ model: patch.model !== undefined ? patch.model : task.model,
239
+ effort: patch.effort !== undefined ? patch.effort : task.effort,
240
+ deviceId: patch.deviceId !== undefined ? patch.deviceId : task.deviceId,
241
+ }
242
+ : {
243
+ model: patch.model ?? null,
244
+ effort: patch.effort ?? null,
245
+ deviceId: patch.deviceId ?? null,
246
+ };
247
+ const pickChanged = patch.model !== undefined ||
248
+ patch.effort !== undefined ||
249
+ patch.deviceId !== undefined ||
250
+ targetChanged;
251
+ if (pickChanged) {
252
+ await this.checkPick(orgId, accountId, newAgent ?? (await this.agents.get(orgId, agentId, accountId)), target.mode, pick, patch.deviceId !== undefined);
253
+ if (pick.model !== task.model)
254
+ changes.model = pick.model;
255
+ if (pick.effort !== task.effort)
256
+ changes.effort = pick.effort;
257
+ if (pick.deviceId !== task.deviceId)
258
+ changes.deviceId = pick.deviceId;
259
+ }
260
+ if (patch.expiresAt !== undefined) {
261
+ changes.expiresAt = expiryOf(patch.expiresAt, changes.trigger ?? task.trigger, now, settings);
262
+ }
263
+ const updated = (await this.store.updateTask(t, task.id, changes)) ?? task;
264
+ await audit(t, {
265
+ actorAccountId: accountId,
266
+ orgId,
267
+ action: "schedule.updated",
268
+ target: task.id,
269
+ detail: { fields: Object.keys(changes).sort() },
270
+ });
271
+ return updated;
272
+ }
273
+ /** The creator, or an org admin (`schedules.manage`); a task not active is returned as is. */
274
+ async pause(orgId, actor, id) {
275
+ const { task, byAdmin } = await this.lockManaged(orgId, actor.accountId, id, "org.read");
276
+ if (task.status !== "active")
277
+ return task;
278
+ const paused = await this.store.updateTask(this.db.db, task.id, {
279
+ status: "paused",
280
+ pauseReason: byAdmin ? "admin" : "creator",
281
+ pausedBy: actor.accountId,
282
+ nextRunAt: null,
283
+ });
284
+ await audit(this.db.db, {
285
+ actorAccountId: actor.accountId,
286
+ orgId,
287
+ action: "schedule.paused",
288
+ target: task.id,
289
+ detail: { reason: byAdmin ? "admin" : "creator", creator: task.accountId },
290
+ });
291
+ return paused ?? task;
292
+ }
293
+ /**
294
+ * The creator (`schedules.use`), or an org admin. The next run is computed from now (missed ticks
295
+ * are not caught up) and the failure count starts over. An ended task cannot be resumed.
296
+ */
297
+ async resume(orgId, actor, id) {
298
+ const { task, byAdmin } = await this.lockManaged(orgId, actor.accountId, id, "schedules.use");
299
+ if (task.status === "active")
300
+ return task;
301
+ if (task.status === "ended")
302
+ throw new ApiError("conflict", "This scheduled task has ended");
303
+ const settings = await readScheduleSettings(this.db.db, this.config, orgId);
304
+ if (!settings.enabled)
305
+ throw scheduleDisabled();
306
+ const now = new Date();
307
+ const next = fireAt(task.trigger, now, task.jitterSeconds);
308
+ if (next === null) {
309
+ throw new ApiError("schedule.once_in_past", "The task's time has passed; set a new time");
310
+ }
311
+ const resumed = await this.store.updateTask(this.db.db, task.id, {
312
+ status: "active",
313
+ pauseReason: null,
314
+ pausedBy: null,
315
+ consecutiveFailures: 0,
316
+ nextRunAt: next,
317
+ });
318
+ await audit(this.db.db, {
319
+ actorAccountId: actor.accountId,
320
+ orgId,
321
+ action: "schedule.resumed",
322
+ target: task.id,
323
+ detail: { byAdmin, creator: task.accountId },
324
+ });
325
+ return resumed ?? task;
326
+ }
327
+ /** The creator, or an org admin. */
328
+ async remove(orgId, actor, id) {
329
+ const { task, byAdmin } = await this.lockManaged(orgId, actor.accountId, id, "org.read");
330
+ await this.store.softDelete(this.db.db, task.id);
331
+ await audit(this.db.db, {
332
+ actorAccountId: actor.accountId,
333
+ orgId,
334
+ action: "schedule.deleted",
335
+ target: task.id,
336
+ detail: { byAdmin, creator: task.accountId, name: task.name },
337
+ });
338
+ }
339
+ /** The creator, or an org admin. */
340
+ async get(orgId, actor, id) {
341
+ const role = await this.liveRole(orgId, actor.accountId);
342
+ const task = await this.store.getTask(this.db.db, orgId, id);
343
+ if (!task || (task.accountId !== actor.accountId && !can(role, "schedules.manage"))) {
344
+ throw scheduleNotFound();
345
+ }
346
+ return task;
347
+ }
348
+ /** The actor's own tasks; `all` (org admins only, else `forbidden`): every task of the org. */
349
+ async list(orgId, actor, opts = {}) {
350
+ const role = await this.liveRole(orgId, actor.accountId);
351
+ let tasks;
352
+ if (opts.all === true) {
353
+ if (!can(role, "schedules.manage")) {
354
+ throw new ApiError("forbidden", "Only org admins can list every scheduled task");
355
+ }
356
+ tasks = await this.store.listForOrg(this.db.db, orgId);
357
+ }
358
+ else {
359
+ tasks = await this.store.listForAccount(this.db.db, orgId, actor.accountId);
360
+ }
361
+ if (opts.conversationId !== undefined) {
362
+ tasks = await this.postingInto(orgId, tasks, opts.conversationId);
363
+ }
364
+ return this.withLastRuns(tasks);
365
+ }
366
+ /**
367
+ * The tasks of `tasks` that post into the conversation: a `conversation` task naming it, or a
368
+ * `chat` task whose chat the conversation is a thread of (spec §10 side panel).
369
+ */
370
+ async postingInto(orgId, tasks, conversationId) {
371
+ const chats = tasks.some((t) => t.mode === "chat")
372
+ ? await this.db.db
373
+ .selectFrom("channel_threads")
374
+ .innerJoin("channel_connections", "channel_connections.id", "channel_threads.connection_id")
375
+ .select(["channel_threads.connection_id", "channel_threads.chat_id"])
376
+ .where("channel_threads.conversation_id", "=", conversationId)
377
+ .where("channel_connections.org_id", "=", orgId)
378
+ .execute()
379
+ : [];
380
+ return tasks.filter((t) => t.mode === "conversation"
381
+ ? t.conversationId === conversationId
382
+ : t.mode === "chat" &&
383
+ t.chat !== null &&
384
+ chats.some((c) => c.connection_id === t.chat?.connectionId && c.chat_id === t.chat.chatId));
385
+ }
386
+ /** Each task with its latest settled non-trial run, in one query. */
387
+ async withLastRuns(tasks) {
388
+ if (tasks.length === 0)
389
+ return [];
390
+ const rows = await this.db.db
391
+ .selectFrom("scheduled_task_runs")
392
+ .select(["task_id", "status", "skip_reason", "fired_at", "scheduled_for"])
393
+ .distinctOn("task_id")
394
+ .where("task_id", "in", tasks.map((t) => t.id))
395
+ .where("trial", "=", false)
396
+ .where("status", "in", ["completed", "failed", "skipped"])
397
+ .orderBy("task_id")
398
+ .orderBy("scheduled_for", "desc")
399
+ .orderBy("id", "desc")
400
+ .execute();
401
+ const last = new Map(rows.map((r) => [r.task_id, r]));
402
+ return tasks.map((t) => {
403
+ const r = last.get(t.id);
404
+ return {
405
+ ...t,
406
+ lastRun: r === undefined
407
+ ? null
408
+ : {
409
+ status: r.status,
410
+ skipReason: r.skip_reason,
411
+ at: new Date(r.fired_at ?? r.scheduled_for),
412
+ },
413
+ };
414
+ });
415
+ }
416
+ /**
417
+ * The chats the caller can point a `chat` task at (spec §8): per active connection of the org,
418
+ * their DM threads (archived included) and, on a channel with threads, the groups they have a
419
+ * thread in. Members up (`schedules.use`, the route's).
420
+ */
421
+ async chatTargets(orgId, accountId) {
422
+ await this.liveRole(orgId, accountId);
423
+ const rows = await this.db.db
424
+ .selectFrom("channel_threads")
425
+ .innerJoin("channel_connections", "channel_connections.id", "channel_threads.connection_id")
426
+ .select([
427
+ "channel_connections.id as connection_id",
428
+ "channel_connections.name",
429
+ "channel_connections.plugin",
430
+ "channel_connections.agent_id",
431
+ "channel_threads.chat_id",
432
+ "channel_threads.thread_key",
433
+ ])
434
+ .where("channel_connections.org_id", "=", orgId)
435
+ .where("channel_connections.status", "=", "active")
436
+ .where("channel_connections.deleted_at", "is", null)
437
+ .where("channel_threads.account_id", "=", accountId)
438
+ .orderBy("channel_connections.name")
439
+ .orderBy("channel_threads.chat_id")
440
+ .execute();
441
+ const out = new Map();
442
+ for (const r of rows) {
443
+ const chatType = r.thread_key.startsWith(DM_THREAD_PREFIX) ? "dm" : "group";
444
+ if (chatType === "group" && this.channelFeatures(r.plugin)?.threads !== true)
445
+ continue;
446
+ const key = `${r.connection_id}\u0000${r.chat_id}\u0000${chatType}`;
447
+ if (out.has(key))
448
+ continue;
449
+ out.set(key, {
450
+ connectionId: r.connection_id,
451
+ connectionName: r.name,
452
+ plugin: r.plugin,
453
+ agentId: r.agent_id,
454
+ chatId: r.chat_id,
455
+ chatType,
456
+ label: chatType === "dm" ? r.name : `${r.name} · ${r.chat_id}`,
457
+ });
458
+ }
459
+ return [...out.values()];
460
+ }
461
+ /**
462
+ * The agent's tools (connector tools included) as a task's grant picker offers them, each
463
+ * grantable or why not by {@link grantRefusalOf}, the rule {@link addGrants} applies.
464
+ * `agent.not_found` when the caller can't use the agent.
465
+ */
466
+ async grantableTools(orgId, accountId, agentId) {
467
+ const role = await this.liveRole(orgId, accountId);
468
+ const agent = await this.agents.get(orgId, agentId, accountId);
469
+ const settings = await readScheduleSettings(this.db.db, this.config, orgId);
470
+ const d = agent.definition;
471
+ const { tools } = await this.tools.forAgent(orgId, {
472
+ disabledTools: d.disabledTools ?? [],
473
+ subagentTools: d.subagentTools,
474
+ subagents: d.subagents,
475
+ });
476
+ const ctx = {
477
+ role,
478
+ forbiddenTools: settings.forbiddenTools,
479
+ };
480
+ return tools.map((tool) => {
481
+ const why = grantRefusalOf(tool.key, tool, ctx);
482
+ return {
483
+ key: tool.key,
484
+ title: tool.spec.title ?? null,
485
+ source: tool.source.kind,
486
+ connectorId: tool.source.kind === "connector" ? tool.source.connectorId : null,
487
+ grantable: why === null,
488
+ reason: why?.code ?? null,
489
+ };
490
+ });
491
+ }
492
+ /** The task's runs, latest first (default 50, at most 200); visible as {@link get}. */
493
+ async runs(orgId, actor, id, limit) {
494
+ const task = await this.get(orgId, actor, id);
495
+ const n = Math.min(Math.max(Math.trunc(limit ?? RUNS_DEFAULT), 1), RUNS_MAX);
496
+ return this.store.listRuns(this.db.db, task.id, n);
497
+ }
498
+ /** The task's grants; visible as {@link get}. */
499
+ async grants(orgId, actor, id) {
500
+ const task = await this.get(orgId, actor, id);
501
+ const grants = await this.store.listGrants(this.db.db, task.id);
502
+ if (grants.length === 0)
503
+ return [];
504
+ // the creator's view of the agent's tools: a grant whose tool changed or left is stale;
505
+ // unknown (null) when the tools cannot be read now, rather than failing the task page
506
+ const current = grants.every((g) => isPolicyGrantKey(g.toolKey))
507
+ ? new Map()
508
+ : await this.agentToolFacts(orgId, task.accountId, task.agentId);
509
+ return grants.map((g) => ({
510
+ ...g,
511
+ // a network or sandbox tier grant names no tool definition: never stale
512
+ stale: isPolicyGrantKey(g.toolKey)
513
+ ? false
514
+ : current === null
515
+ ? null
516
+ : current.get(g.toolKey)?.specHash !== g.specHash,
517
+ }));
518
+ }
519
+ /**
520
+ * Creator only; each grant is checked as at create (spec §7); network domains only from a web
521
+ * session (`source.fromSession`).
522
+ */
523
+ async addGrants(orgId, accountId, id, grants, source = {}) {
524
+ const { role } = await this.orgLock.lockAndAuthorize(orgId, accountId, "schedules.use");
525
+ const t = this.db.db;
526
+ const task = await this.lockOwn(orgId, accountId, role, id);
527
+ const settings = await readScheduleSettings(t, this.config, orgId);
528
+ const agent = await this.agents.get(orgId, task.agentId, accountId);
529
+ const resolved = await this.resolveGrants(orgId, role, agent, settings, grants, await this.taskDeviceOf(orgId, task), source);
530
+ for (const g of resolved)
531
+ await this.store.addGrant(t, task.id, g);
532
+ await audit(t, {
533
+ actorAccountId: accountId,
534
+ orgId,
535
+ action: "schedule.grants_changed",
536
+ target: task.id,
537
+ detail: { added: resolved.map((g) => g.toolKey) },
538
+ });
539
+ return this.store.listGrants(t, task.id);
540
+ }
541
+ /** Creator only; removing a grant the task does not have is a no-op. */
542
+ async removeGrant(orgId, accountId, id, toolKey) {
543
+ const { role } = await this.orgLock.lockAndAuthorize(orgId, accountId, "schedules.use");
544
+ const t = this.db.db;
545
+ const task = await this.lockOwn(orgId, accountId, role, id);
546
+ if (await this.store.removeGrant(t, task.id, toolKey)) {
547
+ await audit(t, {
548
+ actorAccountId: accountId,
549
+ orgId,
550
+ action: "schedule.grants_changed",
551
+ target: task.id,
552
+ detail: { removed: [toolKey] },
553
+ });
554
+ }
555
+ }
556
+ /**
557
+ * The creator's scheduled tasks that need attention (spec §8), derived live: a paused task whose
558
+ * reason is not `creator` (resuming clears it), an active task whose latest settled non-trial
559
+ * run failed (the next good run clears it), and a task whose latest settled non-trial run was
560
+ * refused tools, connections or domains (a tool granted since drops out, a domain too while the
561
+ * org's network allowlist admits it; the next clean run clears the rest). Newest first.
562
+ */
563
+ async attentionFor(orgId, accountId) {
564
+ const db = this.db.db;
565
+ const tasks = (await this.store.listForAccount(db, orgId, accountId)).filter((t) => t.status !== "ended");
566
+ if (tasks.length === 0)
567
+ return [];
568
+ const out = [];
569
+ const pausers = tasks.flatMap((t) => t.status === "paused" && t.pausedBy !== null ? [t.pausedBy] : []);
570
+ const names = new Map();
571
+ if (pausers.length > 0) {
572
+ const rows = await db
573
+ .selectFrom("accounts")
574
+ .select(["id", "display_name"])
575
+ .where("id", "in", pausers)
576
+ .execute();
577
+ for (const r of rows)
578
+ names.set(r.id, r.display_name);
579
+ }
580
+ const ids = tasks.map((t) => t.id);
581
+ // the latest settled non-trial run of each task, and every grant of these tasks, in one query each
582
+ const latest = await db
583
+ .selectFrom("scheduled_task_runs")
584
+ .select(["task_id", "fired_at", "scheduled_for", "missing_grants", "status", "error"])
585
+ .distinctOn("task_id")
586
+ .where("task_id", "in", ids)
587
+ .where("trial", "=", false)
588
+ .where("status", "<>", "skipped")
589
+ .orderBy("task_id")
590
+ .orderBy("scheduled_for", "desc")
591
+ .orderBy("id", "desc")
592
+ .execute();
593
+ const runs = new Map(latest.map((r) => [r.task_id, r]));
594
+ const grantRows = await db
595
+ .selectFrom("scheduled_task_grants")
596
+ .select(["task_id", "tool_key", "spec_hash"])
597
+ .where("task_id", "in", ids)
598
+ .execute();
599
+ /** Per task: each granted tool key with the definition hash it was granted for. */
600
+ const granted = new Map();
601
+ for (const g of grantRows) {
602
+ const byKey = granted.get(g.task_id) ?? new Map();
603
+ byKey.set(g.tool_key, g.spec_hash);
604
+ granted.set(g.task_id, byKey);
605
+ }
606
+ let loaded = false;
607
+ let role = null;
608
+ const agentTools = new Map();
609
+ // the network of the org's ceiling, read once and only when a refused domain needs it
610
+ let limitsOf;
611
+ const orgLimits = () => {
612
+ limitsOf ??= this.sandbox.ceilingLimits(orgId);
613
+ return limitsOf;
614
+ };
615
+ // the org's sandbox ceiling, read once and only when a refused sandbox level needs it
616
+ let ceilingOf;
617
+ const openReachable = () => {
618
+ ceilingOf ??= this.sandbox.devicePolicy({ orgId }).then((p) => p.ceiling === "open");
619
+ return ceilingOf;
620
+ };
621
+ const toolsOf = (agentId) => {
622
+ const lookup = agentTools.get(agentId) ?? this.agentToolFacts(orgId, accountId, agentId);
623
+ agentTools.set(agentId, lookup);
624
+ return lookup;
625
+ };
626
+ for (const task of tasks) {
627
+ if (task.status === "paused" && task.pauseReason !== null && task.pauseReason !== "creator") {
628
+ out.push({
629
+ taskId: task.id,
630
+ name: task.name,
631
+ reason: task.pauseReason,
632
+ pausedBy: task.pausedBy === null
633
+ ? null
634
+ : { id: task.pausedBy, name: names.get(task.pausedBy) ?? "" },
635
+ missing: [],
636
+ needsRole: [],
637
+ needsAdmin: [],
638
+ deviceRefuses: false,
639
+ error: null,
640
+ at: task.updatedAt,
641
+ });
642
+ }
643
+ const run = runs.get(task.id);
644
+ if (!run)
645
+ continue;
646
+ // an active task whose latest run failed (a paused one shows its pause instead)
647
+ if (run.status === "failed" && task.status === "active") {
648
+ out.push({
649
+ taskId: task.id,
650
+ name: task.name,
651
+ reason: "failed",
652
+ pausedBy: null,
653
+ missing: [],
654
+ needsRole: [],
655
+ needsAdmin: [],
656
+ deviceRefuses: false,
657
+ error: run.error,
658
+ at: run.fired_at ?? new Date(run.scheduled_for),
659
+ });
660
+ }
661
+ const have = granted.get(task.id);
662
+ const refused = run.missing_grants;
663
+ // a refused tool granted since drops out, unless its grant no longer matches the tool's
664
+ // definition (stale: it would be refused again); a refused domain granted since drops out
665
+ // while the org's network allowlist admits it (network ask-to-allow spec §8.3)
666
+ // a refused sandbox level granted since drops out while the org's ceiling is still open
667
+ const isTool = (k) => !k.startsWith("connection:") && !isPolicyGrantKey(k);
668
+ const facts = refused.some(isTool) ? await toolsOf(task.agentId) : null;
669
+ const limits = refused.some(isNetworkGrantKey) ? await orgLimits() : null;
670
+ const open = refused.some(isTierGrantKey) ? await openReachable() : false;
671
+ const admits = (k) => isTierGrantKey(k) ? open : limits !== null && orgAllows(networkGrantHost(k), limits);
672
+ const missing = refused.filter((k) => {
673
+ const hash = have?.get(k);
674
+ if (isPolicyGrantKey(k))
675
+ return hash === undefined || !admits(k);
676
+ if (hash === undefined)
677
+ return true;
678
+ const current = facts?.get(k);
679
+ return facts !== null && current?.specHash !== hash;
680
+ });
681
+ if (missing.length === 0)
682
+ continue;
683
+ const needsAdmin = missing.filter((k) => isPolicyGrantKey(k) && !admits(k));
684
+ let deviceRefuses = false;
685
+ if (missing.some(isNetworkGrantKey)) {
686
+ const deviceId = await this.taskDeviceOf(orgId, task).catch(() => null);
687
+ const summary = deviceId === null ? null : await this.devicePolicy.summary(deviceId).catch(() => null);
688
+ deviceRefuses = summary?.policy?.remoteGrants === "off";
689
+ }
690
+ let needsRole = [];
691
+ if (facts !== null && missing.some(isTool)) {
692
+ if (!loaded) {
693
+ role = await this.roleOrNull(orgId, accountId);
694
+ loaded = true;
695
+ }
696
+ needsRole = missing.filter((k) => {
697
+ if (!isTool(k))
698
+ return false;
699
+ const req = facts.get(k)?.requiredRole;
700
+ return req !== undefined && (role === null || !roleAtLeast(role, req));
701
+ });
702
+ }
703
+ out.push({
704
+ taskId: task.id,
705
+ name: task.name,
706
+ reason: "missing_grants",
707
+ pausedBy: null,
708
+ missing,
709
+ needsRole,
710
+ needsAdmin,
711
+ deviceRefuses,
712
+ error: null,
713
+ at: run.fired_at ?? new Date(run.scheduled_for),
714
+ });
715
+ }
716
+ return out.sort((a, b) => b.at.getTime() - a.at.getTime());
717
+ }
718
+ async roleOrNull(orgId, accountId) {
719
+ const m = await this.db.db
720
+ .selectFrom("memberships")
721
+ .select("role")
722
+ .where("org_id", "=", orgId)
723
+ .where("account_id", "=", accountId)
724
+ .executeTakeFirst();
725
+ return m?.role ?? null;
726
+ }
727
+ /**
728
+ * The agent's tools by key (their `requiredRole` and definition hash) as the creator sees them;
729
+ * empty when the agent is gone or hidden (the task is paused for that anyway); null when they
730
+ * cannot be read now (logged: the caller then cannot tell what is stale or needs a role).
731
+ */
732
+ async agentToolFacts(orgId, accountId, agentId) {
733
+ try {
734
+ const d = (await this.agents.get(orgId, agentId, accountId)).definition;
735
+ const { tools } = await this.tools.forAgent(orgId, {
736
+ disabledTools: d.disabledTools ?? [],
737
+ subagentTools: d.subagentTools,
738
+ subagents: d.subagents,
739
+ });
740
+ return new Map(tools.map((x) => [
741
+ x.key,
742
+ {
743
+ specHash: x.specHash,
744
+ ...(x.requiredRole !== undefined && { requiredRole: x.requiredRole }),
745
+ },
746
+ ]));
747
+ }
748
+ catch (e) {
749
+ if (e instanceof ApiError && e.code === "agent.not_found")
750
+ return new Map();
751
+ this.log.warn("scheduled task: the agent's tools could not be read", {
752
+ orgId,
753
+ agentId,
754
+ error: e instanceof Error ? e.message : String(e),
755
+ });
756
+ return null;
757
+ }
758
+ }
759
+ /**
760
+ * Pauses an active live task for `reason` in the caller's transaction `t` (the fire job, the
761
+ * delivery path); `by` is the account that caused it, null for the kernel. A task already
762
+ * paused or ended keeps its state and reason.
763
+ */
764
+ async pauseFor(t, taskId, reason, by) {
765
+ await pauseTaskIn(t, taskId, reason, by);
766
+ }
767
+ /**
768
+ * "Run now" (spec §6), creator only: a task run for the request time, outside the schedule (no
769
+ * unique-key conflict with a tick, and `next_run_at` does not move), fired at once through the
770
+ * fire job after the commit. `trial`: an attended trial run whose approvals become the task's
771
+ * grants (§7). Counted in the rate limit like any run. An ended task cannot run; a paused one
772
+ * can (only its creator gets here: the run is `manual`, which the fire's paused check lets by).
773
+ */
774
+ async runNow(orgId, accountId, id, opts) {
775
+ const { role } = await this.orgLock.lockAndAuthorize(orgId, accountId, "schedules.use");
776
+ const t = this.db.db;
777
+ const task = await this.lockOwn(orgId, accountId, role, id);
778
+ if (task.status === "ended")
779
+ throw new ApiError("conflict", "This scheduled task has ended");
780
+ const settings = await readScheduleSettings(t, this.config, orgId);
781
+ if (!settings.enabled)
782
+ throw scheduleDisabled();
783
+ const run = await this.store.insertRun(t, {
784
+ id: randomUUID(),
785
+ taskId: task.id,
786
+ orgId,
787
+ scheduledFor: new Date(),
788
+ trial: opts.trial,
789
+ manual: true,
790
+ });
791
+ if (!run)
792
+ throw new ApiError("conflict", "The task is being run; retry");
793
+ await audit(t, {
794
+ actorAccountId: accountId,
795
+ orgId,
796
+ action: "schedule.run_now",
797
+ target: task.id,
798
+ detail: { taskRunId: run.id, trial: opts.trial },
799
+ });
800
+ const job = schedFireJob(run.id);
801
+ afterCommit(async () => {
802
+ await this.maintenance.add(job.name, job.data, job.opts);
803
+ });
804
+ return run;
805
+ }
806
+ /** The live role of a member of the org; not a member → `schedule.not_found`. */
807
+ async liveRole(orgId, accountId) {
808
+ const m = await this.db
809
+ .selectFrom("memberships")
810
+ .innerJoin("orgs", "orgs.id", "memberships.org_id")
811
+ .select("memberships.role")
812
+ .where("memberships.org_id", "=", orgId)
813
+ .where("memberships.account_id", "=", accountId)
814
+ .where("orgs.deleted_at", "is", null)
815
+ .executeTakeFirst();
816
+ if (!m)
817
+ throw scheduleNotFound();
818
+ return m.role;
819
+ }
820
+ /** The actor's own task, row-locked; another's: `schedule.forbidden` for an admin, else 404. */
821
+ async lockOwn(orgId, accountId, role, id) {
822
+ const task = await this.store.lockTask(this.db.db, orgId, id);
823
+ if (!task)
824
+ throw scheduleNotFound();
825
+ if (task.accountId !== accountId) {
826
+ throw can(role, "schedules.manage") ? scheduleForbidden() : scheduleNotFound();
827
+ }
828
+ return task;
829
+ }
830
+ /**
831
+ * A task the actor may pause, resume or delete, row-locked under the org lock: their own (with
832
+ * `creatorAction`), or any as an org admin (`schedules.manage`); otherwise `schedule.not_found`.
833
+ */
834
+ async lockManaged(orgId, accountId, id, creatorAction) {
835
+ const { role } = await this.orgLock.lockAndAuthorize(orgId, accountId, "org.read");
836
+ const task = await this.store.lockTask(this.db.db, orgId, id);
837
+ if (!task)
838
+ throw scheduleNotFound();
839
+ if (task.accountId === accountId) {
840
+ if (!can(role, creatorAction)) {
841
+ throw new ApiError("forbidden", "Insufficient organization role");
842
+ }
843
+ return { task, byAdmin: false };
844
+ }
845
+ if (!can(role, "schedules.manage"))
846
+ throw scheduleNotFound();
847
+ return { task, byAdmin: true };
848
+ }
849
+ /**
850
+ * Where runs go (spec §3, §8), checked against the creator:
851
+ * - the mode and its fields agree (`conversation`: a conversation and no chat; `chat`: a chat
852
+ * and no conversation; `new`: neither);
853
+ * - `conversation`: the creator's live chat conversation with this agent, still accessible;
854
+ * - `chat`: the connection is active and bound to this agent; a DM needs a thread row (archived
855
+ * allowed) of the creator in that chat; a group needs the creator's thread row in the group
856
+ * and a channel with threads.
857
+ */
858
+ async checkTarget(orgId, accountId, agentId, target) {
859
+ const { mode, conversationId, chat } = target;
860
+ if (mode === "new" && (conversationId !== null || chat !== null)) {
861
+ throw targetInvalid("A task that starts new conversations takes no conversation or chat");
862
+ }
863
+ if (mode === "conversation" && (conversationId === null || chat !== null)) {
864
+ throw targetInvalid("A conversation task needs a conversation id and no chat");
865
+ }
866
+ if (mode === "chat" && (chat === null || conversationId !== null)) {
867
+ throw targetInvalid("A chat task needs a chat and no conversation id");
868
+ }
869
+ const t = this.db.db;
870
+ if (mode === "conversation" && conversationId !== null) {
871
+ const conv = await t
872
+ .selectFrom("conversations")
873
+ .select("id")
874
+ .where("id", "=", conversationId)
875
+ .where("org_id", "=", orgId)
876
+ .where("account_id", "=", accountId)
877
+ .where("agent_id", "=", agentId)
878
+ .where("kind", "=", "chat")
879
+ .where("deleted_at", "is", null)
880
+ .executeTakeFirst();
881
+ if (!conv || !(await this.access.isAccessible(orgId, conversationId, accountId))) {
882
+ throw targetInvalid("The conversation is not one of yours with this agent");
883
+ }
884
+ }
885
+ if (mode === "chat" && chat !== null) {
886
+ const conn = await t
887
+ .selectFrom("channel_connections")
888
+ .select(["plugin", "agent_id", "status"])
889
+ .where("id", "=", chat.connectionId)
890
+ .where("org_id", "=", orgId)
891
+ .where("deleted_at", "is", null)
892
+ .executeTakeFirst();
893
+ if (conn?.status !== "active") {
894
+ throw targetInvalid("The channel connection does not exist or is not active");
895
+ }
896
+ if (conn.agent_id !== agentId) {
897
+ throw targetInvalid("The chat belongs to another agent: use the connection's agent");
898
+ }
899
+ const dm = chat.chatType === "dm";
900
+ const thread = await t
901
+ .selectFrom("channel_threads")
902
+ .select("id")
903
+ .where("connection_id", "=", chat.connectionId)
904
+ .where("chat_id", "=", chat.chatId)
905
+ .where("account_id", "=", accountId)
906
+ .where("thread_key", dm ? "like" : "not like", `${DM_THREAD_PREFIX}%`)
907
+ .limit(1)
908
+ .executeTakeFirst();
909
+ if (!thread) {
910
+ throw targetInvalid(dm
911
+ ? "The chat is not your direct chat with this bot; message the bot once first"
912
+ : "You have not talked to the bot in this group");
913
+ }
914
+ if (!dm && this.channelFeatures(conn.plugin)?.threads !== true) {
915
+ throw targetInvalid("This channel has no threads, so a group cannot be a task's target");
916
+ }
917
+ }
918
+ }
919
+ /** The channel's declared features; undefined when its extension is not loaded here. */
920
+ channelFeatures(plugin) {
921
+ const entry = this.extensions
922
+ .list("channel")
923
+ .find((e) => e.plugin === plugin && e.capability === CHANNEL_EXTENSION_POINT);
924
+ return entry?.impl?.features;
925
+ }
926
+ /**
927
+ * A task's model, effort and device (spec §4): `new` mode only, since a conversation or chat
928
+ * task runs with that conversation's selection and device (a task must not switch a person's
929
+ * conversation for their later turns), so another mode refuses them (`validation_failed`).
930
+ * The model must resolve for the org (`agent.model_unavailable`, as a message's switch) and
931
+ * declare the effort (`validation_failed`); the device must be one of the creator's
932
+ * (`checkDevice`, when `deviceChanged`).
933
+ */
934
+ async checkPick(orgId, accountId, agent, mode, pick, deviceChanged = true) {
935
+ if (mode !== "new") {
936
+ if (pick.model !== null || pick.effort !== null || pick.deviceId !== null) {
937
+ throw new ApiError("validation_failed", "Only a task that starts new conversations takes a model, effort or device; a conversation or chat task runs with that conversation's");
938
+ }
939
+ return;
940
+ }
941
+ if (pick.model !== null || pick.effort !== null) {
942
+ const { info } = await this.providers.resolve(orgId, pick.model ?? agent.definition.model);
943
+ if (pick.effort !== null) {
944
+ const knob = info.parameters.find((p) => p.key === "effort");
945
+ const values = knob?.schema?.enum ?? [];
946
+ if (!values.includes(pick.effort)) {
947
+ throw new ApiError("validation_failed", "Request validation failed", [
948
+ { path: "effort", message: `The model does not offer effort "${pick.effort}"` },
949
+ ]);
950
+ }
951
+ }
952
+ }
953
+ if (pick.deviceId !== null && deviceChanged) {
954
+ await this.checkDevice(orgId, accountId, pick.deviceId);
955
+ }
956
+ }
957
+ /** The device is one of the creator's live personal devices in the org. */
958
+ async checkDevice(orgId, accountId, deviceId) {
959
+ const row = await this.db
960
+ .selectFrom("devices")
961
+ .select("id")
962
+ .where("id", "=", deviceId)
963
+ .where("org_id", "=", orgId)
964
+ .where("owner_kind", "=", "personal")
965
+ .where("owner_account_id", "=", accountId)
966
+ .where("deleted_at", "is", null)
967
+ .executeTakeFirst();
968
+ if (!row)
969
+ throw targetInvalid("The device is not one of yours");
970
+ }
971
+ /**
972
+ * The grants as stored: each tool resolved among the agent's tools now (its `spec_hash` and
973
+ * source connector), refused by {@link grantRefusal}; each `network:<host>` key (lower-cased)
974
+ * refused by {@link networkGrantRefusal} against the org's effective egress and the summary of
975
+ * `deviceId`, the task's device (null: none can be determined, the device rule is skipped),
976
+ * stored with `spec_hash` `network` and no connector (plan P-D7), and only when
977
+ * `source.fromSession` (a person on the task page; never a token, the Platform tool or the
978
+ * schedule tools). Duplicates keep the last.
979
+ */
980
+ async resolveGrants(orgId, role, agent, settings, grants, deviceId, source = {}) {
981
+ if (grants.length === 0)
982
+ return [];
983
+ const out = new Map();
984
+ const network = grants.filter((g) => isNetworkGrantKey(g.toolKey));
985
+ if (network.length > 0) {
986
+ if (source.fromSession !== true)
987
+ throw grantNotAllowed(NETWORK_GRANT_NEEDS_PERSON);
988
+ const ctx = await this.networkGrantContext(orgId, deviceId);
989
+ for (const g of network) {
990
+ if (g.connectorId !== undefined) {
991
+ throw grantNotAllowed(`${g.toolKey} is a domain and comes from no connector`);
992
+ }
993
+ const host = networkGrantHost(g.toolKey).trim().toLowerCase().replace(/\.$/, "");
994
+ const why = networkGrantRefusal(host, ctx);
995
+ if (why !== null)
996
+ throw grantNotAllowed(why.message);
997
+ const toolKey = `${NETWORK_GRANT_PREFIX}${host}`;
998
+ out.set(toolKey, { toolKey, specHash: NETWORK_GRANT_SPEC_HASH, connectorId: null });
999
+ }
1000
+ }
1001
+ const tiers = grants.filter((g) => isTierGrantKey(g.toolKey));
1002
+ if (tiers.length > 0) {
1003
+ if (source.fromSession !== true)
1004
+ throw grantNotAllowed(TIER_GRANT_NEEDS_PERSON);
1005
+ const [{ ceiling }, device] = await Promise.all([
1006
+ this.sandbox.devicePolicy({ orgId }),
1007
+ deviceId === null ? null : this.devicePolicy.summary(deviceId),
1008
+ ]);
1009
+ // a connected local machine whose program applies no tiers runs every command in strict
1010
+ const deviceReachesOpen = device === null || device.features.includes("tiers");
1011
+ for (const g of tiers) {
1012
+ if (g.connectorId !== undefined) {
1013
+ throw grantNotAllowed(`${g.toolKey} is a sandbox level and comes from no connector`);
1014
+ }
1015
+ const why = tierGrantRefusal(g.toolKey, { ceiling, deviceReachesOpen });
1016
+ if (why !== null)
1017
+ throw grantNotAllowed(why.message);
1018
+ out.set(g.toolKey, {
1019
+ toolKey: g.toolKey,
1020
+ specHash: TIER_GRANT_SPEC_HASH,
1021
+ connectorId: null,
1022
+ });
1023
+ }
1024
+ }
1025
+ const toolGrants = grants.filter((g) => !isPolicyGrantKey(g.toolKey));
1026
+ if (toolGrants.length === 0)
1027
+ return [...out.values()];
1028
+ const d = agent.definition;
1029
+ const { tools } = await this.tools.forAgent(orgId, {
1030
+ disabledTools: d.disabledTools ?? [],
1031
+ subagentTools: d.subagentTools,
1032
+ subagents: d.subagents,
1033
+ });
1034
+ for (const g of toolGrants) {
1035
+ const tool = tools.find((x) => x.key === g.toolKey);
1036
+ const why = grantRefusal(g.toolKey, tool, {
1037
+ role,
1038
+ forbiddenTools: settings.forbiddenTools,
1039
+ });
1040
+ if (why !== null || !tool)
1041
+ throw grantNotAllowed(why ?? `${g.toolKey} cannot be approved`);
1042
+ const connectorId = tool.source.kind === "connector" ? tool.source.connectorId : null;
1043
+ if (g.connectorId !== undefined && g.connectorId !== connectorId) {
1044
+ throw grantNotAllowed(`${g.toolKey} does not come from connector ${g.connectorId}`);
1045
+ }
1046
+ out.set(g.toolKey, { toolKey: g.toolKey, specHash: tool.specHash, connectorId });
1047
+ }
1048
+ return [...out.values()];
1049
+ }
1050
+ /**
1051
+ * What a network grant is checked against (network ask-to-allow spec §8.1): the network of the
1052
+ * org's sandbox ceiling now (sandbox dev tiers spec §4.2) and the local policy summary of the
1053
+ * task's device (null without a device or a connected machine).
1054
+ */
1055
+ async networkGrantContext(orgId, deviceId) {
1056
+ const limits = await this.sandbox.ceilingLimits(orgId);
1057
+ const summary = deviceId === null ? null : await this.devicePolicy.summary(deviceId);
1058
+ return { limits, deviceSummary: summary?.policy ?? null };
1059
+ }
1060
+ /**
1061
+ * The device a task's runs use, as far as the task tells: a `new` task's own device, a
1062
+ * `conversation` task's conversation's device (in the org); null when none is chosen, and for a
1063
+ * chat task: its IM conversation's device is not known until it runs, so the device rule of a
1064
+ * network grant is skipped there (the device still refuses grants at the command when its owner
1065
+ * turned them off, and the run notes it).
1066
+ */
1067
+ async taskDeviceOf(orgId, task) {
1068
+ if (task.mode === "new")
1069
+ return task.deviceId;
1070
+ if (task.mode !== "conversation" || task.conversationId === null)
1071
+ return null;
1072
+ const c = await this.db.db
1073
+ .selectFrom("conversations")
1074
+ .select("device_id")
1075
+ .where("id", "=", task.conversationId)
1076
+ .where("org_id", "=", orgId)
1077
+ .executeTakeFirst();
1078
+ return c?.device_id ?? null;
1079
+ }
1080
+ };
1081
+ __decorate([
1082
+ Transactional()
1083
+ ], ScheduledTasksService.prototype, "create", null);
1084
+ __decorate([
1085
+ Transactional()
1086
+ ], ScheduledTasksService.prototype, "update", null);
1087
+ __decorate([
1088
+ Transactional()
1089
+ ], ScheduledTasksService.prototype, "pause", null);
1090
+ __decorate([
1091
+ Transactional()
1092
+ ], ScheduledTasksService.prototype, "resume", null);
1093
+ __decorate([
1094
+ Transactional()
1095
+ ], ScheduledTasksService.prototype, "remove", null);
1096
+ __decorate([
1097
+ Transactional()
1098
+ ], ScheduledTasksService.prototype, "addGrants", null);
1099
+ __decorate([
1100
+ Transactional()
1101
+ ], ScheduledTasksService.prototype, "removeGrant", null);
1102
+ __decorate([
1103
+ Transactional()
1104
+ ], ScheduledTasksService.prototype, "runNow", null);
1105
+ ScheduledTasksService = __decorate([
1106
+ Injectable(),
1107
+ __param(0, Inject(KernelDb)),
1108
+ __param(1, Inject(TransactionHost)),
1109
+ __param(2, Inject(KernelConfigService)),
1110
+ __param(3, Inject(ScheduledTaskStore)),
1111
+ __param(4, Inject(OrgLock)),
1112
+ __param(5, Inject(AgentLookup)),
1113
+ __param(6, Inject(ConversationAccess)),
1114
+ __param(7, Inject(ToolRegistry)),
1115
+ __param(8, Inject(ExtensionRegistry)),
1116
+ __param(9, Inject(ProviderRegistry)),
1117
+ __param(10, Inject(KERNEL_LOGGER)),
1118
+ __param(11, Inject(getQueueToken(MAINTENANCE_QUEUE_NAME))),
1119
+ __param(12, Inject(SandboxService)),
1120
+ __param(13, Inject(DevicePolicyService))
1121
+ ], ScheduledTasksService);
1122
+ export { ScheduledTasksService };
1123
+ /** A required text field, trimmed; empty or longer than `max` → `validation_failed`. */
1124
+ function requiredText(v, field, max) {
1125
+ const s = v.trim();
1126
+ if (s === "")
1127
+ throw new ApiError("validation_failed", `The task's ${field} is empty`);
1128
+ if (s.length > max) {
1129
+ throw new ApiError("validation_failed", `The task's ${field} is longer than ${max} characters`);
1130
+ }
1131
+ return s;
1132
+ }
1133
+ function withZone(t, timeZone) {
1134
+ return t.kind === "cron"
1135
+ ? { kind: "cron", expr: t.expr.trim(), timeZone }
1136
+ : { kind: "once", at: t.at, timeZone };
1137
+ }
1138
+ /**
1139
+ * The trigger is valid (spec §5) and its fire times are at least the org's interval apart; returns
1140
+ * the sampled gap (null for once), from which the jitter is derived.
1141
+ */
1142
+ function checkTrigger(t, now, settings) {
1143
+ if (!isTimeZone(t.timeZone)) {
1144
+ throw new ApiError("schedule.invalid_time_zone", `${t.timeZone} is not an IANA time zone`);
1145
+ }
1146
+ const v = validateTrigger(t, now);
1147
+ if (!v.ok) {
1148
+ const message = {
1149
+ "schedule.invalid_cron": "The schedule is not a 5-field cron expression",
1150
+ "schedule.invalid_time_zone": `${t.timeZone} is not an IANA time zone`,
1151
+ "schedule.invalid_once_time": "The time is not a local date-time such as 2026-12-01T09:00",
1152
+ "schedule.once_in_past": "The time is not in the future",
1153
+ };
1154
+ throw new ApiError(v.code, message[v.code]);
1155
+ }
1156
+ if (t.kind === "cron") {
1157
+ if (nextTick(t, now) === null) {
1158
+ throw new ApiError("schedule.invalid_cron", "The schedule never fires");
1159
+ }
1160
+ const gap = minGapMinutes(t, now);
1161
+ if (gap !== null && gap < settings.minIntervalMinutes) {
1162
+ throw new ApiError("schedule.interval_too_short", `The schedule fires every ${gap} minutes; this organization allows at least ${settings.minIntervalMinutes}`);
1163
+ }
1164
+ return gap;
1165
+ }
1166
+ return null;
1167
+ }
1168
+ /** The next fire time after `now`, jitter included; null when there is none (a past once). */
1169
+ function fireAt(t, now, jitter) {
1170
+ const tick = nextTick(t, now);
1171
+ return tick === null ? null : new Date(tick.getTime() + jitter * 1000);
1172
+ }
1173
+ /**
1174
+ * A task's expiry: an explicit one must lie in the future; null (not given) is now + the org's
1175
+ * `defaultExpiryDays` for a cron task when the org sets one, else never. The same on create and
1176
+ * update, so a creator cannot clear the org's expiry policy by sending null.
1177
+ */
1178
+ function expiryOf(v, trigger, now, settings) {
1179
+ if (v !== null)
1180
+ return checkExpiry(v, now);
1181
+ return trigger.kind === "cron" && settings.defaultExpiryDays !== null
1182
+ ? new Date(now.getTime() + settings.defaultExpiryDays * DAY_MS)
1183
+ : null;
1184
+ }
1185
+ /** An expiry must lie in the future; null = never. */
1186
+ function checkExpiry(v, now) {
1187
+ if (v === null)
1188
+ return null;
1189
+ if (Number.isNaN(v.getTime()) || v.getTime() <= now.getTime()) {
1190
+ throw new ApiError("validation_failed", "The expiry time is not in the future");
1191
+ }
1192
+ return v;
1193
+ }