@opengeni/core 3.0.1 → 3.1.2

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 (257) hide show
  1. package/README.md +4 -4
  2. package/dist/access/index.d.ts +131 -7
  3. package/dist/access/service-initiator.d.ts +3 -0
  4. package/dist/application/archived-session-imports.d.ts +6 -0
  5. package/dist/application/composer-submit.d.ts +1 -1
  6. package/dist/application/connect-operation.d.ts +6 -0
  7. package/dist/application/external-workspace-members.d.ts +13 -1
  8. package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
  9. package/dist/application/modal-native-original-configuration.d.ts +79 -0
  10. package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
  11. package/dist/application/new-session-drafts.d.ts +29 -2
  12. package/dist/application/organization-privacy.d.ts +57 -0
  13. package/dist/application/prepared-mcp-connection.d.ts +31 -0
  14. package/dist/application/sandbox-recovery.d.ts +58 -0
  15. package/dist/application/session-commands.d.ts +3 -0
  16. package/dist/application/session-tenancy.d.ts +13 -4
  17. package/dist/billing/agent-run-admission.d.ts +21 -0
  18. package/dist/billing/limits.d.ts +27 -1
  19. package/dist/billing/model-call-admission.d.ts +34 -0
  20. package/dist/billing/model-usage-settlement.d.ts +112 -0
  21. package/dist/canonical-human-identities.js +1 -1
  22. package/dist/{chunk-VCZFFEPB.js → chunk-7T2SZ7BI.js} +16 -2
  23. package/dist/chunk-7T2SZ7BI.js.map +1 -0
  24. package/dist/{chunk-ZYCNLYGH.js → chunk-OMJFIIWK.js} +178 -12
  25. package/dist/chunk-OMJFIIWK.js.map +1 -0
  26. package/dist/{chunk-H7X52RI2.js → chunk-QI2M2B2K.js} +83 -8
  27. package/dist/chunk-QI2M2B2K.js.map +1 -0
  28. package/dist/{chunk-OF65T3PM.js → chunk-TTWTXQB2.js} +94 -6
  29. package/dist/chunk-TTWTXQB2.js.map +1 -0
  30. package/dist/codex-model-availability.d.ts +55 -0
  31. package/dist/default-session-model.d.ts +192 -0
  32. package/dist/dependencies.d.ts +41 -7
  33. package/dist/developer-setup-credential-ceiling.d.ts +10 -0
  34. package/dist/domain/agent-config-resolution.d.ts +49 -0
  35. package/dist/domain/automations.d.ts +8 -1
  36. package/dist/domain/capabilities.d.ts +112 -8
  37. package/dist/domain/connection-account-selection-error.d.ts +8 -0
  38. package/dist/domain/connector-tool-permissions.d.ts +62 -0
  39. package/dist/domain/direct-model-provider.d.ts +3 -0
  40. package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
  41. package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
  42. package/dist/domain/editable-artifacts/ports.d.ts +10 -0
  43. package/dist/domain/editable-artifacts/service.d.ts +3 -1
  44. package/dist/domain/editable-artifacts/types.d.ts +1 -1
  45. package/dist/domain/encryption-key.d.ts +12 -0
  46. package/dist/domain/insights-timing.d.ts +16 -0
  47. package/dist/domain/insights-usage.d.ts +27 -0
  48. package/dist/domain/insights.d.ts +21 -3
  49. package/dist/domain/knowledge-files.d.ts +1 -1
  50. package/dist/domain/knowledge-preparation.d.ts +10 -0
  51. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  52. package/dist/domain/knowledge-search.d.ts +15 -76
  53. package/dist/domain/mcp-account-bindings.d.ts +17 -0
  54. package/dist/domain/mcp-account-routes.d.ts +21 -0
  55. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  56. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  57. package/dist/domain/personal-connection-delegations.d.ts +73 -12
  58. package/dist/domain/pr-review.d.ts +121 -6
  59. package/dist/domain/realtime-voice-billing.d.ts +103 -0
  60. package/dist/domain/resources.d.ts +17 -1
  61. package/dist/domain/scheduled-task-access.d.ts +265 -0
  62. package/dist/domain/scheduled-task-account-audit.d.ts +27 -0
  63. package/dist/domain/scheduled-task-audience.d.ts +70 -0
  64. package/dist/domain/scheduled-tasks.d.ts +61 -18
  65. package/dist/domain/session-connection-accounts.d.ts +53 -0
  66. package/dist/domain/session-file-access.d.ts +4 -0
  67. package/dist/domain/session-native-connection-replacement.d.ts +14 -0
  68. package/dist/domain/session-start-timing.d.ts +7 -0
  69. package/dist/domain/session-tool-policy.d.ts +102 -4
  70. package/dist/domain/sessions.d.ts +179 -15
  71. package/dist/domain/site-audience.d.ts +52 -0
  72. package/dist/domain/skills.d.ts +29 -1
  73. package/dist/domain/slack-bot.d.ts +46 -1
  74. package/dist/domain/slack-publication-secret-safety.d.ts +1 -1
  75. package/dist/domain/vaults/index.d.ts +7 -0
  76. package/dist/domain/vaults/mapping.d.ts +25 -0
  77. package/dist/domain/vaults/provider-registry.d.ts +72 -0
  78. package/dist/domain/vaults/provider-sync.d.ts +176 -0
  79. package/dist/domain/vaults/provider-writes.d.ts +57 -0
  80. package/dist/domain/vaults/secret-resolver.d.ts +34 -0
  81. package/dist/domain/vaults/types.d.ts +24 -0
  82. package/dist/domain/vaults/vault-service.d.ts +35 -0
  83. package/dist/domain/voice-input-billing.d.ts +77 -0
  84. package/dist/domain/web-search-billing.d.ts +51 -0
  85. package/dist/domain/workspace-credits.d.ts +7 -0
  86. package/dist/editable-artifact-live/application.d.ts +2 -1
  87. package/dist/editable-artifact-live/server.d.ts +5 -1
  88. package/dist/editable-artifact-live/ticket.d.ts +2 -1
  89. package/dist/editable-artifact-live/types.d.ts +9 -0
  90. package/dist/editable-artifact-live.js +2 -2
  91. package/dist/editable-artifacts.js +10 -2
  92. package/dist/goal-admission.d.ts +627 -0
  93. package/dist/host-session-usage-limit.d.ts +15 -0
  94. package/dist/index.d.ts +43 -8
  95. package/dist/index.js +22682 -11743
  96. package/dist/index.js.map +1 -1
  97. package/dist/managed-session.d.ts +110 -7
  98. package/dist/model-call.d.ts +58 -0
  99. package/dist/model-catalog.d.ts +29 -3
  100. package/dist/model-provider-credentials.d.ts +43 -0
  101. package/dist/product-usage-metrics.d.ts +30 -0
  102. package/dist/rigs/index.d.ts +1 -13
  103. package/dist/sandbox/fleet.d.ts +58 -4
  104. package/dist/sandbox/routing.d.ts +42 -2
  105. package/dist/sandbox/runtime-settings.d.ts +27 -17
  106. package/dist/session-authorization.d.ts +10 -0
  107. package/dist/session-usage-limit-metrics.d.ts +60 -0
  108. package/dist/session-usage-limits.d.ts +89 -0
  109. package/dist/site-agent-clamp.d.ts +71 -0
  110. package/dist/site-session-origin.d.ts +39 -2
  111. package/dist/transcription.d.ts +120 -3
  112. package/dist/turn-surface.d.ts +20 -0
  113. package/dist/user-presence.d.ts +32 -0
  114. package/dist/workflow-wake-contract.d.ts +13 -1
  115. package/package.json +15 -22
  116. package/src/access/index.ts +961 -38
  117. package/src/access/service-initiator.ts +50 -0
  118. package/src/application/api-integration-servers.ts +9 -0
  119. package/src/application/archived-session-imports.ts +161 -0
  120. package/src/application/composer-submit.ts +3 -2
  121. package/src/application/connect-authority.ts +3 -3
  122. package/src/application/connect-operation.ts +37 -3
  123. package/src/application/external-continuation.ts +20 -3
  124. package/src/application/external-link-work-admission.ts +3 -0
  125. package/src/application/external-workspace-members.ts +51 -3
  126. package/src/application/modal-native-live-declaration-join.ts +196 -0
  127. package/src/application/modal-native-original-configuration.ts +261 -0
  128. package/src/application/modal-native-worker-host-transport.ts +263 -0
  129. package/src/application/new-session-drafts.ts +172 -55
  130. package/src/application/organization-privacy.ts +161 -0
  131. package/src/application/prepared-mcp-connection.ts +133 -0
  132. package/src/application/sandbox-recovery.ts +78 -0
  133. package/src/application/session-commands.ts +73 -1
  134. package/src/application/session-mcp-credential-rotation.ts +61 -18
  135. package/src/application/session-tenancy.ts +262 -44
  136. package/src/application/user-resource-grants.ts +1 -29
  137. package/src/billing/agent-run-admission.ts +117 -0
  138. package/src/billing/limits.ts +138 -14
  139. package/src/billing/model-call-admission.ts +118 -0
  140. package/src/billing/model-usage-settlement.ts +472 -0
  141. package/src/codex-model-availability.ts +264 -0
  142. package/src/default-session-model.ts +692 -0
  143. package/src/dependencies.ts +48 -6
  144. package/src/developer-setup-credential-ceiling.ts +60 -0
  145. package/src/domain/agent-config-resolution.ts +148 -0
  146. package/src/domain/automations.ts +76 -2
  147. package/src/domain/capabilities.ts +830 -181
  148. package/src/domain/connection-account-selection-error.ts +19 -0
  149. package/src/domain/connector-tool-permissions.ts +572 -0
  150. package/src/domain/direct-model-provider.ts +62 -0
  151. package/src/domain/editable-artifacts/agent-application.ts +44 -6
  152. package/src/domain/editable-artifacts/durable-export.ts +100 -5
  153. package/src/domain/editable-artifacts/in-memory.ts +6 -0
  154. package/src/domain/editable-artifacts/ports.ts +11 -0
  155. package/src/domain/editable-artifacts/service.ts +20 -0
  156. package/src/domain/editable-artifacts/types.ts +1 -1
  157. package/src/domain/encryption-key.ts +26 -0
  158. package/src/domain/file-owner.ts +16 -1
  159. package/src/domain/github-action-policies.ts +1 -1
  160. package/src/domain/github-skill-source.ts +3 -1
  161. package/src/domain/insights-timing.ts +95 -0
  162. package/src/domain/insights-usage.ts +67 -0
  163. package/src/domain/insights.ts +167 -43
  164. package/src/domain/knowledge-files.ts +10 -3
  165. package/src/domain/knowledge-messages.ts +1 -0
  166. package/src/domain/knowledge-preparation.test.ts +276 -0
  167. package/src/domain/knowledge-preparation.ts +147 -0
  168. package/src/domain/knowledge-search.test.ts +617 -5
  169. package/src/domain/knowledge-search.ts +240 -23
  170. package/src/domain/mcp-account-bindings.ts +169 -0
  171. package/src/domain/mcp-account-routes.ts +152 -0
  172. package/src/domain/native-mcp-connection-admission.ts +14 -0
  173. package/src/domain/organization-integration-catalog.ts +88 -0
  174. package/src/domain/personal-connection-delegations.ts +399 -257
  175. package/src/domain/pr-review.ts +24 -8
  176. package/src/domain/realtime-voice-billing.ts +425 -0
  177. package/src/domain/resources.ts +50 -4
  178. package/src/domain/scheduled-task-access.ts +1089 -0
  179. package/src/domain/scheduled-task-account-audit.ts +90 -0
  180. package/src/domain/scheduled-task-audience.ts +249 -0
  181. package/src/domain/scheduled-tasks.ts +1313 -374
  182. package/src/domain/session-connection-accounts.ts +239 -0
  183. package/src/domain/session-file-access.ts +47 -0
  184. package/src/domain/session-native-connection-replacement.ts +64 -0
  185. package/src/domain/session-start-timing.ts +60 -0
  186. package/src/domain/session-tool-policy.ts +489 -14
  187. package/src/domain/sessions.ts +2254 -717
  188. package/src/domain/site-audience.ts +130 -0
  189. package/src/domain/skills.ts +9 -0
  190. package/src/domain/slack-bot.ts +141 -4
  191. package/src/domain/slack-publication-secret-safety.ts +1 -1
  192. package/src/domain/vaults/index.ts +19 -0
  193. package/src/domain/vaults/mapping.ts +190 -0
  194. package/src/domain/vaults/provider-registry.ts +201 -0
  195. package/src/domain/vaults/provider-sync.ts +531 -0
  196. package/src/domain/vaults/provider-writes.ts +288 -0
  197. package/src/domain/vaults/secret-resolver.ts +464 -0
  198. package/src/domain/vaults/types.ts +26 -0
  199. package/src/domain/vaults/vault-service.ts +1316 -0
  200. package/src/domain/voice-input-billing.ts +330 -0
  201. package/src/domain/web-search-billing.ts +169 -0
  202. package/src/domain/workspace-credits.ts +13 -0
  203. package/src/editable-artifact-live/application.ts +8 -1
  204. package/src/editable-artifact-live/server.ts +154 -6
  205. package/src/editable-artifact-live/ticket.ts +86 -1
  206. package/src/editable-artifact-live/types.ts +10 -0
  207. package/src/goal-admission.ts +220 -0
  208. package/src/host-session-usage-limit.ts +25 -0
  209. package/src/index.ts +75 -11
  210. package/src/managed-session.ts +137 -6
  211. package/src/model-call.ts +79 -0
  212. package/src/model-catalog.ts +345 -91
  213. package/src/model-provider-credentials.ts +394 -0
  214. package/src/product-usage-metrics.ts +101 -0
  215. package/src/rigs/index.ts +23 -111
  216. package/src/sandbox/fleet.ts +253 -28
  217. package/src/sandbox/routing.ts +204 -43
  218. package/src/sandbox/runtime-settings.ts +159 -85
  219. package/src/session-authorization.ts +172 -23
  220. package/src/session-usage-limit-metrics.ts +95 -0
  221. package/src/session-usage-limits.ts +415 -0
  222. package/src/site-agent-clamp.ts +226 -0
  223. package/src/site-session-origin.ts +84 -5
  224. package/src/transcription.ts +123 -4
  225. package/src/turn-surface.ts +47 -0
  226. package/src/user-presence.ts +134 -0
  227. package/src/workflow-wake-contract.ts +14 -1
  228. package/dist/application/host-mcp-owner.d.ts +0 -6
  229. package/dist/application/host-mcp-resolvers.d.ts +0 -13
  230. package/dist/chunk-5WZYFL45.js +0 -266
  231. package/dist/chunk-5WZYFL45.js.map +0 -1
  232. package/dist/chunk-H7X52RI2.js.map +0 -1
  233. package/dist/chunk-OF65T3PM.js.map +0 -1
  234. package/dist/chunk-VCZFFEPB.js.map +0 -1
  235. package/dist/chunk-ZYCNLYGH.js.map +0 -1
  236. package/dist/domain/connection-authority.d.ts +0 -97
  237. package/dist/domain/connection-authority.js +0 -169
  238. package/dist/domain/connection-authority.js.map +0 -1
  239. package/dist/domain/environments.d.ts +0 -33
  240. package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
  241. package/dist/domain/host-mcp-task-admission.d.ts +0 -18
  242. package/dist/domain/packs.d.ts +0 -59
  243. package/dist/domain/product-integration-pack.d.ts +0 -44
  244. package/dist/domain/product-integration-skill.gen.d.ts +0 -5
  245. package/dist/remote-mcp-credentials.d.ts +0 -23
  246. package/dist/remote-mcp-credentials.js +0 -11
  247. package/dist/remote-mcp-credentials.js.map +0 -1
  248. package/src/application/host-mcp-owner.ts +0 -41
  249. package/src/application/host-mcp-resolvers.ts +0 -84
  250. package/src/domain/connection-authority.ts +0 -329
  251. package/src/domain/environments.ts +0 -120
  252. package/src/domain/host-mcp-authority-source-admission.ts +0 -25
  253. package/src/domain/host-mcp-task-admission.ts +0 -121
  254. package/src/domain/packs.ts +0 -699
  255. package/src/domain/product-integration-pack.ts +0 -42
  256. package/src/domain/product-integration-skill.gen.ts +0 -52
  257. package/src/remote-mcp-credentials.ts +0 -361
