@opengeni/core 0.28.1 → 1.0.0

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