@@ -0,0 +1,1089 @@
1
+ /**
2
+ * Scheduled-task access drift, the owner's explicit access refresh, and the
3
+ * in-app signal for runs that failed closed on connector access or ran without
4
+ * an unusable pinned account.
5
+ *
6
+ * A separate-agent task saves its connectors and (when an agent created it,
7
+ * migration 0428) its Opengeni tool policy. Connector accounts are never
8
+ * saved: every run resolves the owner's current accounts, except for accounts
9
+ * a person explicitly pinned. This module computes one plan: what the task's
10
+ * owner would get by saving it again now, and which pinned accounts can no
11
+ * longer be used. The refresh applies exactly that plan through the ordinary
12
+ * owner update path, so a refresh never widens beyond what the calling person
13
+ * could grant with an edit. See docs/scheduled-task-access.md.
14
+ */
15
+ import {
16
+ allowedFirstPartyMcpToolsForSession,
17
+ resolveFirstPartyMcpToolPolicy,
18
+ type Settings,
19
+ type FirstPartyMcpToolPolicySettings,
20
+ } from "@opengeni/config";
21
+ import {
22
+ DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
23
+ SCHEDULED_TASK_ACCOUNT_PLAN_ACCOUNTS_MAX,
24
+ SCHEDULED_TASK_ACCESS_CONNECTORS_MAX,
25
+ SCHEDULED_TASK_ACCESS_ATTENTION_MAX,
26
+ SCHEDULED_TASK_RUN_ACCESS_FAILURES_MAX,
27
+ ToolAuthNeededReason,
28
+ mergeToolRefs,
29
+ resolveWorkspaceSessionToolDefaults,
30
+ scheduledTaskKnowledgeSource,
31
+ type AccessGrant,
32
+ type ConnectionMetadata,
33
+ type FirstPartyMcpToolName,
34
+ type McpConnectionAccountBinding,
35
+ type McpConnectionAccountSelection,
36
+ type Permission,
37
+ type RefreshScheduledTaskAccessRequest,
38
+ type ScheduledTask,
39
+ type ScheduledTaskAccountPlan,
40
+ type ScheduledTaskAccessAttention,
41
+ type ScheduledTaskAccessConnector,
42
+ type ScheduledTaskPolicyDrift,
43
+ type ScheduledTaskRun,
44
+ type ScheduledTaskRunAccessFailure,
45
+ type SessionAuthorizationPort,
46
+ type SessionAuthorizationSurface,
47
+ type ToolRef,
48
+ } from "@opengeni/contracts";
49
+ import { GOOGLE_DRIVE_PUBLICATION_SERVER_ID } from "@opengeni/contracts/google-drive";
50
+ import { PERSONAL_GITHUB_CONNECTION_SURFACE_ID } from "@opengeni/contracts/personal-github";
51
+ import {
52
+ getSession,
53
+ listActiveScheduledTasksWithPinnedAccounts,
54
+ listScheduledTaskAccessAttentionEvents,
55
+ listScheduledTaskHumanWaitAttention,
56
+ listScheduledTaskCreatorPolicies,
57
+ listScheduledTaskRunAuthNeededEvents,
58
+ requireWorkspace,
59
+ ScheduledTaskHeadChangedError,
60
+ type Database,
61
+ type ScheduledTaskCreatorPolicy,
62
+ } from "@opengeni/db";
63
+ import { HTTPException } from "hono/http-exception";
64
+ import { z } from "zod";
65
+ import {
66
+ hasPermission,
67
+ hasVerifiedOwningUserAuthorization,
68
+ requirePermission,
69
+ type AccessGrantAuthorization,
70
+ } from "../access";
71
+ import type { ObjectStorageDependency } from "../dependencies";
72
+ import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
73
+ import {
74
+ availableMcpAccountBindings,
75
+ personalConnectionDelegationSourceForGrant,
76
+ visibleMcpAccountConnections,
77
+ type PersonalConnectionDelegationSource,
78
+ } from "./personal-connection-delegations";
79
+ import { scheduledTaskRunTools, withWorkspaceDefaultMcpTools } from "./resources";
80
+ import { requireVisibleScheduledTask } from "./scheduled-task-audience";
81
+ import {
82
+ assertScheduledTaskMutationOwner,
83
+ scheduledConnectionTools,
84
+ updateScheduledTaskForApi,
85
+ validatedScheduledTaskUpdate,
86
+ } from "./scheduled-tasks";
87
+ import { settingsWithSessionMcpServerMetadata } from "./sessions";
88
+
89
+ const ALWAYS_ATTACHED_CONNECTOR = "opengeni";
90
+
91
+ /** Special first-party surfaces keep their own account contract; a refresh passes them through. */
92
+ const PRESERVED_ACCOUNT_SURFACES: ReadonlySet<string> = new Set([
93
+ GOOGLE_DRIVE_PUBLICATION_SERVER_ID,
94
+ PERSONAL_GITHUB_CONNECTION_SURFACE_ID,
95
+ ]);
96
+
97
+ export type ScheduledTaskConnectorPlan = {
98
+ tools: ToolRef[];
99
+ missing: ScheduledTaskAccessConnector[];
100
+ unavailable: ScheduledTaskAccessConnector[];
101
+ };
102
+
103
+ function connector(
104
+ id: string,
105
+ names: ReadonlyMap<string, string>,
106
+ fallback?: string,
107
+ ): ScheduledTaskAccessConnector {
108
+ const name = names.get(id)?.trim() || fallback?.trim() || id;
109
+ return { id: id.slice(0, 256), name: name.slice(0, 256) };
110
+ }
111
+
112
+ function uniqueById(items: ScheduledTaskAccessConnector[]): ScheduledTaskAccessConnector[] {
113
+ const seen = new Set<string>();
114
+ return items
115
+ .filter((item) => (seen.has(item.id) ? false : (seen.add(item.id), true)))
116
+ .slice(0, SCHEDULED_TASK_ACCESS_CONNECTORS_MAX);
117
+ }
118
+
119
+ /**
120
+ * Connectors: keep every connector the task already uses that still exists,
121
+ * drop the ones this workspace no longer sets up, and add the workspace
122
+ * defaults a new schedule would get, except the ones the owner chose to keep
123
+ * off (`leaveOut`). A refresh never removes a connector the owner deliberately
124
+ * kept, so it only narrows what no longer exists.
125
+ */
126
+ export function planScheduledTaskConnectors(input: {
127
+ taskTools: readonly ToolRef[];
128
+ defaultTools: readonly ToolRef[];
129
+ availableServerIds: ReadonlySet<string>;
130
+ names: ReadonlyMap<string, string>;
131
+ leaveOut?: ReadonlySet<string>;
132
+ }): ScheduledTaskConnectorPlan {
133
+ const unavailable = input.taskTools.filter(
134
+ (tool) => tool.id !== ALWAYS_ATTACHED_CONNECTOR && !input.availableServerIds.has(tool.id),
135
+ );
136
+ const unavailableIds = new Set(unavailable.map((tool) => tool.id));
137
+ const kept = input.taskTools.filter((tool) => !unavailableIds.has(tool.id));
138
+ const keptIds = new Set(kept.map((tool) => tool.id));
139
+ const missing = input.defaultTools.filter(
140
+ (tool) =>
141
+ tool.id !== ALWAYS_ATTACHED_CONNECTOR &&
142
+ !keptIds.has(tool.id) &&
143
+ !input.leaveOut?.has(tool.id) &&
144
+ input.availableServerIds.has(tool.id),
145
+ );
146
+ return {
147
+ tools: mergeToolRefs([...kept], [...missing]),
148
+ missing: uniqueById(missing.map((tool) => connector(tool.id, input.names))),
149
+ unavailable: uniqueById(unavailable.map((tool) => connector(tool.id, input.names))),
150
+ };
151
+ }
152
+
153
+ export type ScheduledTaskPinnedAccountPlan = {
154
+ /** Pins that stay: their connector is still used (special surfaces always stay). */
155
+ kept: McpConnectionAccountSelection[];
156
+ /** Connectors with a pinned account the owner can no longer use. */
157
+ unavailable: ScheduledTaskAccessConnector[];
158
+ /** Connectors with a pin that the task no longer uses; saving drops the pin. */
159
+ dropped: ScheduledTaskAccessConnector[];
160
+ };
161
+
162
+ /**
163
+ * Person-pinned accounts against what the owner can use now. Unpinned
164
+ * connectors need no plan: every run resolves the owner's current accounts.
165
+ * A pin is unavailable when its connector is still used but the pinned
166
+ * account no longer resolves for the owner; runs then go without that
167
+ * connector, never with another account. A pin whose connector the task no
168
+ * longer uses is dropped by the next save. Special first-party surfaces keep
169
+ * their own account contract and are checked at admission.
170
+ */
171
+ export function planScheduledTaskPinnedAccounts(input: {
172
+ pins: readonly McpConnectionAccountSelection[];
173
+ connectionServerIds: readonly string[];
174
+ availableBindings: ReadonlyArray<{ canonicalServerId: string; connectionId: string }>;
175
+ names: ReadonlyMap<string, string>;
176
+ }): ScheduledTaskPinnedAccountPlan {
177
+ const used = new Set(input.connectionServerIds);
178
+ const available = new Set(
179
+ input.availableBindings.map((binding) =>
180
+ JSON.stringify([binding.canonicalServerId, binding.connectionId]),
181
+ ),
182
+ );
183
+ const kept: McpConnectionAccountSelection[] = [];
184
+ const unavailable: ScheduledTaskAccessConnector[] = [];
185
+ const dropped: ScheduledTaskAccessConnector[] = [];
186
+ for (const pin of input.pins) {
187
+ if (PRESERVED_ACCOUNT_SURFACES.has(pin.serverId)) {
188
+ kept.push(pin);
189
+ continue;
190
+ }
191
+ if (!used.has(pin.serverId)) {
192
+ dropped.push(connector(pin.serverId, input.names));
193
+ continue;
194
+ }
195
+ kept.push(pin);
196
+ if (!available.has(JSON.stringify([pin.serverId, pin.connectionId])))
197
+ unavailable.push(connector(pin.serverId, input.names));
198
+ }
199
+ return {
200
+ kept,
201
+ unavailable: uniqueById(unavailable),
202
+ dropped: uniqueById(dropped),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * What each connector of a scheduled run would act as if the run started
208
+ * now. `bindings` are every account the owner can use for the task's
209
+ * connectors right now, resolved the way a run resolves them. A connector
210
+ * without pins follows those accounts (Default); a pinned connector uses only
211
+ * the pinned accounts that still resolve and names the ones that do not.
212
+ * Special first-party surfaces keep their own account contract and are not
213
+ * listed; neither are host-supplied connectors (absent from
214
+ * `connectionServerIds`).
215
+ */
216
+ export function planScheduledTaskAccounts(input: {
217
+ connectionServerIds: readonly string[];
218
+ pins: readonly McpConnectionAccountSelection[];
219
+ bindings: ReadonlyArray<
220
+ Pick<
221
+ McpConnectionAccountBinding,
222
+ "canonicalServerId" | "connectionId" | "accountLabel" | "subjectScope"
223
+ >
224
+ >;
225
+ names: ReadonlyMap<string, string>;
226
+ }): ScheduledTaskAccountPlan {
227
+ const connectors: ScheduledTaskAccountPlan["connectors"] = [];
228
+ for (const serverId of new Set(input.connectionServerIds)) {
229
+ if (PRESERVED_ACCOUNT_SURFACES.has(serverId)) continue;
230
+ if (connectors.length === SCHEDULED_TASK_ACCESS_CONNECTORS_MAX) break;
231
+ const pinned = new Set(
232
+ input.pins.filter((pin) => pin.serverId === serverId).map((pin) => pin.connectionId),
233
+ );
234
+ const usable = input.bindings.filter(
235
+ (binding) =>
236
+ binding.canonicalServerId === serverId &&
237
+ (pinned.size === 0 || pinned.has(binding.connectionId)),
238
+ );
239
+ const resolved = new Set(usable.map((binding) => binding.connectionId));
240
+ connectors.push({
241
+ ...connector(serverId, input.names),
242
+ mode: pinned.size === 0 ? "default" : "pinned",
243
+ accounts: usable.slice(0, SCHEDULED_TASK_ACCOUNT_PLAN_ACCOUNTS_MAX).map((binding) => ({
244
+ connectionId: binding.connectionId,
245
+ label: binding.accountLabel,
246
+ scope: binding.subjectScope === "subject" ? ("personal" as const) : ("shared" as const),
247
+ })),
248
+ unavailablePins: [...pinned]
249
+ .filter((connectionId) => !resolved.has(connectionId))
250
+ .slice(0, SCHEDULED_TASK_ACCOUNT_PLAN_ACCOUNTS_MAX)
251
+ .map((connectionId) => ({ connectionId })),
252
+ });
253
+ }
254
+ return { connectors };
255
+ }
256
+
257
+ export type ScheduledTaskOpenGeniToolPlan = {
258
+ missing: FirstPartyMcpToolName[];
259
+ policy: {
260
+ firstPartyMcpTools: FirstPartyMcpToolName[];
261
+ firstPartyMcpPermissions: Permission[];
262
+ } | null;
263
+ };
264
+
265
+ function sameMembers<T>(left: readonly T[], right: readonly T[]): boolean {
266
+ const a = new Set(left);
267
+ const b = new Set(right);
268
+ return a.size === b.size && [...a].every((item) => b.has(item));
269
+ }
270
+
271
+ /** Permissions each first-party tool needs; the API passes its MCP registration table. */
272
+ export type FirstPartyToolPermissionRequirements = (
273
+ tools: readonly FirstPartyMcpToolName[],
274
+ ) => readonly Permission[];
275
+
276
+ /**
277
+ * Opengeni tools of an agent-created task (migration 0428). A human- or
278
+ * API-created task has no frozen creator policy and already follows the
279
+ * deployment default at each run, so there is nothing to refresh. For a frozen
280
+ * policy the refresh adds the current default tools. Permissions stay
281
+ * least-privilege: a frozen permission is kept only while the refreshing
282
+ * person holds it, and the only permissions added are the ones the newly
283
+ * added tools need, within the default worker set and that person's grant. A
284
+ * refresh therefore never lifts a deliberately narrowed permission boundary
285
+ * for tools the task already had, and every addition follows a tool the drift
286
+ * report names. Default tools the owner chose to keep off (`leaveOut`) are
287
+ * neither reported nor added. The frozen session policy (agent access, scope,
288
+ * memory) is never touched.
289
+ */
290
+ export function planScheduledTaskOpenGeniTools(input: {
291
+ creatorPolicy: Pick<
292
+ ScheduledTaskCreatorPolicy,
293
+ "firstPartyMcpTools" | "firstPartyMcpPermissions"
294
+ > | null;
295
+ settings: FirstPartyMcpToolPolicySettings;
296
+ grantPermissions: readonly Permission[];
297
+ grantPermissionMode?: AccessGrant["permissionMode"];
298
+ permissionsRequiredByTools: FirstPartyToolPermissionRequirements;
299
+ leaveOut?: ReadonlySet<FirstPartyMcpToolName>;
300
+ }): ScheduledTaskOpenGeniToolPlan {
301
+ const stored = input.creatorPolicy?.firstPartyMcpTools;
302
+ if (!stored) return { missing: [], policy: null };
303
+ const effective = new Set(allowedFirstPartyMcpToolsForSession(input.settings, stored));
304
+ const missing = resolveFirstPartyMcpToolPolicy(input.settings).default.filter(
305
+ (tool) => !effective.has(tool) && !input.leaveOut?.has(tool),
306
+ );
307
+ const tools = [...new Set([...stored, ...missing])];
308
+ // A frozen null permission set runs with the default worker set (see the
309
+ // scheduler), so that is the boundary a refresh starts from.
310
+ const priorPermissions: readonly Permission[] = input.creatorPolicy?.firstPartyMcpPermissions ?? [
311
+ ...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
312
+ ];
313
+ const holds = (permission: Permission) =>
314
+ hasPermission(input.grantPermissions, permission, input.grantPermissionMode);
315
+ const defaults: ReadonlySet<Permission> = new Set(DEFAULT_FIRST_PARTY_MCP_PERMISSIONS);
316
+ const added = input
317
+ .permissionsRequiredByTools(missing)
318
+ .filter((permission) => defaults.has(permission) && holds(permission));
319
+ const permissions = [...new Set<Permission>([...priorPermissions.filter(holds), ...added])];
320
+ const unchanged = sameMembers(tools, stored) && sameMembers(permissions, priorPermissions);
321
+ return {
322
+ missing,
323
+ policy: unchanged ? null : { firstPartyMcpTools: tools, firstPartyMcpPermissions: permissions },
324
+ };
325
+ }
326
+
327
+ const AuthNeededFact = z.object({
328
+ serverId: z.string().min(1),
329
+ canonicalServerId: z.string().min(1).optional(),
330
+ providerDomain: z.string().min(1),
331
+ reason: ToolAuthNeededReason,
332
+ capability: z.unknown().optional(),
333
+ setupRequest: z.unknown().optional(),
334
+ });
335
+
336
+ /**
337
+ * Fold one run's `tool.auth_needed` facts into one entry per connector and
338
+ * reason. An agent's recommendation to set up a new capability or custom
339
+ * connector is a suggestion, not a failed frozen access, and is left out.
340
+ */
341
+ export function scheduledTaskRunAccessFailures(
342
+ events: ReadonlyArray<{ payload: unknown; occurredAt: string }>,
343
+ names: ReadonlyMap<string, string>,
344
+ ): ScheduledTaskRunAccessFailure[] {
345
+ const byKey = new Map<string, ScheduledTaskRunAccessFailure>();
346
+ for (const event of events) {
347
+ const parsed = AuthNeededFact.safeParse(event.payload);
348
+ if (!parsed.success) continue;
349
+ const fact = parsed.data;
350
+ if (fact.capability !== undefined || fact.setupRequest !== undefined) continue;
351
+ const serverId = (fact.canonicalServerId ?? fact.serverId).slice(0, 256);
352
+ const key = `${serverId}\u0000${fact.reason}`;
353
+ const prior = byKey.get(key);
354
+ if (prior) {
355
+ prior.count += 1;
356
+ if (event.occurredAt < prior.firstOccurredAt) prior.firstOccurredAt = event.occurredAt;
357
+ continue;
358
+ }
359
+ byKey.set(key, {
360
+ serverId,
361
+ name: connector(serverId, names, fact.providerDomain).name,
362
+ providerDomain: fact.providerDomain.slice(0, 512),
363
+ reason: fact.reason,
364
+ count: 1,
365
+ firstOccurredAt: event.occurredAt,
366
+ });
367
+ }
368
+ return [...byKey.values()]
369
+ .sort((left, right) => left.firstOccurredAt.localeCompare(right.firstOccurredAt))
370
+ .slice(0, SCHEDULED_TASK_RUN_ACCESS_FAILURES_MAX);
371
+ }
372
+
373
+ /**
374
+ * Whose access view this viewer may see: the owner's (only for the owner
375
+ * itself, as an entitled authenticated subject) or, for a task without an
376
+ * owner, the workspace's (for people who manage schedules). Null otherwise.
377
+ */
378
+ export function scheduledTaskAccessSource(
379
+ task: Pick<ScheduledTask, "ownerSubjectId">,
380
+ grant: AccessGrant,
381
+ ): Exclude<PersonalConnectionDelegationSource, { kind: "turn" }> | null {
382
+ if (task.ownerSubjectId === null) {
383
+ return hasPermission(grant.permissions, "scheduled_tasks:manage") ? { kind: "none" } : null;
384
+ }
385
+ const source = personalConnectionDelegationSourceForGrant(grant);
386
+ return source.kind === "subject" && source.subjectId === task.ownerSubjectId ? source : null;
387
+ }
388
+
389
+ /**
390
+ * A signed-in person: the canonical managed cookie session (or a verified
391
+ * external owning user) or the exact built-in local human. Provenance stamps,
392
+ * never grant shape; API keys, services, delegated bearers and agent attempts
393
+ * do not qualify.
394
+ */
395
+ export function isScheduledTaskAccessRefreshHuman(
396
+ authorization: AccessGrantAuthorization,
397
+ workspaceId: string,
398
+ ): boolean {
399
+ const { grant } = authorization;
400
+ if (grant.workspaceId !== workspaceId) return false;
401
+ if (
402
+ authorization.canonicalLocalHumanSession &&
403
+ authorization.contextIntegrity &&
404
+ authorization.authenticatedSubjectId === grant.subjectId &&
405
+ grant.principalKind === "human_session" &&
406
+ grant.metadata?.delegated !== true &&
407
+ !grant.serviceInitiator
408
+ )
409
+ return true;
410
+ return (
411
+ hasVerifiedOwningUserAuthorization(authorization) &&
412
+ personalConnectionDelegationSourceForGrant(grant).kind === "subject"
413
+ );
414
+ }
415
+
416
+ function scheduledTaskToolsFollowTask(task: Pick<ScheduledTask, "runMode" | "reusableSessionId">) {
417
+ return (
418
+ task.runMode === "new_session_per_run" ||
419
+ (task.runMode === "reusable_session" && task.reusableSessionId === null)
420
+ );
421
+ }
422
+
423
+ function hasAgentAccess(task: Pick<ScheduledTask, "action" | "agentConfig">): boolean {
424
+ return task.action.kind === "agent_turn" && scheduledTaskKnowledgeSource(task) === null;
425
+ }
426
+
427
+ export type ScheduledTaskAccessPlan = {
428
+ drift: Omit<ScheduledTaskPolicyDrift, "canRefresh">;
429
+ /** What each connector would act as if a run started now (see `planScheduledTaskAccounts`). */
430
+ accounts: ScheduledTaskAccountPlan;
431
+ /** Connectors with a pinned account the task no longer uses (see the pin plan). */
432
+ droppedPins: ScheduledTaskAccessConnector[];
433
+ tools: ToolRef[];
434
+ creatorFirstPartyPolicy: ScheduledTaskOpenGeniToolPlan["policy"];
435
+ changed: boolean;
436
+ };
437
+
438
+ /** Request-local reads shared by every task one viewer's list decorates. */
439
+ export type ScheduledTaskAccessReadCache = {
440
+ registries: Map<string, Promise<Settings>>;
441
+ connections: Map<string, Promise<ConnectionMetadata[]>>;
442
+ };
443
+
444
+ export function scheduledTaskAccessReadCache(): ScheduledTaskAccessReadCache {
445
+ return { registries: new Map(), connections: new Map() };
446
+ }
447
+
448
+ function cached<T>(cache: Map<string, Promise<T>>, key: string, read: () => Promise<T>) {
449
+ let value = cache.get(key);
450
+ if (!value) {
451
+ value = read();
452
+ cache.set(key, value);
453
+ }
454
+ return value;
455
+ }
456
+
457
+ function runtimeRegistry(
458
+ db: Database,
459
+ workspaceId: string,
460
+ settings: Settings,
461
+ subjectId: string | null,
462
+ cache: ScheduledTaskAccessReadCache,
463
+ ): Promise<Settings> {
464
+ return cached(cache.registries, subjectId ?? "", () =>
465
+ settingsWithEnabledCapabilityMcpServers(
466
+ db,
467
+ workspaceId,
468
+ settings,
469
+ subjectId ? { subjectId } : {},
470
+ ),
471
+ );
472
+ }
473
+
474
+ function registryNames(settings: Pick<Settings, "mcpServers">): Map<string, string> {
475
+ return new Map(settings.mcpServers.map((server) => [server.id, server.name ?? server.id]));
476
+ }
477
+
478
+ /**
479
+ * What saving `task` again would save for its owner now, and which of its
480
+ * pinned accounts the owner can no longer use, read-only. The connector
481
+ * registry used to judge availability is the calling person's, the same
482
+ * registry the owner update path validates against.
483
+ */
484
+ export async function computeScheduledTaskAccessPlan(input: {
485
+ db: Database;
486
+ settings: Settings;
487
+ grant: AccessGrant;
488
+ task: ScheduledTask;
489
+ source: Exclude<PersonalConnectionDelegationSource, { kind: "turn" }>;
490
+ creatorPolicy: ScheduledTaskCreatorPolicy | null;
491
+ permissionsRequiredByTools: FirstPartyToolPermissionRequirements;
492
+ cache?: ScheduledTaskAccessReadCache;
493
+ workspaceSettings?: unknown;
494
+ /** Defaults the refreshing person chose to keep off; narrows what the plan adds. */
495
+ leaveOut?: RefreshScheduledTaskAccessRequest["leaveOut"];
496
+ }): Promise<ScheduledTaskAccessPlan> {
497
+ const { db, task, settings } = input;
498
+ const cache = input.cache ?? scheduledTaskAccessReadCache();
499
+ const ownerSubjectId = input.source.kind === "subject" ? input.source.subjectId : null;
500
+ const [callerRegistry, ownerRegistry, workspaceSettings] = await Promise.all([
501
+ runtimeRegistry(db, task.workspaceId, settings, input.grant.subjectId, cache),
502
+ runtimeRegistry(db, task.workspaceId, settings, ownerSubjectId, cache),
503
+ input.workspaceSettings !== undefined
504
+ ? Promise.resolve(input.workspaceSettings)
505
+ : requireWorkspace(db, task.workspaceId).then((workspace) => workspace.settings),
506
+ ]);
507
+ const followsTask = scheduledTaskToolsFollowTask(task);
508
+ const target =
509
+ task.runMode === "existing_session" && task.targetSessionId
510
+ ? await getSession(db, task.workspaceId, task.targetSessionId)
511
+ : null;
512
+ const accountRegistry = target
513
+ ? settingsWithSessionMcpServerMetadata(ownerRegistry, target.mcpServers)
514
+ : ownerRegistry;
515
+ const names = new Map([...registryNames(callerRegistry), ...registryNames(accountRegistry)]);
516
+ const workspaceDefaults = resolveWorkspaceSessionToolDefaults(workspaceSettings);
517
+ // A schedule that follows the workspace defaults never lacks one: its runs
518
+ // get them as they are. Only the tools it lists itself can go stale.
519
+ const followsDefaults = task.agentConfig.toolPolicy !== undefined;
520
+ const connectors = followsTask
521
+ ? planScheduledTaskConnectors({
522
+ taskTools: task.agentConfig.tools,
523
+ defaultTools: followsDefaults
524
+ ? []
525
+ : withWorkspaceDefaultMcpTools([], settings, ownerRegistry, workspaceDefaults),
526
+ availableServerIds: new Set(callerRegistry.mcpServers.map((server) => server.id)),
527
+ names,
528
+ leaveOut: new Set(input.leaveOut?.connectors ?? []),
529
+ })
530
+ : { tools: [...task.agentConfig.tools], missing: [], unavailable: [] };
531
+ const connectionTools = await scheduledConnectionTools(
532
+ db,
533
+ task.workspaceId,
534
+ accountRegistry,
535
+ target,
536
+ followsTask
537
+ ? scheduledTaskRunTools({
538
+ agentConfig: { tools: connectors.tools, toolPolicy: task.agentConfig.toolPolicy },
539
+ settings,
540
+ runtimeSettings: ownerRegistry,
541
+ workspaceDefaults,
542
+ })
543
+ : connectors.tools,
544
+ ownerSubjectId ?? undefined,
545
+ );
546
+ const connectionToolIds = new Set(connectionTools.map((tool) => tool.id));
547
+ const connectionServerIds = accountRegistry.mcpServers
548
+ .filter(
549
+ (server) =>
550
+ connectionToolIds.has(server.id) &&
551
+ server.connectionRef &&
552
+ server.connectionRef.authoritySource !== "host",
553
+ )
554
+ .map((server) => server.id);
555
+ const source = input.source;
556
+ const pins = task.agentConfig.pinnedAccounts ?? [];
557
+ const availableBindings =
558
+ connectionServerIds.length === 0
559
+ ? []
560
+ : await availableMcpAccountBindings({
561
+ db,
562
+ accountId: task.accountId,
563
+ workspaceId: task.workspaceId,
564
+ settings: accountRegistry,
565
+ tools: connectionTools,
566
+ source,
567
+ connections: await cached(
568
+ cache.connections,
569
+ source.kind === "subject" ? `subject:${source.subjectId}` : "workspace",
570
+ () =>
571
+ visibleMcpAccountConnections(db, {
572
+ accountId: task.accountId,
573
+ workspaceId: task.workspaceId,
574
+ source,
575
+ }),
576
+ ),
577
+ });
578
+ const accounts = planScheduledTaskPinnedAccounts({
579
+ pins,
580
+ connectionServerIds,
581
+ availableBindings,
582
+ names,
583
+ });
584
+ const openGeni = followsTask
585
+ ? planScheduledTaskOpenGeniTools({
586
+ creatorPolicy: input.creatorPolicy,
587
+ settings,
588
+ grantPermissions: input.grant.permissions,
589
+ grantPermissionMode: input.grant.permissionMode,
590
+ permissionsRequiredByTools: input.permissionsRequiredByTools,
591
+ leaveOut: new Set(input.leaveOut?.openGeniTools ?? []),
592
+ })
593
+ : { missing: [], policy: null };
594
+ const toolKey = (tool: ToolRef) =>
595
+ `${tool.kind}:${tool.id}:${tool.optional === true}:${tool.eager === true}`;
596
+ const toolsChanged = !sameMembers(
597
+ connectors.tools.map(toolKey),
598
+ task.agentConfig.tools.map(toolKey),
599
+ );
600
+ return {
601
+ drift: {
602
+ missingConnectors: connectors.missing,
603
+ unavailableConnectors: connectors.unavailable,
604
+ missingOpenGeniTools: openGeni.missing,
605
+ unavailableAccounts: accounts.unavailable,
606
+ },
607
+ accounts: planScheduledTaskAccounts({
608
+ connectionServerIds,
609
+ pins,
610
+ bindings: availableBindings,
611
+ names,
612
+ }),
613
+ droppedPins: accounts.dropped,
614
+ tools: connectors.tools,
615
+ creatorFirstPartyPolicy: openGeni.policy,
616
+ changed: toolsChanged || accounts.dropped.length > 0 || openGeni.policy !== null,
617
+ };
618
+ }
619
+
620
+ export function scheduledTaskPolicyDriftHasChanges(
621
+ drift: Omit<ScheduledTaskPolicyDrift, "canRefresh">,
622
+ ): boolean {
623
+ return (
624
+ drift.missingConnectors.length > 0 ||
625
+ drift.unavailableConnectors.length > 0 ||
626
+ drift.missingOpenGeniTools.length > 0 ||
627
+ drift.unavailableAccounts.length > 0
628
+ );
629
+ }
630
+
631
+ /**
632
+ * Attach `policyDrift` and `accountPlan` to each task this viewer can act on.
633
+ * A viewer who cannot act on a task receives it unchanged, so nobody else sees
634
+ * the owner's account labels. Both are advisory: a failure to compute them
635
+ * never fails the read, it only omits the fields.
636
+ */
637
+ export async function withScheduledTaskPolicyDrift(input: {
638
+ db: Database;
639
+ settings: Settings;
640
+ authorization: AccessGrantAuthorization;
641
+ tasks: ScheduledTask[];
642
+ permissionsRequiredByTools: FirstPartyToolPermissionRequirements;
643
+ onError?: (error: unknown) => void;
644
+ }): Promise<ScheduledTask[]> {
645
+ const { grant } = input.authorization;
646
+ const actionable = input.tasks.filter(
647
+ (task) => hasAgentAccess(task) && scheduledTaskAccessSource(task, grant) !== null,
648
+ );
649
+ if (actionable.length === 0) return input.tasks;
650
+ const workspaceId = grant.workspaceId;
651
+ let creatorPolicies: Map<string, ScheduledTaskCreatorPolicy>;
652
+ let workspaceSettings: unknown;
653
+ try {
654
+ [creatorPolicies, workspaceSettings] = await Promise.all([
655
+ listScheduledTaskCreatorPolicies(
656
+ input.db,
657
+ workspaceId,
658
+ actionable.map((task) => task.id),
659
+ ),
660
+ requireWorkspace(input.db, workspaceId).then((workspace) => workspace.settings),
661
+ ]);
662
+ } catch (error) {
663
+ input.onError?.(error);
664
+ return input.tasks;
665
+ }
666
+ const canRefresh =
667
+ hasPermission(grant.permissions, "scheduled_tasks:manage") &&
668
+ isScheduledTaskAccessRefreshHuman(input.authorization, workspaceId);
669
+ const cache = scheduledTaskAccessReadCache();
670
+ const decorations = new Map<
671
+ string,
672
+ { policyDrift: ScheduledTaskPolicyDrift | null; accountPlan: ScheduledTaskAccountPlan }
673
+ >();
674
+ for (const task of actionable) {
675
+ try {
676
+ const plan = await computeScheduledTaskAccessPlan({
677
+ db: input.db,
678
+ settings: input.settings,
679
+ grant,
680
+ task,
681
+ source: scheduledTaskAccessSource(task, grant)!,
682
+ creatorPolicy: creatorPolicies.get(task.id) ?? null,
683
+ permissionsRequiredByTools: input.permissionsRequiredByTools,
684
+ cache,
685
+ workspaceSettings,
686
+ });
687
+ decorations.set(task.id, {
688
+ policyDrift: scheduledTaskPolicyDriftHasChanges(plan.drift)
689
+ ? { ...plan.drift, canRefresh }
690
+ : null,
691
+ accountPlan: plan.accounts,
692
+ });
693
+ } catch (error) {
694
+ input.onError?.(error);
695
+ }
696
+ }
697
+ return input.tasks.map((task) => {
698
+ const decoration = decorations.get(task.id);
699
+ return decoration ? { ...task, ...decoration } : task;
700
+ });
701
+ }
702
+
703
+ /**
704
+ * The owner's explicit access refresh: re-save the task's connectors and (for
705
+ * an agent-created task) Opengeni tool policy with the calling person's
706
+ * current authority. Connector accounts are not part of it: unpinned
707
+ * connectors already follow the owner's current accounts, and pins change only
708
+ * when a person edits them (a pin whose connector is dropped goes with it).
709
+ * It is an ordinary owner edit whose
710
+ * content the server computes, so the owner check, Variable Set permission,
711
+ * model policy, target validation, personal-resource re-authorization and
712
+ * execution-digest update all run exactly as for a manual edit.
713
+ */
714
+ export async function refreshScheduledTaskAccess(input: {
715
+ settings: Settings;
716
+ db: Database;
717
+ objectStorage: ObjectStorageDependency;
718
+ authorization: AccessGrantAuthorization;
719
+ taskId: string;
720
+ request: RefreshScheduledTaskAccessRequest;
721
+ permissionsRequiredByTools: FirstPartyToolPermissionRequirements;
722
+ sessionAuthorization?: SessionAuthorizationPort | null | undefined;
723
+ authorizationSurface?: SessionAuthorizationSurface | undefined;
724
+ }): Promise<ScheduledTask> {
725
+ const { grant } = input.authorization;
726
+ requirePermission(grant, "scheduled_tasks:manage");
727
+ if (!isScheduledTaskAccessRefreshHuman(input.authorization, grant.workspaceId)) {
728
+ throw new HTTPException(403, {
729
+ message:
730
+ "Only a signed-in person can refresh a schedule's access. API keys, services and agents cannot.",
731
+ });
732
+ }
733
+ const existing = await requireVisibleScheduledTask(input.db, grant, input.taskId);
734
+ if (!hasAgentAccess(existing)) {
735
+ throw new HTTPException(422, {
736
+ message: "This schedule has no connectors or tools to refresh.",
737
+ });
738
+ }
739
+ if (existing.executionDigest !== input.request.executionDigest) {
740
+ throw new HTTPException(409, {
741
+ message: "This schedule changed. Reload it and review the refresh again.",
742
+ });
743
+ }
744
+ await assertScheduledTaskMutationOwner(input.db, grant, existing.id);
745
+ const source = scheduledTaskAccessSource(existing, grant);
746
+ if (!source) {
747
+ throw new HTTPException(403, {
748
+ message: "Only the schedule owner can refresh its access.",
749
+ });
750
+ }
751
+ const creatorPolicy =
752
+ (await listScheduledTaskCreatorPolicies(input.db, grant.workspaceId, [existing.id])).get(
753
+ existing.id,
754
+ ) ?? null;
755
+ const plan = await computeScheduledTaskAccessPlan({
756
+ db: input.db,
757
+ settings: input.settings,
758
+ grant,
759
+ task: existing,
760
+ source,
761
+ creatorPolicy,
762
+ permissionsRequiredByTools: input.permissionsRequiredByTools,
763
+ leaveOut: input.request.leaveOut,
764
+ });
765
+ if (!plan.changed) return existing;
766
+ const {
767
+ pinnedAccounts: _pinnedAccounts,
768
+ pinnedAccountsRequired: _pinnedAccountsRequired,
769
+ ...agentConfig
770
+ } = existing.agentConfig;
771
+ const update = await validatedScheduledTaskUpdate({
772
+ settings: input.settings,
773
+ db: input.db,
774
+ objectStorage: input.objectStorage,
775
+ grant,
776
+ authorization: input.authorization,
777
+ existing,
778
+ payload: { agentConfig: { ...agentConfig, tools: plan.tools } },
779
+ toolsProvided: true,
780
+ sessionAuthorization: input.sessionAuthorization,
781
+ authorizationSurface: input.authorizationSurface,
782
+ });
783
+ update.expectedExecutionDigest = existing.executionDigest;
784
+ if (plan.creatorFirstPartyPolicy) {
785
+ if (plan.creatorFirstPartyPolicy.firstPartyMcpPermissions.length === 0) {
786
+ throw new HTTPException(403, {
787
+ message:
788
+ "You hold none of the permissions this schedule's Opengeni tools need, so it cannot be refreshed with your access.",
789
+ });
790
+ }
791
+ update.creatorFirstPartyPolicy = plan.creatorFirstPartyPolicy;
792
+ }
793
+ try {
794
+ return await updateScheduledTaskForApi(input.db, grant, existing.id, update);
795
+ } catch (error) {
796
+ if (error instanceof ScheduledTaskHeadChangedError) {
797
+ throw new HTTPException(409, {
798
+ message: "This schedule changed. Reload it and review the refresh again.",
799
+ });
800
+ }
801
+ throw error;
802
+ }
803
+ }
804
+
805
+ async function connectorNames(
806
+ db: Database,
807
+ settings: Settings,
808
+ workspaceId: string,
809
+ subjectId: string,
810
+ ): Promise<Map<string, string>> {
811
+ try {
812
+ return registryNames(
813
+ await settingsWithEnabledCapabilityMcpServers(db, workspaceId, settings, { subjectId }),
814
+ );
815
+ } catch {
816
+ return registryNames(settings);
817
+ }
818
+ }
819
+
820
+ /** Attach `accessFailures` to runs of a task this viewer can act on. */
821
+ export async function withScheduledTaskRunAccessFailures<T extends ScheduledTaskRun>(input: {
822
+ db: Database;
823
+ settings: Settings;
824
+ grant: AccessGrant;
825
+ task: ScheduledTask;
826
+ runs: T[];
827
+ }): Promise<T[]> {
828
+ if (
829
+ input.runs.length === 0 ||
830
+ !hasAgentAccess(input.task) ||
831
+ scheduledTaskAccessSource(input.task, input.grant) === null
832
+ ) {
833
+ return input.runs;
834
+ }
835
+ const events = await listScheduledTaskRunAuthNeededEvents(
836
+ input.db,
837
+ input.grant.workspaceId,
838
+ input.runs.map((run) => run.id),
839
+ );
840
+ const byRun = new Map<string, typeof events>();
841
+ for (const event of events) {
842
+ byRun.set(event.runId, [...(byRun.get(event.runId) ?? []), event]);
843
+ }
844
+ const names =
845
+ events.length > 0
846
+ ? await connectorNames(
847
+ input.db,
848
+ input.settings,
849
+ input.grant.workspaceId,
850
+ input.grant.subjectId,
851
+ )
852
+ : new Map<string, string>();
853
+ return input.runs.map((run) => ({
854
+ ...run,
855
+ accessFailures: scheduledTaskRunAccessFailures(byRun.get(run.id) ?? [], names),
856
+ }));
857
+ }
858
+
859
+ const MACHINE_PRINCIPALS: ReadonlySet<string> = new Set(["api_key", "configured_key", "service"]);
860
+
861
+ /**
862
+ * The in-app notice: schedules whose latest run failed closed on connector
863
+ * access and that no later run has cleared. A person is told about the
864
+ * schedules they own. A task without an owner has nobody to notify, so it is
865
+ * listed only for an organization key, configured key or service that manages
866
+ * schedules (the callers that create such tasks), never for every member.
867
+ */
868
+ export function scheduledTaskAttentionScope(
869
+ grant: AccessGrant,
870
+ ): { ownerSubjectId: string | null; includeOwnerless: boolean } | null {
871
+ const source = personalConnectionDelegationSourceForGrant(grant);
872
+ const includeOwnerless =
873
+ MACHINE_PRINCIPALS.has(grant.principalKind ?? "") &&
874
+ hasPermission(grant.permissions, "scheduled_tasks:manage");
875
+ const ownerSubjectId = source.kind === "subject" ? source.subjectId : null;
876
+ if (ownerSubjectId === null && !includeOwnerless) return null;
877
+ return { ownerSubjectId, includeOwnerless };
878
+ }
879
+
880
+ /**
881
+ * Tasks among `tasks` with a person-pinned connector account that can no
882
+ * longer be used: the pin plan's `unavailableAccounts`, exactly what the
883
+ * drift reports, plus pins whose connector the workspace stopped setting up.
884
+ * A fresh occurrence runs without those connectors (or is
885
+ * skipped when the task requires its pinned accounts). Only the pin part of
886
+ * the plan is read, which does not depend on the creator policy. Advisory: a
887
+ * task whose plan cannot be computed is skipped.
888
+ */
889
+ export async function scheduledTasksWithUnavailableAccounts(input: {
890
+ db: Database;
891
+ settings: Settings;
892
+ grant: AccessGrant;
893
+ tasks: readonly ScheduledTask[];
894
+ onError?: ((error: unknown) => void) | undefined;
895
+ }): Promise<Array<{ task: ScheduledTask; unavailableAccounts: ScheduledTaskAccessConnector[] }>> {
896
+ const actionable = input.tasks.filter(
897
+ (task) =>
898
+ task.status === "active" &&
899
+ hasAgentAccess(task) &&
900
+ (task.agentConfig.pinnedAccounts?.length ?? 0) > 0 &&
901
+ scheduledTaskAccessSource(task, input.grant) !== null,
902
+ );
903
+ if (actionable.length === 0) return [];
904
+ let workspaceSettings: unknown;
905
+ try {
906
+ workspaceSettings = (await requireWorkspace(input.db, input.grant.workspaceId)).settings;
907
+ } catch (error) {
908
+ input.onError?.(error);
909
+ return [];
910
+ }
911
+ const cache = scheduledTaskAccessReadCache();
912
+ const blocked: Array<{
913
+ task: ScheduledTask;
914
+ unavailableAccounts: ScheduledTaskAccessConnector[];
915
+ }> = [];
916
+ for (const task of actionable) {
917
+ try {
918
+ const plan = await computeScheduledTaskAccessPlan({
919
+ db: input.db,
920
+ settings: input.settings,
921
+ grant: input.grant,
922
+ task,
923
+ source: scheduledTaskAccessSource(task, input.grant)!,
924
+ creatorPolicy: null,
925
+ permissionsRequiredByTools: () => [],
926
+ cache,
927
+ workspaceSettings,
928
+ });
929
+ // A pin whose connector is no longer set up is also one the run goes without.
930
+ const unavailableAccounts = uniqueById([
931
+ ...plan.drift.unavailableAccounts,
932
+ ...plan.droppedPins,
933
+ ]);
934
+ if (unavailableAccounts.length > 0) blocked.push({ task, unavailableAccounts });
935
+ } catch (error) {
936
+ input.onError?.(error);
937
+ }
938
+ }
939
+ return blocked;
940
+ }
941
+
942
+ /**
943
+ * The owner's in-app notice: schedules whose latest run failed closed on
944
+ * connector access and that no later run has cleared, plus schedules with a
945
+ * pinned account that can no longer be used (their runs go without that
946
+ * connector), named by connector. Tasks needing both appear once.
947
+ */
948
+ export async function listScheduledTaskAccessAttention(input: {
949
+ db: Database;
950
+ settings: Settings;
951
+ grant: AccessGrant;
952
+ onError?: ((error: unknown) => void) | undefined;
953
+ }): Promise<ScheduledTaskAccessAttention[]> {
954
+ const { grant } = input;
955
+ const scope = scheduledTaskAttentionScope(grant);
956
+ if (!scope) return [];
957
+ const query = {
958
+ // A caller without an entitled subject matches no owner: owners are people.
959
+ subjectId: scope.ownerSubjectId ?? "",
960
+ includeOwnerless: scope.includeOwnerless,
961
+ };
962
+ const [events, candidates, humanWaits] = await Promise.all([
963
+ listScheduledTaskAccessAttentionEvents(input.db, grant.workspaceId, {
964
+ ...query,
965
+ taskLimit: SCHEDULED_TASK_ACCESS_ATTENTION_MAX,
966
+ }),
967
+ listActiveScheduledTasksWithPinnedAccounts(input.db, grant.workspaceId, {
968
+ ...query,
969
+ limit: SCHEDULED_TASK_ACCESS_ATTENTION_MAX,
970
+ }),
971
+ listScheduledTaskHumanWaitAttention(input.db, grant.workspaceId, {
972
+ ...query,
973
+ taskLimit: SCHEDULED_TASK_ACCESS_ATTENTION_MAX,
974
+ }),
975
+ ]);
976
+ const blocked = await scheduledTasksWithUnavailableAccounts({
977
+ db: input.db,
978
+ settings: input.settings,
979
+ grant,
980
+ tasks: candidates,
981
+ onError: input.onError,
982
+ });
983
+ if (events.length === 0 && blocked.length === 0 && humanWaits.length === 0) return [];
984
+ const names =
985
+ events.length > 0
986
+ ? await connectorNames(input.db, input.settings, grant.workspaceId, grant.subjectId)
987
+ : new Map<string, string>();
988
+ const runs = new Map<
989
+ string,
990
+ {
991
+ taskName: string;
992
+ executionDigest: string;
993
+ runId: string;
994
+ firedAt: string;
995
+ events: typeof events;
996
+ }
997
+ >();
998
+ for (const event of events) {
999
+ const task = runs.get(event.taskId) ?? {
1000
+ taskName: event.taskName,
1001
+ executionDigest: event.taskExecutionDigest,
1002
+ runId: event.runId,
1003
+ firedAt: event.firedAt,
1004
+ events: [],
1005
+ };
1006
+ task.events.push(event);
1007
+ runs.set(event.taskId, task);
1008
+ }
1009
+ const attention = new Map<string, ScheduledTaskAccessAttention>();
1010
+ for (const [taskId, task] of runs) {
1011
+ const failures = scheduledTaskRunAccessFailures(task.events, names);
1012
+ if (failures.length === 0) continue;
1013
+ attention.set(taskId, {
1014
+ taskId,
1015
+ taskName: task.taskName,
1016
+ executionDigest: task.executionDigest,
1017
+ runId: task.runId,
1018
+ firedAt: task.firedAt,
1019
+ failures,
1020
+ unavailableAccounts: [],
1021
+ awaitingHuman: null,
1022
+ });
1023
+ }
1024
+ for (const { task, unavailableAccounts } of blocked) {
1025
+ const prior = attention.get(task.id);
1026
+ attention.set(
1027
+ task.id,
1028
+ prior
1029
+ ? { ...prior, executionDigest: task.executionDigest, unavailableAccounts }
1030
+ : {
1031
+ taskId: task.id,
1032
+ taskName: task.name,
1033
+ executionDigest: task.executionDigest,
1034
+ runId: null,
1035
+ firedAt: null,
1036
+ failures: [],
1037
+ unavailableAccounts,
1038
+ awaitingHuman: null,
1039
+ },
1040
+ );
1041
+ }
1042
+ // A latest run waiting on a person needs its owner (or, for an ownerless
1043
+ // schedule, whoever manages schedules) to answer before it can finish.
1044
+ for (const wait of humanWaits) {
1045
+ const awaitingHuman = { since: wait.since, expiresAt: wait.expiresAt };
1046
+ const prior = attention.get(wait.taskId);
1047
+ attention.set(
1048
+ wait.taskId,
1049
+ prior
1050
+ ? {
1051
+ ...prior,
1052
+ ...(prior.runId === null || prior.runId === wait.runId
1053
+ ? { runId: wait.runId, firedAt: wait.firedAt }
1054
+ : {}),
1055
+ awaitingHuman,
1056
+ }
1057
+ : {
1058
+ taskId: wait.taskId,
1059
+ taskName: wait.taskName,
1060
+ executionDigest: wait.taskExecutionDigest,
1061
+ runId: wait.runId,
1062
+ firedAt: wait.firedAt,
1063
+ failures: [],
1064
+ unavailableAccounts: [],
1065
+ awaitingHuman,
1066
+ },
1067
+ );
1068
+ }
1069
+ return orderScheduledTaskAccessAttention([...attention.values()]).slice(
1070
+ 0,
1071
+ SCHEDULED_TASK_ACCESS_ATTENTION_MAX,
1072
+ );
1073
+ }
1074
+
1075
+ /**
1076
+ * Schedules with an unusable pinned account come first, then the most recent
1077
+ * failed runs. Ties keep a stable order by task id.
1078
+ */
1079
+ export function orderScheduledTaskAccessAttention(
1080
+ items: readonly ScheduledTaskAccessAttention[],
1081
+ ): ScheduledTaskAccessAttention[] {
1082
+ return [...items].sort((left, right) => {
1083
+ const blocked =
1084
+ Number(right.unavailableAccounts.length > 0) - Number(left.unavailableAccounts.length > 0);
1085
+ if (blocked !== 0) return blocked;
1086
+ const fired = (right.firedAt ?? "").localeCompare(left.firedAt ?? "");
1087
+ return fired !== 0 ? fired : left.taskId.localeCompare(right.taskId);
1088
+ });
1089
+ }