@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
@@ -1,25 +1,45 @@
1
1
  import {
2
+ getSessionTurnClaudeProviderAccountAuthoritySnapshot,
3
+ getScheduledTaskClaudeProviderAccountAuthoritySnapshot,
4
+ } from "@opengeni/db";
5
+ import {
6
+ SCHEDULED_SLACK_BOT_POSTING_TOOLS,
2
7
  scheduledTaskKnowledgeSource,
3
8
  requireScheduledTaskKnowledgeSource,
9
+ scheduledOccurrencePayloadUtf8Bytes,
10
+ mergeResourceRefs,
11
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_MAX_BYTES,
12
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_INGRESS_HEADROOM_BYTES,
4
13
  } from "@opengeni/contracts";
5
14
  import {
6
15
  allowedFirstPartyMcpToolsForSession,
16
+ resolveSessionFirstPartyMcpTools,
7
17
  resolveFirstPartyMcpToolPolicy,
18
+ type FirstPartyMcpToolPolicySettings,
8
19
  type Settings,
9
20
  } from "@opengeni/config";
21
+ import { WORKSPACE_XAI_PROVIDER_ACCOUNT_AUTHORITY_SNAPSHOT_V1 } from "@opengeni/contracts";
22
+ import type { VaultPolicy } from "@opengeni/contracts";
23
+ import {
24
+ ATLASSIAN_NATIVE_RETIRED_MESSAGE,
25
+ isRetiredNativeAtlassianSource,
26
+ isRetiredNativeAtlassianTask,
27
+ } from "@opengeni/contracts/atlassian-native-retirement";
10
28
  import type {
11
29
  AccessGrant,
12
- McpPersonalConnectionDelegation,
13
30
  KnowledgeSourceSyncAction,
31
+ McpConnectionAccountSelection,
14
32
  Permission,
15
33
  ScheduledTask,
16
34
  ScheduledTaskAgentConfig,
17
35
  Session,
36
+ ToolRef,
18
37
  SessionAuthorizationPort,
19
38
  SessionAuthorizationSurface,
20
39
  CreateScheduledTaskRequest as CreateScheduledTaskPayload,
21
40
  UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
22
41
  XaiProviderAccountAuthoritySnapshotV1,
42
+ ClaudeProviderAccountAuthoritySnapshotV1,
23
43
  } from "@opengeni/contracts";
24
44
  import {
25
45
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
@@ -29,9 +49,11 @@ import {
29
49
  SessionAgentAccess,
30
50
  SessionScopeSubjectId,
31
51
  SessionMemoryScope,
52
+ readTurnExecutionPolicyV1,
32
53
  } from "@opengeni/contracts";
33
54
  import {
34
55
  createScheduledTask,
56
+ scheduledTaskMutationOwnerMatches,
35
57
  getAgentLearningSettings,
36
58
  saveAgentLearningSettings,
37
59
  type KnowledgeContext,
@@ -45,33 +67,49 @@ import {
45
67
  getRig,
46
68
  getScheduledTask,
47
69
  getScheduledTaskIncludingDeletedForUpdate,
48
- getScheduledTaskPersonalConnectionDelegations,
49
70
  getScheduledTaskXaiProviderAccountAuthoritySnapshot,
50
71
  getSandbox,
51
72
  getSessionTurnXaiProviderAccountAuthoritySnapshot,
52
73
  getSession,
74
+ getSessionTurnForAttempt,
53
75
  getSessionAuthorityProjection,
76
+ getWorkspaceDefaultRigId,
54
77
  withSessionRlsActorContext,
55
78
  nestedPostgresSqlState,
56
79
  requireWorkspace,
57
80
  scopedKnowledgeScopeKey,
58
81
  updateScheduledTask,
82
+ ScheduledTaskHeadChangedError,
59
83
  withWorkspaceSubjectRls,
60
- resolveXaiProviderAccountAuthoritySnapshotForAcceptance,
61
84
  type Database,
62
85
  type ScheduledTaskCreatorPolicy,
86
+ type SessionCommandActor,
63
87
  type TemporalScheduleCleanupClaim,
64
88
  type UpdateScheduledTaskInput,
65
89
  } from "@opengeni/db";
66
90
  import { HTTPException } from "hono/http-exception";
91
+ import { GOOGLE_DRIVE_PUBLICATION_SERVER_ID } from "@opengeni/contracts/google-drive";
92
+ import { PERSONAL_GITHUB_CONNECTION_SURFACE_ID } from "@opengeni/contracts/personal-github";
93
+ import {
94
+ currentSiteSessionOrigin,
95
+ SITE_ORIGIN_METADATA_KEY,
96
+ siteOriginsMatch,
97
+ siteSessionOriginFromMetadata,
98
+ withSiteOriginMetadata,
99
+ } from "../site-session-origin";
100
+ import { assertNotSiteClampedAgent } from "../site-agent-clamp";
101
+ import { personalGitHubRepositoryResources } from "./resources";
67
102
  import { knowledgeContextForAccess } from "./knowledge";
68
103
  import { fileOwnerContextForAccess, fileOwnerContextForAgent } from "./file-owner";
69
104
  import { isDeepStrictEqual } from "node:util";
70
- import { hasPermission, requirePermission, type AccessGrantAuthorization } from "../access";
71
105
  import {
72
- prepareHostMcpTaskAdmission,
73
- prepareInheritedHostMcpTaskAdmission,
74
- } from "./host-mcp-task-admission";
106
+ hasPermission,
107
+ requireExplicitPermissionDelegation,
108
+ isDeveloperSetupAuthorization,
109
+ isDeveloperSetupGrant,
110
+ requirePermission,
111
+ type AccessGrantAuthorization,
112
+ } from "../access";
75
113
  import {
76
114
  requireSessionAuthorization,
77
115
  SessionAuthorizationDeniedError,
@@ -79,32 +117,46 @@ import {
79
117
  } from "../session-authorization";
80
118
  import type { SessionWorkflowClient } from "../dependencies";
81
119
  import type { ObjectStorageDependency } from "../dependencies";
120
+ import {
121
+ resolveScheduledTaskVisibility,
122
+ scheduledTaskViewerForGrant,
123
+ } from "./scheduled-task-audience";
82
124
  import { lockActiveCustomModelForAdmission, workspaceCustomModelReference } from "../model-catalog";
83
125
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
126
+ import {
127
+ resolveSessionToolPolicy,
128
+ workspaceSessionToolPolicyDefaultServerIds,
129
+ } from "./session-tool-policy";
84
130
  import { prepareExternalLinkTaskAdmission } from "../application/external-link-work-admission";
85
- import { validateVariableSetAttachment } from "./environments";
131
+ import { variableSetsRemovedError } from "./encryption-key";
86
132
  import {
87
- freezePersonalConnectionDelegations,
133
+ freezeConnectionAccounts,
88
134
  personalConnectionDelegationSourceForGrant,
89
135
  } from "./personal-connection-delegations";
90
136
  import {
91
137
  assertWorkspaceModelPolicyAllows,
92
138
  canonicalConfiguredModel,
93
139
  creationInitiatorForGrant,
140
+ modelUnavailableHttpException,
141
+ settingsWithSessionMcpServerMetadata,
94
142
  } from "./sessions";
95
143
  import {
96
144
  hasReservedOpenGeniSlackBotSessionMetadata,
97
- scheduledSlackBotConnectionId,
98
145
  validateOpenGeniSlackBotConnectionSelection,
146
+ validateScheduledTaskSlackChannel,
147
+ type ScheduledTaskSlackChannelVerifier,
99
148
  } from "./slack-bot";
100
149
  import {
101
150
  normalizeResources,
102
- personalGitHubRepositoryResources,
103
151
  validateFileResources,
104
152
  validateGitHubRepositorySelection,
105
153
  validateToolRefs,
106
- withWorkspaceDefaultMcpTools,
154
+ scheduledTaskRunTools,
107
155
  } from "./resources";
156
+ import {
157
+ recordScheduledTaskAccountChoiceChanges,
158
+ scheduledTaskAccountChoiceChanges,
159
+ } from "./scheduled-task-account-audit";
108
160
 
109
161
  /**
110
162
  * Whether a raw scheduled-task payload explicitly set agentConfig.tools.
@@ -114,6 +166,14 @@ import {
114
166
  * (enabled capability MCP servers)", an explicit list (even empty) is taken
115
167
  * verbatim.
116
168
  */
169
+
170
+ /** Vault IDs are compared as lowercase text in SQL. */
171
+ function scheduledVaultPolicy(policy: VaultPolicy): VaultPolicy {
172
+ return policy.mode === "all"
173
+ ? { mode: "all" }
174
+ : { mode: "selected", vaultIds: [...new Set(policy.vaultIds.map((id) => id.toLowerCase()))] };
175
+ }
176
+
117
177
  export function scheduledTaskToolsProvided(rawPayload: unknown): boolean {
118
178
  if (!rawPayload || typeof rawPayload !== "object") {
119
179
  return false;
@@ -126,11 +186,31 @@ export function scheduledTaskToolsProvided(rawPayload: unknown): boolean {
126
186
  );
127
187
  }
128
188
 
189
+ /**
190
+ * Only the message survives. The destination chat owns execution: its
191
+ * connectors, and the owner's current accounts resolved at each run. An
192
+ * existing-chat schedule never saves account choices of its own.
193
+ */
194
+ function scheduledSessionMessageConfig(config: ScheduledTaskAgentConfig): ScheduledTaskAgentConfig {
195
+ return {
196
+ prompt: config.prompt,
197
+ resources: config.resources,
198
+ tools: [],
199
+ metadata: config.metadata,
200
+ ...(config.approvalTimeoutSeconds !== undefined
201
+ ? { approvalTimeoutSeconds: config.approvalTimeoutSeconds }
202
+ : {}),
203
+ };
204
+ }
205
+
129
206
  function workspaceCustomModelCommitGuard(input: {
130
207
  settings: Settings;
131
208
  accountId: string;
132
209
  workspaceId: string;
133
210
  modelId: string;
211
+ claudeAuthoritySnapshot?: ClaudeProviderAccountAuthoritySnapshotV1;
212
+ /** The task an update re-admits (Claude's core route reads its accepted authority). */
213
+ scheduledTaskId?: string;
134
214
  }): ((tx: Database) => Promise<void>) | undefined {
135
215
  const reference = workspaceCustomModelReference(input.settings, input.modelId);
136
216
  if (!reference) return undefined;
@@ -139,35 +219,142 @@ function workspaceCustomModelCommitGuard(input: {
139
219
  accountId: input.accountId,
140
220
  workspaceId: input.workspaceId,
141
221
  reference,
222
+ claudeAuthority: input.claudeAuthoritySnapshot
223
+ ? {
224
+ authoritySnapshot: input.claudeAuthoritySnapshot,
225
+ ...(input.scheduledTaskId ? { scheduledTaskId: input.scheduledTaskId } : {}),
226
+ }
227
+ : null,
142
228
  });
143
229
  if (!active) {
144
- throw new HTTPException(422, {
145
- message: `model is not available: ${input.modelId}`,
146
- });
230
+ throw modelUnavailableHttpException(input.modelId);
147
231
  }
148
232
  };
149
233
  }
150
234
 
235
+ /** Resolve the target's current tool policy before choosing its owner's accounts. */
236
+ export async function scheduledConnectionTools(
237
+ db: Database,
238
+ workspaceId: string,
239
+ settings: Settings,
240
+ target: Session | null,
241
+ taskTools: ToolRef[],
242
+ subjectId?: string,
243
+ ): Promise<ToolRef[]> {
244
+ if (!target) return [...taskTools, { kind: "mcp", id: "opengeni" }];
245
+ return resolveSessionToolPolicy({
246
+ toolPolicy: target.toolPolicy,
247
+ sessionTools: target.tools,
248
+ availableMcpServerIds: [
249
+ "opengeni",
250
+ ...settings.mcpServers.map(({ id }) => id),
251
+ ...target.mcpServers.map(({ id }) => id),
252
+ ],
253
+ defaultMcpServerIds: await workspaceSessionToolPolicyDefaultServerIds(
254
+ db,
255
+ workspaceId,
256
+ settings,
257
+ subjectId,
258
+ ),
259
+ }).toolRefs;
260
+ }
261
+
151
262
  export function scheduledConnectionSurfaceEligibility(
152
263
  settings: Settings,
153
- target: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions"> | null,
264
+ target:
265
+ | (Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions"> &
266
+ Partial<Pick<Session, "toolPolicy" | "agent">>)
267
+ | null,
268
+ workspaceSettings: unknown = {},
154
269
  ): { googleDrivePublicationEnabled: boolean; atlassianEnabled: boolean } {
155
- const tools = target?.firstPartyMcpTools ?? resolveFirstPartyMcpToolPolicy(settings).default;
156
- const permissions = target?.firstPartyMcpPermissions?.length
157
- ? target.firstPartyMcpPermissions
158
- : DEFAULT_FIRST_PARTY_MCP_PERMISSIONS;
270
+ const tools = target?.toolPolicy
271
+ ? resolveSessionFirstPartyMcpTools(
272
+ settings,
273
+ { ...target, toolPolicy: target.toolPolicy },
274
+ workspaceSettings,
275
+ )
276
+ : allowedFirstPartyMcpToolsForSession(settings, target?.firstPartyMcpTools);
277
+ const permissions = target?.firstPartyMcpPermissions ?? DEFAULT_FIRST_PARTY_MCP_PERMISSIONS;
159
278
  return {
160
279
  googleDrivePublicationEnabled:
161
280
  tools.includes("editable_artifact_export") &&
162
281
  tools.includes("editable_artifact_export_status") &&
163
282
  permissions.includes("artifacts:read") &&
164
283
  permissions.includes("artifacts:publish"),
165
- atlassianEnabled:
166
- tools.some((tool) => tool.startsWith("atlassian_")) &&
167
- permissions.includes("connections:read"),
284
+ atlassianEnabled: false,
168
285
  };
169
286
  }
170
287
 
288
+ /**
289
+ * An organization/workspace API key or the deployment's configured key is a
290
+ * machine principal. It is never a person, so it can never be a schedule's
291
+ * execution owner (a human revision authorizer): its schedules are ownerless
292
+ * and run under service authority, exactly like a delegated service's.
293
+ */
294
+ function isScheduledTaskMachinePrincipal(grant: AccessGrant): boolean {
295
+ return grant.principalKind === "api_key" || grant.principalKind === "configured_key";
296
+ }
297
+
298
+ /**
299
+ * Only a person pins connector accounts: an authenticated human acting
300
+ * directly, through the schedule editor or their own API call. Agents, API
301
+ * keys, services and delegated bearers never pin; schedules they save follow
302
+ * the owner's current accounts at each run.
303
+ */
304
+ function requireScheduledTaskPinAuthor(grant: AccessGrant): void {
305
+ if (
306
+ isScheduledTaskMachinePrincipal(grant) ||
307
+ personalConnectionDelegationSourceForGrant(grant).kind !== "subject"
308
+ ) {
309
+ throw new HTTPException(403, {
310
+ message:
311
+ "Only a person can pin connector accounts on a schedule. Schedules saved by agents, API keys or services use the owner's current accounts.",
312
+ });
313
+ }
314
+ }
315
+
316
+ /** Stable, duplicate-free pin order so equal choices store identically. */
317
+ function normalizedScheduledTaskPins(
318
+ pins: readonly McpConnectionAccountSelection[],
319
+ ): McpConnectionAccountSelection[] {
320
+ const unique = new Map(
321
+ pins.map((pin) => [`${pin.serverId}\u0000${pin.connectionId}`, pin] as const),
322
+ );
323
+ return [...unique.entries()]
324
+ .sort(([left], [right]) => left.localeCompare(right))
325
+ .map(([, pin]) => ({ serverId: pin.serverId, connectionId: pin.connectionId }));
326
+ }
327
+
328
+ /** Store pins and the do-not-run switch only when they say something. */
329
+ function withScheduledTaskPins(
330
+ config: ScheduledTaskAgentConfig,
331
+ pins: readonly McpConnectionAccountSelection[] | undefined,
332
+ required: boolean,
333
+ ): ScheduledTaskAgentConfig {
334
+ const {
335
+ pinnedAccounts: _pinnedAccounts,
336
+ pinnedAccountsRequired: _pinnedAccountsRequired,
337
+ ...rest
338
+ } = config;
339
+ const normalized = normalizedScheduledTaskPins(pins ?? []);
340
+ return normalized.length > 0
341
+ ? { ...rest, pinnedAccounts: normalized, ...(required ? { pinnedAccountsRequired: true } : {}) }
342
+ : rest;
343
+ }
344
+
345
+ /** Service attribution cannot become a personal schedule execution owner. */
346
+ function scheduledTaskInitiatorForGrant(grant: AccessGrant) {
347
+ const creator = creationInitiatorForGrant(grant);
348
+ if (
349
+ creator.initiator?.kind === "subject" &&
350
+ (personalConnectionDelegationSourceForGrant(grant).kind === "none" ||
351
+ isScheduledTaskMachinePrincipal(grant))
352
+ ) {
353
+ return { ...creator, initiator: { ...creator.initiator, kind: "service" as const } };
354
+ }
355
+ return creator;
356
+ }
357
+
171
358
  export async function createValidatedScheduledTask(input: {
172
359
  settings: Settings;
173
360
  db: Database;
@@ -179,12 +366,43 @@ export async function createValidatedScheduledTask(input: {
179
366
  // scheduledTaskToolsProvided). Absent tools get the workspace's enabled
180
367
  // capability MCP servers, mirroring session creation.
181
368
  toolsProvided?: boolean;
182
- // Set for pack-installation-inherited attachments that were already
183
- // authorized with variable-sets:use when the pack was enabled.
184
- variableSetPreauthorized?: boolean;
185
369
  sessionAuthorization?: SessionAuthorizationPort | null | undefined;
186
370
  authorizationSurface?: SessionAuthorizationSurface | undefined;
371
+ /** Proves the bot may post in a newly chosen task Slack channel. */
372
+ verifySlackChannel?: ScheduledTaskSlackChannelVerifier | undefined;
187
373
  }): Promise<ScheduledTask> {
374
+ // An agent started from a Site page acts with the page's reach: no schedules.
375
+ await assertNotSiteClampedAgent(input.db, input.grant, "schedules");
376
+ // Variable Sets were replaced by vaults (RM-1).
377
+ if (input.payload.variableSetId) throw variableSetsRemovedError("variableSetId");
378
+ if (input.payload.runMode === "existing_session") {
379
+ const target = await validateScheduledTaskTarget({
380
+ ...input,
381
+ targetSessionId: input.payload.targetSessionId,
382
+ runMode: "existing_session",
383
+ variableSetId: input.payload.variableSetId,
384
+ rigId: input.payload.rigId,
385
+ agentConfig: input.payload.agentConfig,
386
+ });
387
+ if (input.payload.agentConfig.knowledgeSource)
388
+ throw new HTTPException(422, {
389
+ message: "Source ingestion requires a separate scheduled agent",
390
+ });
391
+ if (input.payload.rigId && input.payload.rigId !== target?.rigId)
392
+ throw new HTTPException(422, {
393
+ message:
394
+ "Existing-chat schedules use the chat's environment. Change the chat's attachments separately.",
395
+ });
396
+ input = {
397
+ ...input,
398
+ payload: {
399
+ ...input.payload,
400
+ agentConfig: scheduledSessionMessageConfig(input.payload.agentConfig),
401
+ variableSetId: null,
402
+ rigId: null,
403
+ },
404
+ };
405
+ }
188
406
  const learning = "agentLearning" in input.payload ? input.payload.agentLearning : undefined;
189
407
  const learningContext =
190
408
  learning && input.authorization
@@ -201,13 +419,8 @@ export async function createValidatedScheduledTask(input: {
201
419
  }
202
420
  if (learningContext?.actor.kind === "human" && learning)
203
421
  learningContext.actor.settingsScopes = [learning.scope];
204
- // API parsing fills this default, but pack installers and older internal
205
- // callers can still invoke the shared validator with the pre-action shape.
422
+ // Internal callers can omit the action; agent turns are the default.
206
423
  const action = input.payload.action ?? ({ kind: "agent_turn" } as const);
207
- const hostSelections =
208
- "selectedHostMcpDelegations" in input.payload
209
- ? input.payload.selectedHostMcpDelegations
210
- : undefined;
211
424
  const knowledgeAction = input.payload.agentConfig.knowledgeSource ?? null;
212
425
  if (knowledgeAction) {
213
426
  await validateKnowledgeSourceSyncAction({
@@ -216,10 +429,45 @@ export async function createValidatedScheduledTask(input: {
216
429
  action: knowledgeAction,
217
430
  });
218
431
  }
219
- const agentConfig: ScheduledTaskAgentConfig = await validateScheduledTaskAgentConfig({
432
+ const validatedAgentConfig: ScheduledTaskAgentConfig = await validateScheduledTaskAgentConfig({
220
433
  ...input,
221
434
  workspaceId: input.grant.workspaceId,
222
435
  });
436
+ // A schedule created from a Site page records which Site. Each run's
437
+ // session copies this metadata, so runs record it too and act with the
438
+ // page's reach (the Site agent clamp). Callers never supply it themselves.
439
+ const agentConfig: ScheduledTaskAgentConfig = {
440
+ ...validatedAgentConfig,
441
+ metadata: withSiteOriginMetadata(
442
+ validatedAgentConfig.metadata ?? {},
443
+ currentSiteSessionOrigin(),
444
+ ),
445
+ };
446
+ // Connector accounts are never saved from what the schedule would resolve
447
+ // now. Only accounts a person explicitly pins are stored, and only on a
448
+ // schedule that starts its own session; everything else follows the owner's
449
+ // current accounts at each run.
450
+ const requestedPins =
451
+ "pinnedAccounts" in input.payload ? input.payload.pinnedAccounts : undefined;
452
+ const requestedPinsRequired =
453
+ "pinnedAccountsRequired" in input.payload && input.payload.pinnedAccountsRequired === true;
454
+ if ((requestedPins?.length ?? 0) > 0 || requestedPinsRequired) {
455
+ if (input.payload.runMode === "existing_session" || knowledgeAction)
456
+ throw new HTTPException(422, {
457
+ message:
458
+ "Pin connector accounts only on a schedule that starts its own session. An existing-chat schedule uses that chat's connectors with the owner's current accounts.",
459
+ });
460
+ requireScheduledTaskPinAuthor(input.grant);
461
+ }
462
+ await validateScheduledTaskSlackChannel({
463
+ grant: input.grant,
464
+ authorization: input.authorization,
465
+ previous: null,
466
+ next: agentConfig,
467
+ runMode: input.payload.runMode,
468
+ reusableSessionCanPost: null,
469
+ verifySlackChannel: input.verifySlackChannel,
470
+ });
223
471
  if (knowledgeAction && input.payload.overlapPolicy === "allow_concurrent")
224
472
  throw new HTTPException(422, { message: "Source tasks require skip or buffer_one overlap" });
225
473
  const id = crypto.randomUUID();
@@ -233,6 +481,7 @@ export async function createValidatedScheduledTask(input: {
233
481
  runMode: input.payload.runMode,
234
482
  variableSetId: input.payload.variableSetId,
235
483
  rigId: input.payload.rigId,
484
+ // An omitted Sandbox Environment adopts the target session's own one below.
236
485
  agentConfig,
237
486
  });
238
487
  if (
@@ -263,17 +512,12 @@ export async function createValidatedScheduledTask(input: {
263
512
  grant: input.grant,
264
513
  runMode: input.payload.runMode,
265
514
  agentConfig,
515
+ audience:
516
+ "visibility" in input.payload && input.payload.visibility === "private"
517
+ ? "user_private"
518
+ : "workspace_shared",
266
519
  });
267
520
  }
268
- if (input.payload.variableSetId) {
269
- await validateVariableSetAttachment(
270
- { settings: input.settings, db: input.db },
271
- input.grant,
272
- input.grant.workspaceId,
273
- input.payload.variableSetId,
274
- { preauthorized: input.variableSetPreauthorized ?? false },
275
- );
276
- }
277
521
  // The rig is stored on the task and resolved to its ACTIVE version per fire
278
522
  // (at dispatch), so validate only that the id names a rig in the workspace —
279
523
  // NOT that it has an active version now (that is a fire-time concern). RLS
@@ -289,6 +533,17 @@ export async function createValidatedScheduledTask(input: {
289
533
  input.payload.rigId,
290
534
  );
291
535
  }
536
+ const rigId = knowledgeAction
537
+ ? (input.payload.rigId ?? null)
538
+ : await resolveScheduledTaskCreateRigId({
539
+ settings: input.settings,
540
+ db: input.db,
541
+ grant: input.grant,
542
+ requestedRigId: input.payload.rigId,
543
+ runMode: input.payload.runMode,
544
+ target,
545
+ agentConfig,
546
+ });
292
547
  const runtimeSettings = knowledgeAction
293
548
  ? null
294
549
  : await settingsWithEnabledCapabilityMcpServers(
@@ -297,33 +552,63 @@ export async function createValidatedScheduledTask(input: {
297
552
  input.settings,
298
553
  { subjectId: input.grant.subjectId },
299
554
  );
300
- const personalConnectionDelegations =
301
- knowledgeAction || !runtimeSettings
302
- ? []
303
- : await freezePersonalConnectionDelegations({
304
- db: input.db,
305
- workspaceId: input.grant.workspaceId,
306
- settings: runtimeSettings,
307
- tools: [...agentConfig.tools, { kind: "mcp", id: "opengeni" }],
308
- resources: agentConfig.resources,
309
- source: personalConnectionDelegationSourceForGrant(input.grant),
310
- authoritySelections: input.payload.connectionAuthorities,
311
- rejectUnselectedActivatedConnections: true,
312
- ...scheduledConnectionSurfaceEligibility(runtimeSettings, target),
313
- });
314
- const creationInitiator = creationInitiatorForGrant(input.grant);
555
+ // Existing-session runs use the target's persisted MCP configuration, with
556
+ // session definitions taking precedence over deployment servers of the same
557
+ // ID. Metadata selects the destination; captured grants still supply authority.
558
+ const effectiveRuntimeSettings =
559
+ runtimeSettings && target
560
+ ? settingsWithSessionMcpServerMetadata(runtimeSettings, target.mcpServers)
561
+ : runtimeSettings;
562
+ // Prove the creator may use the schedule's connectors and repositories, and
563
+ // that every pinned account resolves for the owner. Nothing it resolves is
564
+ // saved: each run resolves the owner's current accounts again, and a
565
+ // schedule that follows the workspace defaults resolves them again too.
566
+ if (!knowledgeAction && effectiveRuntimeSettings) {
567
+ const creationWorkspaceSettings = (await requireWorkspace(input.db, input.grant.workspaceId))
568
+ .settings;
569
+ await freezeConnectionAccounts({
570
+ db: input.db,
571
+ accountId: input.grant.accountId,
572
+ workspaceId: input.grant.workspaceId,
573
+ settings: effectiveRuntimeSettings,
574
+ tools: await scheduledConnectionTools(
575
+ input.db,
576
+ input.grant.workspaceId,
577
+ effectiveRuntimeSettings,
578
+ target,
579
+ scheduledTaskRunTools({
580
+ agentConfig,
581
+ settings: input.settings,
582
+ runtimeSettings: effectiveRuntimeSettings,
583
+ workspaceDefaults: resolveWorkspaceSessionToolDefaults(creationWorkspaceSettings),
584
+ }),
585
+ input.grant.subjectId,
586
+ ),
587
+ resources: mergeResourceRefs(target?.resources ?? [], agentConfig.resources),
588
+ source: personalConnectionDelegationSourceForGrant(input.grant),
589
+ authoritySelections: requestedPins ?? [],
590
+ ...scheduledConnectionSurfaceEligibility(
591
+ effectiveRuntimeSettings,
592
+ target,
593
+ creationWorkspaceSettings,
594
+ ),
595
+ });
596
+ }
597
+ const storedAgentConfig = knowledgeAction
598
+ ? agentConfig
599
+ : withScheduledTaskPins(agentConfig, requestedPins, requestedPinsRequired);
600
+ const creationInitiator = scheduledTaskInitiatorForGrant(input.grant);
315
601
  const captureLinkAuthority = prepareExternalLinkTaskAdmission(
316
602
  input.authorization,
317
603
  creationInitiator.actor,
318
604
  );
319
- const creatorPolicy = creationInitiator.actor
320
- ? await frozenScheduledTaskCreatorPolicy({
321
- db: input.db,
322
- settings: input.settings,
323
- grant: input.grant,
324
- sessionId: creationInitiator.actor.sessionId,
325
- })
326
- : null;
605
+ const creatorPolicy = await frozenScheduledTaskCreatorPolicy({
606
+ db: input.db,
607
+ settings: input.settings,
608
+ grant: input.grant,
609
+ authorization: input.authorization,
610
+ actor: creationInitiator.actor ?? null,
611
+ });
327
612
  const xaiProviderAccountAuthoritySnapshot: XaiProviderAccountAuthoritySnapshotV1 =
328
613
  creationInitiator.actor
329
614
  ? await getSessionTurnXaiProviderAccountAuthoritySnapshot(
@@ -332,10 +617,19 @@ export async function createValidatedScheduledTask(input: {
332
617
  creationInitiator.actor.sessionId,
333
618
  creationInitiator.actor.turnId,
334
619
  )
335
- : await resolveXaiProviderAccountAuthoritySnapshotForAcceptance(input.db, {
336
- workspaceId: input.grant.workspaceId,
337
- subjectId: input.grant.subjectId,
338
- });
620
+ : // SuperGrok's v1 value is never computed from live state after its
621
+ // cutover: new work writes the column default (design 5.3, "v1
622
+ // columns after a provider's cutover").
623
+ WORKSPACE_XAI_PROVIDER_ACCOUNT_AUTHORITY_SNAPSHOT_V1;
624
+ const claudeProviderAccountAuthoritySnapshot: ClaudeProviderAccountAuthoritySnapshotV1 =
625
+ creationInitiator.actor
626
+ ? await getSessionTurnClaudeProviderAccountAuthoritySnapshot(
627
+ input.db,
628
+ input.grant.workspaceId,
629
+ creationInitiator.actor.sessionId,
630
+ creationInitiator.actor.turnId,
631
+ )
632
+ : { version: 1 as const, scope: "workspace" as const };
339
633
  const beforeCreateCommit =
340
634
  input.payload.runMode !== "existing_session"
341
635
  ? workspaceCustomModelCommitGuard({
@@ -343,8 +637,28 @@ export async function createValidatedScheduledTask(input: {
343
637
  accountId: input.grant.accountId,
344
638
  workspaceId: input.grant.workspaceId,
345
639
  modelId: agentConfig.model ?? input.settings.openaiModel,
640
+ claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot,
346
641
  })
347
642
  : undefined;
643
+ const visibility = await resolveScheduledTaskVisibility({
644
+ db: input.db,
645
+ settings: input.settings,
646
+ grant: input.grant,
647
+ requested: "visibility" in input.payload ? input.payload.visibility : undefined,
648
+ current: null,
649
+ targetSessionId: target?.id ?? null,
650
+ personalKnowledge: agentConfig.knowledgeSource?.destination.kind === "personal",
651
+ ownerSubjectId:
652
+ action.kind === "agent_turn"
653
+ ? (await scheduledTaskViewerForGrant(input.db, input.grant, input.settings)).subjectId
654
+ : null,
655
+ });
656
+ await requirePersonalMachineAudience({
657
+ db: input.db,
658
+ grant: input.grant,
659
+ agentConfig,
660
+ audience: visibility,
661
+ });
348
662
  return await withScheduledTaskAuthorityWriteErrors(() =>
349
663
  input.db.transaction(async (transaction) => {
350
664
  const task = await createScheduledTask(transaction, {
@@ -353,44 +667,36 @@ export async function createValidatedScheduledTask(input: {
353
667
  workspaceId: input.grant.workspaceId,
354
668
  name: trimmedScheduledTaskName(input.payload.name),
355
669
  status: input.payload.status,
670
+ visibility,
356
671
  schedule: input.payload.schedule,
357
672
  temporalScheduleId: scheduledTaskTemporalScheduleId(id),
358
673
  runMode: input.payload.runMode,
359
674
  overlapPolicy: input.payload.overlapPolicy,
360
675
  action,
361
- agentConfig,
676
+ agentConfig: storedAgentConfig,
362
677
  ...(creationInitiator.initiator ? { createdBy: creationInitiator.initiator } : {}),
363
678
  ...(creationInitiator.context ? { createdByContext: creationInitiator.context } : {}),
364
679
  createdByActor: creationInitiator.actor ?? null,
365
680
  ...(captureLinkAuthority ? { captureLinkAuthority } : {}),
366
- personalConnectionDelegations,
367
681
  xaiProviderAccountAuthoritySnapshot,
682
+ claudeProviderAccountAuthoritySnapshot,
368
683
  creatorPolicy,
369
684
  targetSessionId: target?.id ?? null,
370
685
  variableSetId: input.payload.variableSetId ?? null,
371
- rigId: input.payload.rigId ?? null,
686
+ ...("vaultPolicy" in input.payload && input.payload.vaultPolicy
687
+ ? { vaultPolicy: scheduledVaultPolicy(input.payload.vaultPolicy) }
688
+ : {}),
689
+ rigId,
372
690
  metadata: input.payload.metadata,
373
- ...(hostSelections?.length && runtimeSettings
374
- ? {
375
- captureHostAuthority: prepareHostMcpTaskAdmission({
376
- settings: runtimeSettings,
377
- tools: target?.tools ?? agentConfig.tools,
378
- grant: input.grant,
379
- ...(input.authorization ? { authorization: input.authorization } : {}),
380
- selections: hostSelections,
381
- }),
382
- }
383
- : hostSelections === undefined && creationInitiator.actor && runtimeSettings
384
- ? {
385
- captureHostAuthority: prepareInheritedHostMcpTaskAdmission(
386
- runtimeSettings,
387
- target?.tools ?? agentConfig.tools,
388
- creationInitiator.actor,
389
- ),
390
- }
391
- : {}),
392
691
  ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
393
692
  });
693
+ await recordScheduledTaskAccountChoiceChanges(transaction, {
694
+ grant: input.grant,
695
+ taskId: task.id,
696
+ operation: "create",
697
+ before: null,
698
+ after: task.agentConfig,
699
+ });
394
700
  if (learning && learningContext)
395
701
  await saveAgentLearningSettings(transaction, learningContext, {
396
702
  scope: learning.scope,
@@ -405,7 +711,8 @@ export async function createValidatedScheduledTask(input: {
405
711
  }
406
712
 
407
713
  /**
408
- * Freeze the creating session's boundary onto an agent-created task so the
714
+ * Freeze authenticated credential restrictions for every creation lane, and
715
+ * the creating session's boundary onto an agent-created task so the
409
716
  * sessions generated for it inherit exactly what the creator could see and
410
717
  * do, never the deployment default. Tools are the session's effective
411
718
  * model-visible selection under the deployment ceiling; permissions are the
@@ -415,25 +722,86 @@ export async function createValidatedScheduledTask(input: {
415
722
  * projection when it exposes those facts; each absent fact is stored as null
416
723
  * so a generated session keeps its own default for that key.
417
724
  */
418
- async function frozenScheduledTaskCreatorPolicy(input: {
725
+ export async function frozenScheduledTaskCreatorPolicy(input: {
419
726
  db: Database;
420
727
  settings: Settings;
421
728
  grant: AccessGrant;
422
- sessionId: string;
423
- }): Promise<ScheduledTaskCreatorPolicy> {
424
- const session = await getSession(input.db, input.grant.workspaceId, input.sessionId);
729
+ authorization?: AccessGrantAuthorization | undefined;
730
+ actor: Extract<SessionCommandActor, { type: "agent_attempt" }> | null;
731
+ }): Promise<ScheduledTaskCreatorPolicy | null> {
732
+ let restricted =
733
+ (input.authorization?.grant === input.grant &&
734
+ isDeveloperSetupAuthorization(input.authorization)) ||
735
+ isDeveloperSetupGrant(input.grant);
736
+ if (!input.actor) {
737
+ if (input.grant.permissionMode === "explicit") {
738
+ const permissions = DEFAULT_FIRST_PARTY_MCP_PERMISSIONS.filter((permission) =>
739
+ hasPermission(input.grant.permissions, permission, "explicit"),
740
+ );
741
+ if (permissions.length === 0) {
742
+ throw new HTTPException(403, {
743
+ message:
744
+ "the organization key holds no first-party MCP permission it could delegate to scheduled runs",
745
+ });
746
+ }
747
+ return {
748
+ firstPartyMcpTools: null,
749
+ firstPartyMcpPermissions: permissions,
750
+ sessionPolicy: null,
751
+ ...(restricted ? { credentialRestriction: "developer_setup" as const } : {}),
752
+ };
753
+ }
754
+ // A restriction is not an agent tool/permission selection. Keep the exact
755
+ // first-party and session defaults of ordinary API/service/asUser tasks.
756
+ return restricted
757
+ ? {
758
+ firstPartyMcpTools: null,
759
+ firstPartyMcpPermissions: null,
760
+ sessionPolicy: null,
761
+ credentialRestriction: "developer_setup",
762
+ }
763
+ : null;
764
+ }
765
+ const session = await getSession(input.db, input.grant.workspaceId, input.actor.sessionId);
425
766
  if (!session) {
426
767
  throw new HTTPException(403, {
427
768
  message: "the calling agent session is not available in this workspace",
428
769
  });
429
770
  }
430
- const firstPartyMcpTools = allowedFirstPartyMcpToolsForSession(
771
+ const turn = await getSessionTurnForAttempt(
772
+ input.db,
773
+ input.grant.workspaceId,
774
+ input.actor.sessionId,
775
+ input.actor.attemptId,
776
+ );
777
+ if (!turn || turn.id !== input.actor.turnId) {
778
+ throw new HTTPException(403, { message: "the calling agent attempt is not available" });
779
+ }
780
+ // These are server-frozen policies, not task/agentConfig metadata. The DB
781
+ // insert also verifies this exact actor under its ownership locks.
782
+ const turnPolicy = readTurnExecutionPolicyV1(turn.metadata);
783
+ const sessionPolicy = readTurnExecutionPolicyV1(session.metadata);
784
+ restricted ||= Boolean(
785
+ (turnPolicy.kind === "valid" &&
786
+ turnPolicy.policy.credentialRestriction === "developer_setup") ||
787
+ (sessionPolicy.kind === "valid" &&
788
+ sessionPolicy.policy.credentialRestriction === "developer_setup"),
789
+ );
790
+ const currentSelection = resolveSessionFirstPartyMcpTools(
431
791
  input.settings,
432
- session.firstPartyMcpTools,
792
+ session,
793
+ (await requireWorkspace(input.db, input.grant.workspaceId)).settings,
433
794
  );
795
+ const signedSelection = input.grant.metadata?.["firstPartyMcpTools"];
796
+ const firstPartyMcpTools = Array.isArray(signedSelection)
797
+ ? currentSelection.filter((name) => signedSelection.includes(name))
798
+ : currentSelection;
434
799
  const firstPartyMcpPermissions = (
435
800
  session.firstPartyMcpPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS]
436
- ).filter((permission) => hasPermission(input.grant.permissions, permission));
801
+ ).filter((permission) =>
802
+ hasPermission(input.grant.permissions, permission, input.grant.permissionMode),
803
+ );
804
+ requireExplicitPermissionDelegation(input.grant, firstPartyMcpPermissions);
437
805
  if (firstPartyMcpPermissions.length === 0) {
438
806
  throw new HTTPException(403, {
439
807
  message:
@@ -449,6 +817,7 @@ async function frozenScheduledTaskCreatorPolicy(input: {
449
817
  return {
450
818
  firstPartyMcpTools,
451
819
  firstPartyMcpPermissions,
820
+ ...(restricted ? { credentialRestriction: "developer_setup" as const } : {}),
452
821
  sessionPolicy: {
453
822
  agentAccess: agentAccess.success ? agentAccess.data : null,
454
823
  scopeSubjectId: scopeSubjectId.success ? scopeSubjectId.data : null,
@@ -487,6 +856,10 @@ export async function withScheduledTaskAuthorityWriteErrors<T>(run: () => Promis
487
856
  try {
488
857
  return await run();
489
858
  } catch (error) {
859
+ if (error instanceof ScheduledTaskHeadChangedError)
860
+ throw new HTTPException(409, {
861
+ message: "Scheduled task changed. Reload it before saving.",
862
+ });
490
863
  if (nestedPostgresSqlState(error) === "40001")
491
864
  throw new HTTPException(409, {
492
865
  message: "Scheduled task learning settings changed. Reload the task before saving.",
@@ -506,7 +879,7 @@ export async function withScheduledTaskAuthorityWriteErrors<T>(run: () => Promis
506
879
  /** API/MCP-facing update that maps authority-write denials to 409. */
507
880
  export async function updateScheduledTaskForApi(
508
881
  db: Database,
509
- workspaceId: string,
882
+ grant: AccessGrant,
510
883
  taskId: string,
511
884
  update: UpdateScheduledTaskInput,
512
885
  learning?: {
@@ -515,6 +888,15 @@ export async function updateScheduledTaskForApi(
515
888
  restoreState: ScheduledTaskRestoreState;
516
889
  },
517
890
  ): Promise<ScheduledTask> {
891
+ const workspaceId = grant.workspaceId;
892
+ const beforeUpdateCommit = update.beforeUpdateCommit;
893
+ update = {
894
+ ...update,
895
+ beforeUpdateCommit: async (tx) => {
896
+ await assertScheduledTaskMutationOwner(tx, grant, taskId);
897
+ await beforeUpdateCommit?.(tx);
898
+ },
899
+ };
518
900
  if (learning) {
519
901
  const context = await knowledgeContextForAccess(
520
902
  { db },
@@ -581,6 +963,116 @@ export async function updateScheduledTaskForApi(
581
963
  );
582
964
  }
583
965
 
966
+ export async function assertScheduledTaskMutationOwner(
967
+ db: Database,
968
+ grant: AccessGrant,
969
+ taskId: string,
970
+ ): Promise<void> {
971
+ await assertNotSiteClampedAgent(db, grant, "schedules");
972
+ await assertSiteMayReachScheduledTask(db, grant, taskId);
973
+ const writer = scheduledTaskInitiatorForGrant(grant);
974
+ const matches = await scheduledTaskMutationOwnerMatches(db, {
975
+ workspaceId: grant.workspaceId,
976
+ taskId,
977
+ ...(writer.initiator ? { createdBy: writer.initiator } : {}),
978
+ ...(writer.context ? { createdByContext: writer.context } : {}),
979
+ createdByActor: writer.actor ?? null,
980
+ });
981
+ if (!matches)
982
+ throw new HTTPException(403, {
983
+ message: "Only the schedule owner can change or run it. Copy it to create your own schedule.",
984
+ });
985
+ }
986
+
987
+ /**
988
+ * A request from a Site page (a tool the page called) may change or run only
989
+ * a schedule that same Site created. Refused as a missing schedule.
990
+ */
991
+ async function assertSiteMayReachScheduledTask(
992
+ db: Database,
993
+ grant: AccessGrant,
994
+ taskId: string,
995
+ ): Promise<void> {
996
+ const origin = currentSiteSessionOrigin();
997
+ if (!origin) return;
998
+ const task = await getScheduledTask(db, grant.workspaceId, taskId);
999
+ if (!siteOriginsMatch(origin, siteSessionOriginFromMetadata(task?.agentConfig.metadata)))
1000
+ throw new HTTPException(404, { message: "scheduled task not found" });
1001
+ }
1002
+
1003
+ /**
1004
+ * An admin's pause of a schedule they do not own. Pausing is reversible and
1005
+ * changes nothing else; only the owner can change, run or resume it again.
1006
+ */
1007
+ export async function pauseScheduledTaskAsAdmin(
1008
+ db: Database,
1009
+ workspaceId: string,
1010
+ taskId: string,
1011
+ ): Promise<ScheduledTask> {
1012
+ return await withScheduledTaskAuthorityWriteErrors(() =>
1013
+ updateScheduledTask(db, workspaceId, taskId, { status: "paused" }),
1014
+ );
1015
+ }
1016
+
1017
+ export const SCHEDULED_TASK_PAUSED_TRIGGER_MESSAGE =
1018
+ "This schedule is paused, so it cannot run now. Resume it first, then trigger it.";
1019
+
1020
+ export async function triggerScheduledTaskForGrant(
1021
+ db: Database,
1022
+ grant: AccessGrant,
1023
+ workflowClient: SessionWorkflowClient,
1024
+ input: Parameters<SessionWorkflowClient["triggerScheduledTask"]>[0],
1025
+ ): Promise<void> {
1026
+ await db.transaction(async (tx) => {
1027
+ await assertScheduledTaskMutationOwner(tx, grant, input.task.id);
1028
+ if (isRetiredNativeAtlassianTask(input.task))
1029
+ throw new HTTPException(410, { message: ATLASSIAN_NATIVE_RETIRED_MESSAGE });
1030
+ // The worker never runs a paused schedule. Refuse here so the caller is
1031
+ // told, instead of accepting a trigger that would silently do nothing.
1032
+ if (input.task.status === "paused")
1033
+ throw new HTTPException(409, { message: SCHEDULED_TASK_PAUSED_TRIGGER_MESSAGE });
1034
+ const actor = creationInitiatorForGrant(grant).actor ?? null;
1035
+ const restriction = await scheduledTaskCredentialRestrictionForGrant(tx, grant, actor);
1036
+ // A caller cannot supply or clear the trusted ceiling. Ownerless and
1037
+ // same-human schedules can be triggered by a different credential than
1038
+ // their creator; restrict that accepted run, not the durable task.
1039
+ const { credentialRestriction: _untrustedRestriction, ...trigger } = input;
1040
+ await workflowClient.triggerScheduledTask({
1041
+ ...trigger,
1042
+ ...(restriction ? { credentialRestriction: restriction } : {}),
1043
+ });
1044
+ });
1045
+ }
1046
+
1047
+ async function scheduledTaskCredentialRestrictionForGrant(
1048
+ db: Database,
1049
+ grant: AccessGrant,
1050
+ actor: Extract<SessionCommandActor, { type: "agent_attempt" }> | null,
1051
+ ): Promise<"developer_setup" | undefined> {
1052
+ if (isDeveloperSetupGrant(grant)) return "developer_setup";
1053
+ if (!actor) return undefined;
1054
+ const session = await getSession(db, grant.workspaceId, actor.sessionId);
1055
+ const turn = await getSessionTurnForAttempt(
1056
+ db,
1057
+ grant.workspaceId,
1058
+ actor.sessionId,
1059
+ actor.attemptId,
1060
+ );
1061
+ if (!session || !turn || turn.id !== actor.turnId) {
1062
+ throw new HTTPException(403, { message: "the calling agent attempt is not available" });
1063
+ }
1064
+ const policies = [
1065
+ readTurnExecutionPolicyV1(turn.metadata),
1066
+ readTurnExecutionPolicyV1(session.metadata),
1067
+ ];
1068
+ return policies.some(
1069
+ (policy) =>
1070
+ policy.kind === "valid" && policy.policy.credentialRestriction === "developer_setup",
1071
+ )
1072
+ ? "developer_setup"
1073
+ : undefined;
1074
+ }
1075
+
584
1076
  export async function validateScheduledTaskTarget(input: {
585
1077
  db: Database;
586
1078
  sessionAuthorization?: SessionAuthorizationPort | null | undefined;
@@ -592,6 +1084,8 @@ export async function validateScheduledTaskTarget(input: {
592
1084
  rigId: string | null | undefined;
593
1085
  agentConfig: ScheduledTaskAgentConfig;
594
1086
  missingTargetStatus?: 404 | 422;
1087
+ /** Leaving a cancelled chat still requires control, but not revivability. */
1088
+ departingTarget?: boolean;
595
1089
  }): Promise<Session | null> {
596
1090
  if (input.runMode !== "existing_session") {
597
1091
  if (input.targetSessionId) {
@@ -643,54 +1137,58 @@ export async function validateScheduledTaskTarget(input: {
643
1137
  if (!session || session.accountId !== input.grant.accountId) {
644
1138
  throw new HTTPException(404, { message: "target session not found" });
645
1139
  }
646
- if (
647
- input.agentConfig.bundledSkillIds !== undefined &&
648
- !isDeepStrictEqual(input.agentConfig.bundledSkillIds, session.bundledSkillIds)
649
- ) {
650
- throw new HTTPException(422, {
651
- message: "An existing-session schedule cannot change that session's bundled Skill selection",
652
- });
653
- }
654
- if (session.status === "cancelled") {
1140
+ if (session.status === "cancelled" && !input.departingTarget) {
655
1141
  throw new HTTPException(409, {
656
1142
  message: "target session is cancelled; choose a revivable session",
657
1143
  });
658
1144
  }
659
- if ((session.variableSetId ?? null) !== (input.variableSetId ?? null)) {
660
- throw new HTTPException(422, {
661
- message: "target session variableSet attachment does not match the scheduled task",
662
- });
663
- }
664
- if ((session.rigId ?? null) !== (input.rigId ?? null)) {
665
- throw new HTTPException(422, {
666
- message: "target session rig does not match the scheduled task",
667
- });
668
- }
669
- if (
670
- input.agentConfig.sandboxBackend !== undefined &&
671
- input.agentConfig.sandboxBackend !== session.sandboxBackend
672
- ) {
673
- throw new HTTPException(422, {
674
- message: "target session sandbox backend does not match the scheduled task",
675
- });
676
- }
677
- if (
678
- scheduledSlackBotConnectionId(session.metadata) !==
679
- (input.agentConfig.slackBotConnectionId ?? null)
680
- ) {
681
- throw new HTTPException(422, {
682
- message: "target session OpenGeni Slack bot binding does not match the scheduled task",
683
- });
684
- }
1145
+ // The target owns execution settings. Admission freezes and revalidates its
1146
+ // current policy under the scheduled owner; duplicated task fields confer no access.
685
1147
  return session;
686
1148
  }
687
1149
 
1150
+ export const PERSONAL_MACHINE_SCHEDULE_AUDIENCE_MESSAGE =
1151
+ "Your personal machine can only run schedules that only you can see. Set Who can see it to Only me, or pick a shared machine.";
1152
+
1153
+ /**
1154
+ * A schedule's audience is resolved after its machine is validated, and can be
1155
+ * changed without touching the machine. Re-check the pairing on the final
1156
+ * audience so a personal machine never ends up on a schedule others can see.
1157
+ */
1158
+ async function requirePersonalMachineAudience(input: {
1159
+ db: Database;
1160
+ grant: AccessGrant;
1161
+ agentConfig: ScheduledTaskAgentConfig;
1162
+ audience: "user_private" | "workspace_shared";
1163
+ }): Promise<void> {
1164
+ const target = input.agentConfig.machineTarget;
1165
+ if (!target || input.audience === "user_private") return;
1166
+ const sandbox = await getSandbox(
1167
+ input.db,
1168
+ {
1169
+ accountId: input.grant.accountId,
1170
+ workspaceId: input.grant.workspaceId,
1171
+ subjectId: input.grant.subjectId,
1172
+ },
1173
+ target.targetSandboxId,
1174
+ );
1175
+ if (sandbox?.scope === "user") {
1176
+ throw new HTTPException(422, { message: PERSONAL_MACHINE_SCHEDULE_AUDIENCE_MESSAGE });
1177
+ }
1178
+ }
1179
+
688
1180
  export async function validateScheduledTaskMachineTarget(input: {
689
1181
  settings: Settings;
690
1182
  db: Database;
691
1183
  grant: AccessGrant;
692
1184
  runMode: ScheduledTask["runMode"];
693
1185
  agentConfig: ScheduledTaskAgentConfig;
1186
+ /**
1187
+ * Who can see the schedule. A personal machine runs only an Only me schedule
1188
+ * of its owner; the owner check is the scoped lookup itself, which returns a
1189
+ * personal machine to nobody else.
1190
+ */
1191
+ audience: "user_private" | "workspace_shared";
694
1192
  requireOnline?: boolean;
695
1193
  }): Promise<{
696
1194
  sandboxId: string;
@@ -731,11 +1229,8 @@ export async function validateScheduledTaskMachineTarget(input: {
731
1229
  message: "the selected Connected Machine is unavailable",
732
1230
  });
733
1231
  }
734
- if (sandbox.scope === "user") {
735
- throw new HTTPException(422, {
736
- message:
737
- "personal Connected Machines cannot run unattended schedules; select a workspace or organization machine",
738
- });
1232
+ if (sandbox.scope === "user" && input.audience !== "user_private") {
1233
+ throw new HTTPException(422, { message: PERSONAL_MACHINE_SCHEDULE_AUDIENCE_MESSAGE });
739
1234
  }
740
1235
  const enrollment = input.requireOnline
741
1236
  ? await getLiveEnrollmentConnection(input.db, access, sandbox.enrollmentId)
@@ -786,7 +1281,7 @@ export function scheduledTaskAuthorityUpdateForGrant(
786
1281
  | "authorityUpdatedByContext"
787
1282
  | "authorityUpdatedByActor"
788
1283
  > {
789
- const writer = creationInitiatorForGrant(grant);
1284
+ const writer = scheduledTaskInitiatorForGrant(grant);
790
1285
  return {
791
1286
  refreshPersonalResourceAuthority: true,
792
1287
  ...(writer.initiator ? { authorityUpdatedBy: writer.initiator } : {}),
@@ -795,20 +1290,131 @@ export function scheduledTaskAuthorityUpdateForGrant(
795
1290
  };
796
1291
  }
797
1292
 
1293
+ /**
1294
+ * The Sandbox Environment a new task stores. An explicit id or null is the
1295
+ * caller's choice. An omitted value is resolved once, here, and frozen on the
1296
+ * task, the way session create resolves an omitted rigId:
1297
+ * - an existing-session task keeps its target session's own environment;
1298
+ * - a Connected Machine task stores none: environment setup and Variable Set
1299
+ * injection never reach a machine, so a binding would only add a fire-time
1300
+ * failure mode;
1301
+ * - otherwise the workspace default, when the creator can see it and it has an
1302
+ * active version. A stale default degrades to none, like session create.
1303
+ * The default's own Variable Sets must be attachable by the creator, exactly
1304
+ * as session create requires, so a workspace default never gives a task
1305
+ * secrets its creator could not attach. Later changes to the workspace default
1306
+ * do not move an existing task.
1307
+ */
1308
+ async function resolveScheduledTaskCreateRigId(input: {
1309
+ settings: Settings;
1310
+ db: Database;
1311
+ grant: AccessGrant;
1312
+ requestedRigId: string | null | undefined;
1313
+ runMode: ScheduledTask["runMode"];
1314
+ target: Session | null;
1315
+ agentConfig: ScheduledTaskAgentConfig;
1316
+ }): Promise<string | null> {
1317
+ if (input.requestedRigId !== undefined) return input.requestedRigId;
1318
+ if (input.runMode === "existing_session") return input.target?.rigId ?? null;
1319
+ if (input.agentConfig.machineTarget) return null;
1320
+ const defaultRigId = await getWorkspaceDefaultRigId(input.db, input.grant.workspaceId);
1321
+ if (!defaultRigId) return null;
1322
+ const rig = await getRig(
1323
+ input.db,
1324
+ {
1325
+ accountId: input.grant.accountId,
1326
+ workspaceId: input.grant.workspaceId,
1327
+ subjectId: input.grant.subjectId,
1328
+ },
1329
+ defaultRigId,
1330
+ );
1331
+ if (!rig?.activeVersion) return null;
1332
+ return rig.id;
1333
+ }
1334
+
1335
+ type ScheduledTaskRig = NonNullable<Awaited<ReturnType<typeof getRig>>>;
1336
+
798
1337
  // Validate a scheduled task's rig reference: it must name a rig in the
799
1338
  // workspace. A missing/cross-workspace id is a 422 (RLS-invisible == missing).
800
1339
  async function requireScheduledTaskRig(
801
1340
  db: Database,
802
1341
  access: { accountId: string; workspaceId: string; subjectId: string },
803
1342
  rigId: string,
804
- ): Promise<void> {
1343
+ ): Promise<ScheduledTaskRig> {
805
1344
  const rig = await getRig(db, access, rigId);
806
1345
  if (!rig) {
807
1346
  throw new HTTPException(422, { message: `unknown rigId: ${rigId}` });
808
1347
  }
1348
+ return rig;
1349
+ }
1350
+
1351
+ /**
1352
+ * The validated update for a schedule edit, including its audience: a schedule
1353
+ * that ends up posting into an Only me chat is Only me, and only its owner can
1354
+ * change who sees it (the update itself is owner-only).
1355
+ */
1356
+ export async function validatedScheduledTaskUpdate(
1357
+ input: Parameters<typeof validatedScheduledTaskUpdateWithoutAudience>[0],
1358
+ ): Promise<UpdateScheduledTaskInput> {
1359
+ const update = await validatedScheduledTaskUpdateWithoutAudience(input);
1360
+ const runMode = update.runMode ?? input.existing.runMode;
1361
+ const targetSessionId =
1362
+ runMode === "existing_session"
1363
+ ? (update.targetSessionId ?? input.existing.targetSessionId)
1364
+ : runMode === "reusable_session" && input.existing.runMode === "reusable_session"
1365
+ ? input.existing.reusableSessionId
1366
+ : null;
1367
+ const agentConfig = update.agentConfig ?? input.existing.agentConfig;
1368
+ const visibility = await resolveScheduledTaskVisibility({
1369
+ db: input.db,
1370
+ settings: input.settings,
1371
+ grant: input.grant,
1372
+ requested: input.payload.visibility,
1373
+ current: input.existing,
1374
+ targetSessionId,
1375
+ personalKnowledge: agentConfig.knowledgeSource?.destination.kind === "personal",
1376
+ ownerSubjectId: input.existing.ownerSubjectId,
1377
+ });
1378
+ await requirePersonalMachineAudience({
1379
+ db: input.db,
1380
+ grant: input.grant,
1381
+ agentConfig,
1382
+ audience: visibility,
1383
+ });
1384
+ const current = input.existing.visibility === "private" ? "user_private" : "workspace_shared";
1385
+ const audited = withScheduledTaskAccountChoiceAudit(input.grant, input.existing, update);
1386
+ return visibility === current ? audited : { ...audited, visibility };
1387
+ }
1388
+
1389
+ /**
1390
+ * Record the edit's account and connector choice changes in the transaction
1391
+ * that commits it. Every update writer (form, API, agent tool, access
1392
+ * refresh) commits through the validated update.
1393
+ */
1394
+ function withScheduledTaskAccountChoiceAudit(
1395
+ grant: AccessGrant,
1396
+ existing: ScheduledTask,
1397
+ update: UpdateScheduledTaskInput,
1398
+ ): UpdateScheduledTaskInput {
1399
+ const next = update.agentConfig;
1400
+ if (!next || !scheduledTaskAccountChoiceChanges(existing.agentConfig, next)) return update;
1401
+ const prior = update.beforeUpdateCommit;
1402
+ return {
1403
+ ...update,
1404
+ beforeUpdateCommit: async (tx) => {
1405
+ await prior?.(tx);
1406
+ await recordScheduledTaskAccountChoiceChanges(tx, {
1407
+ grant,
1408
+ taskId: existing.id,
1409
+ operation: "update",
1410
+ before: existing.agentConfig,
1411
+ after: next,
1412
+ });
1413
+ },
1414
+ };
809
1415
  }
810
1416
 
811
- export async function validatedScheduledTaskUpdate(input: {
1417
+ async function validatedScheduledTaskUpdateWithoutAudience(input: {
812
1418
  settings: Settings;
813
1419
  db: Database;
814
1420
  objectStorage: ObjectStorageDependency;
@@ -820,7 +1426,124 @@ export async function validatedScheduledTaskUpdate(input: {
820
1426
  toolsProvided?: boolean;
821
1427
  sessionAuthorization?: SessionAuthorizationPort | null | undefined;
822
1428
  authorizationSurface?: SessionAuthorizationSurface | undefined;
1429
+ /** Proves the bot may post in a newly chosen task Slack channel. */
1430
+ verifySlackChannel?: ScheduledTaskSlackChannelVerifier | undefined;
823
1431
  }): Promise<UpdateScheduledTaskInput> {
1432
+ const requestedPrompt =
1433
+ input.payload.prompt ??
1434
+ input.payload.agentConfigPatch?.prompt ??
1435
+ input.payload.agentConfig?.prompt;
1436
+ if (requestedPrompt !== undefined && !requestedPrompt.trim())
1437
+ throw new HTTPException(422, { message: "scheduled task prompt is required" });
1438
+ const retainedPrompt =
1439
+ input.payload.agentConfig === undefined
1440
+ ? (requestedPrompt ?? input.existing.agentConfig.prompt)
1441
+ : undefined;
1442
+ if (
1443
+ input.payload.expectedExecutionDigest !== undefined &&
1444
+ input.payload.expectedExecutionDigest !== input.existing.executionDigest
1445
+ )
1446
+ throw new HTTPException(409, {
1447
+ message: "Scheduled task changed. Reload it before saving.",
1448
+ });
1449
+ if (input.payload.prompt !== undefined)
1450
+ input = {
1451
+ ...input,
1452
+ payload: {
1453
+ ...input.payload,
1454
+ agentConfigPatch: {
1455
+ ...input.payload.agentConfigPatch,
1456
+ prompt: input.payload.prompt,
1457
+ },
1458
+ },
1459
+ };
1460
+ if (input.payload.targetSessionId && input.payload.runMode === undefined)
1461
+ input = {
1462
+ ...input,
1463
+ payload: { ...input.payload, runMode: "existing_session" },
1464
+ };
1465
+ const requestedMode = input.payload.runMode ?? input.existing.runMode;
1466
+ if (requestedMode === "existing_session") {
1467
+ const config = input.payload.agentConfig ?? input.existing.agentConfig;
1468
+ if (config.knowledgeSource)
1469
+ throw new HTTPException(422, {
1470
+ message: "Source ingestion requires a separate scheduled agent",
1471
+ });
1472
+ const target = await validateScheduledTaskTarget({
1473
+ ...input,
1474
+ runMode: "existing_session",
1475
+ targetSessionId: input.payload.targetSessionId ?? input.existing.targetSessionId,
1476
+ variableSetId: null,
1477
+ rigId: null,
1478
+ agentConfig: scheduledSessionMessageConfig(config),
1479
+ });
1480
+ if (input.payload.rigId && input.payload.rigId !== target?.rigId)
1481
+ throw new HTTPException(422, {
1482
+ message:
1483
+ "Existing-chat schedules use the chat's environment. Change the chat's attachments separately.",
1484
+ });
1485
+ const retargeting =
1486
+ input.existing.runMode !== "existing_session" ||
1487
+ target?.id !== input.existing.targetSessionId;
1488
+ const previousTargetSessionId =
1489
+ input.existing.runMode === "existing_session"
1490
+ ? input.existing.targetSessionId
1491
+ : input.existing.runMode === "reusable_session"
1492
+ ? input.existing.reusableSessionId
1493
+ : null;
1494
+ const previousTarget =
1495
+ retargeting && previousTargetSessionId
1496
+ ? await validateScheduledTaskTarget({
1497
+ ...input,
1498
+ runMode: "existing_session",
1499
+ targetSessionId: previousTargetSessionId,
1500
+ departingTarget: true,
1501
+ variableSetId: null,
1502
+ rigId: null,
1503
+ agentConfig: scheduledSessionMessageConfig(input.existing.agentConfig),
1504
+ })
1505
+ : null;
1506
+ // Variable Sets were replaced by vaults; only an environment change needs review.
1507
+ const removedVariableSetIds: string[] = [];
1508
+ const previousRigId = previousTarget ? previousTarget.rigId : input.existing.rigId;
1509
+ const changedRig = retargeting && previousRigId && previousRigId !== target?.rigId;
1510
+ if ((removedVariableSetIds.length || changedRig) && !input.payload.adoptSessionSettings) {
1511
+ const detail = {
1512
+ code: "scheduled_target_access_change",
1513
+ targetSessionId: target!.id,
1514
+ removedVariableSetIds,
1515
+ removedVariableSetCount: removedVariableSetIds.length,
1516
+ removedRigId: changedRig ? previousRigId : null,
1517
+ resolution:
1518
+ "Review the destination's access, then retry with adoptSessionSettings=true and the reviewed expectedExecutionDigest to use that chat's attachments.",
1519
+ };
1520
+ throw new HTTPException(409, {
1521
+ message:
1522
+ "The destination chat has different attachments. Review the access change before moving this schedule.",
1523
+ cause: detail,
1524
+ });
1525
+ }
1526
+ // Server-side normalization preserves exact message fields and authority choices;
1527
+ // callers never reconstruct an incomplete get projection to remove old settings.
1528
+ input = {
1529
+ ...input,
1530
+ payload: {
1531
+ ...input.payload,
1532
+ ...(retargeting
1533
+ ? {
1534
+ agentConfig: scheduledSessionMessageConfig({
1535
+ ...config,
1536
+ ...input.payload.agentConfigPatch,
1537
+ prompt: input.payload.agentConfigPatch?.prompt ?? config.prompt,
1538
+ }),
1539
+ agentConfigPatch: undefined,
1540
+ variableSetId: null,
1541
+ rigId: null,
1542
+ }
1543
+ : {}),
1544
+ },
1545
+ };
1546
+ }
824
1547
  if (input.payload.agentLearning) {
825
1548
  const context = input.authorization
826
1549
  ? await knowledgeContextForAccess(
@@ -834,8 +1557,15 @@ export async function validatedScheduledTaskUpdate(input: {
834
1557
  message: "Only an authenticated person can configure Agent learning",
835
1558
  });
836
1559
  }
837
- const update: UpdateScheduledTaskInput = {};
1560
+ const update: UpdateScheduledTaskInput = {
1561
+ expectedExecutionDigest: input.existing.executionDigest,
1562
+ };
838
1563
  const requestedKnowledgeSource = input.payload.agentConfig?.knowledgeSource ?? null;
1564
+ if (
1565
+ isRetiredNativeAtlassianTask(input.existing) &&
1566
+ (input.payload.status === "active" || requestedKnowledgeSource)
1567
+ )
1568
+ throw new HTTPException(410, { message: ATLASSIAN_NATIVE_RETIRED_MESSAGE });
839
1569
  const existingKnowledgeSource = scheduledTaskKnowledgeSource(input.existing);
840
1570
  // Editing an ordinary source task's prompt/settings must not orphan its
841
1571
  // connector binding. Deleting the task is the explicit source-disable path.
@@ -885,22 +1615,17 @@ export async function validatedScheduledTaskUpdate(input: {
885
1615
  });
886
1616
  const existingTarget = input.existing.targetSessionId;
887
1617
  const nextRunMode = input.payload.runMode ?? input.existing.runMode;
1618
+ const materializedReusable =
1619
+ input.existing.runMode === "reusable_session" &&
1620
+ nextRunMode === "reusable_session" &&
1621
+ input.existing.reusableSessionId !== null;
1622
+ let reusableTarget: Session | null = null;
888
1623
  const nextTargetSessionId =
889
1624
  input.payload.targetSessionId !== undefined
890
1625
  ? input.payload.targetSessionId
891
1626
  : nextRunMode === "existing_session"
892
1627
  ? existingTarget
893
1628
  : null;
894
- if (
895
- input.existing.runMode === "reusable_session" &&
896
- input.existing.reusableSessionId &&
897
- nextRunMode === "existing_session"
898
- ) {
899
- throw new HTTPException(409, {
900
- message:
901
- "cannot target an existing session after this task created its reusable session; create a new task",
902
- });
903
- }
904
1629
  if (input.payload.name !== undefined) {
905
1630
  update.name = trimmedScheduledTaskName(input.payload.name);
906
1631
  }
@@ -920,41 +1645,14 @@ export async function validatedScheduledTaskUpdate(input: {
920
1645
  if (input.payload.metadata !== undefined) {
921
1646
  update.metadata = input.payload.metadata;
922
1647
  }
923
- if (input.payload.variableSetId !== undefined) {
924
- const nextVariableSetId = input.payload.variableSetId;
925
- if (
926
- (input.existing.variableSetId ?? null) !== (nextVariableSetId ?? null) &&
927
- input.existing.runMode === "reusable_session" &&
928
- input.existing.reusableSessionId
929
- ) {
930
- throw new HTTPException(409, {
931
- message:
932
- "cannot change variableSet of a task with a live reusable session; recreate the task",
933
- });
934
- }
935
- if (nextVariableSetId === null) {
936
- if (input.existing.variableSetId !== null) {
937
- // Detaching is also an attachment change: it strips the secrets a
938
- // task's instructions were designed around.
939
- requirePermission(input.grant, "variable-sets:attach");
940
- }
941
- update.variableSetId = null;
942
- } else {
943
- await validateVariableSetAttachment(
944
- { settings: input.settings, db: input.db },
945
- input.grant,
946
- input.existing.workspaceId,
947
- nextVariableSetId,
948
- );
949
- update.variableSetId = nextVariableSetId;
950
- }
1648
+ if (input.payload.vaultPolicy !== undefined) {
1649
+ update.vaultPolicy = scheduledVaultPolicy(input.payload.vaultPolicy);
951
1650
  }
1651
+ if (input.payload.variableSetId) throw variableSetsRemovedError("variableSetId");
1652
+ // Clearing a legacy selection stays allowed.
1653
+ if (input.payload.variableSetId === null) update.variableSetId = null;
952
1654
  if (input.payload.rigId !== undefined) {
953
- if (
954
- input.existing.runMode === "reusable_session" &&
955
- input.existing.reusableSessionId !== null &&
956
- input.payload.rigId !== input.existing.rigId
957
- ) {
1655
+ if (materializedReusable && input.payload.rigId !== input.existing.rigId) {
958
1656
  throw new HTTPException(409, {
959
1657
  message: "A reusable-session task cannot change rigId after materialization; recreate it",
960
1658
  });
@@ -972,17 +1670,44 @@ export async function validatedScheduledTaskUpdate(input: {
972
1670
  }
973
1671
  update.rigId = input.payload.rigId;
974
1672
  }
975
- if (input.payload.agentConfig !== undefined) {
976
- // Editing the instructions of a task that injects workspace secrets is
977
- // equivalent to attaching those secrets to new instructions, so it
978
- // requires variable-sets:use even though plain task edits do not.
979
- const willHaveVariableSet =
980
- input.payload.variableSetId !== undefined
981
- ? input.payload.variableSetId !== null
982
- : Boolean(input.existing.variableSetId);
983
- if (willHaveVariableSet) {
984
- requirePermission(input.grant, "variable-sets:use");
1673
+ if (input.payload.agentConfig !== undefined || input.payload.agentConfigPatch !== undefined) {
1674
+ // A materialized reusable chat must still exist to take new instructions.
1675
+ if (materializedReusable) {
1676
+ reusableTarget = await getSession(
1677
+ input.db,
1678
+ input.existing.workspaceId,
1679
+ input.existing.reusableSessionId!,
1680
+ );
1681
+ if (!reusableTarget || reusableTarget.accountId !== input.existing.accountId)
1682
+ throw new HTTPException(409, { message: "Scheduled task chat is unavailable" });
1683
+ }
1684
+ }
1685
+ if (input.payload.agentConfigPatch) {
1686
+ const patch = input.payload.agentConfigPatch;
1687
+ const model =
1688
+ patch.model === undefined ? undefined : canonicalConfiguredModel(input.settings, patch.model);
1689
+ if (model !== undefined && model !== null) {
1690
+ await assertWorkspaceModelPolicyAllows(
1691
+ input.db,
1692
+ input.settings,
1693
+ input.existing.workspaceId,
1694
+ model,
1695
+ );
985
1696
  }
1697
+ // Validate only the newly supplied model settings, not a reconstructed
1698
+ // bounded input. Legacy stored text, resources and selections stay exact.
1699
+ update.agentConfig = {
1700
+ ...input.existing.agentConfig,
1701
+ ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}),
1702
+ ...(model !== undefined && model !== null ? { model } : {}),
1703
+ ...(patch.reasoningEffort !== undefined ? { reasoningEffort: patch.reasoningEffort } : {}),
1704
+ };
1705
+ // Validation performs asynchronous authority checks before persistence.
1706
+ // Reuse the locked-row CAS so that merging this snapshot cannot erase a
1707
+ // concurrent config edit. The caller receives 409, never an automatic retry.
1708
+ update.expectedExecutionDigest = input.existing.executionDigest;
1709
+ }
1710
+ if (input.payload.agentConfig !== undefined) {
986
1711
  const nextAgentConfig = await validateScheduledTaskAgentConfig({
987
1712
  settings: input.settings,
988
1713
  db: input.db,
@@ -999,21 +1724,105 @@ export async function validatedScheduledTaskUpdate(input: {
999
1724
  : input.existing.reusableSessionId,
1000
1725
  },
1001
1726
  ...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
1727
+ ...(materializedReusable ? { retainedCreationConfig: input.existing.agentConfig } : {}),
1728
+ ...(input.existing.agentConfig.toolPolicy
1729
+ ? { existingToolPolicy: input.existing.agentConfig.toolPolicy }
1730
+ : {}),
1002
1731
  });
1003
1732
  if (
1004
- input.existing.reusableSessionId &&
1005
- input.existing.runMode === "reusable_session" &&
1733
+ materializedReusable &&
1006
1734
  (input.existing.agentConfig.slackBotConnectionId ?? null) !==
1007
1735
  (nextAgentConfig.slackBotConnectionId ?? null)
1008
1736
  ) {
1009
1737
  throw new HTTPException(409, {
1010
1738
  message:
1011
- "cannot change the OpenGeni Slack bot connection of a task with a live reusable session; recreate the task",
1739
+ "cannot change the Opengeni Slack bot connection of a task with a live reusable session; recreate the task",
1012
1740
  });
1013
1741
  }
1742
+ // Update text is exact, even when a full form submission reuses the saved
1743
+ // prompt while changing another setting. Creation keeps its trim convention.
1744
+ nextAgentConfig.prompt = retainedPrompt ?? input.payload.agentConfig.prompt;
1745
+ if (retainedPrompt !== undefined) {
1746
+ nextAgentConfig.resources = input.existing.agentConfig.resources;
1747
+ }
1748
+ // The Site a schedule was created from stays recorded; an update never
1749
+ // sets, changes or clears it.
1750
+ const recordedOrigin = input.existing.agentConfig.metadata?.[SITE_ORIGIN_METADATA_KEY];
1751
+ nextAgentConfig.metadata = {
1752
+ ...withSiteOriginMetadata(nextAgentConfig.metadata ?? {}, null),
1753
+ ...(recordedOrigin !== undefined ? { [SITE_ORIGIN_METADATA_KEY]: recordedOrigin } : {}),
1754
+ };
1014
1755
  update.agentConfig = nextAgentConfig;
1015
1756
  }
1016
- const nextAgentConfig = update.agentConfig ?? input.existing.agentConfig;
1757
+ // Pins are the only saved account choice, a person's explicit one, and only
1758
+ // a schedule that starts its own session has them. A full config replacement
1759
+ // keeps them unless the request changes them; moving into an existing chat
1760
+ // drops them, because that chat's connectors and the owner's current
1761
+ // accounts apply.
1762
+ const pinsProvided =
1763
+ input.payload.pinnedAccounts !== undefined ||
1764
+ input.payload.pinnedAccountsRequired !== undefined;
1765
+ if (pinsProvided) {
1766
+ if (
1767
+ nextRunMode === "existing_session" &&
1768
+ ((input.payload.pinnedAccounts?.length ?? 0) > 0 ||
1769
+ input.payload.pinnedAccountsRequired === true)
1770
+ )
1771
+ throw new HTTPException(422, {
1772
+ message:
1773
+ "Pin connector accounts only on a schedule that starts its own session. An existing-chat schedule uses that chat's connectors with the owner's current accounts.",
1774
+ });
1775
+ requireScheduledTaskPinAuthor(input.grant);
1776
+ }
1777
+ if (update.agentConfig || pinsProvided || nextRunMode === "existing_session") {
1778
+ const base = update.agentConfig ?? input.existing.agentConfig;
1779
+ const pins =
1780
+ nextRunMode === "existing_session"
1781
+ ? []
1782
+ : (input.payload.pinnedAccounts ?? input.existing.agentConfig.pinnedAccounts ?? []);
1783
+ const required =
1784
+ input.payload.pinnedAccountsRequired ??
1785
+ input.existing.agentConfig.pinnedAccountsRequired === true;
1786
+ const next = withScheduledTaskPins(base, pins, required);
1787
+ if (update.agentConfig || !isDeepStrictEqual(next, input.existing.agentConfig))
1788
+ update.agentConfig = next;
1789
+ }
1790
+ const nextAgentConfig = {
1791
+ ...(update.agentConfig ?? input.existing.agentConfig),
1792
+ };
1793
+ // Retargeting rewrites a narrow patch into a full config above. Check the
1794
+ // exact final message after restoring retained fields, regardless of path.
1795
+ // An unedited legacy prompt remains movable even when above today's limit.
1796
+ if (
1797
+ requestedPrompt !== undefined &&
1798
+ scheduledOccurrencePayloadUtf8Bytes(nextAgentConfig) >
1799
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_MAX_BYTES -
1800
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_INGRESS_HEADROOM_BYTES
1801
+ )
1802
+ throw new HTTPException(422, {
1803
+ message: "Updated scheduled message and attachments exceed the supported occurrence payload",
1804
+ });
1805
+ await validateScheduledTaskSlackChannel({
1806
+ grant: input.grant,
1807
+ authorization: input.authorization,
1808
+ previous: input.existing.agentConfig,
1809
+ next: nextAgentConfig,
1810
+ runMode: nextRunMode,
1811
+ reusableSessionCanPost:
1812
+ input.existing.runMode === "reusable_session" && input.existing.reusableSessionId !== null
1813
+ ? async () => {
1814
+ const chat = await getSession(
1815
+ input.db,
1816
+ input.existing.workspaceId,
1817
+ input.existing.reusableSessionId!,
1818
+ );
1819
+ return SCHEDULED_SLACK_BOT_POSTING_TOOLS.every(
1820
+ (tool) => chat?.firstPartyMcpTools?.includes(tool) === true,
1821
+ );
1822
+ }
1823
+ : null,
1824
+ verifySlackChannel: input.verifySlackChannel,
1825
+ });
1017
1826
  const authorityTargetChanged =
1018
1827
  nextRunMode !== input.existing.runMode ||
1019
1828
  nextTargetSessionId !== input.existing.targetSessionId ||
@@ -1022,8 +1831,7 @@ export async function validatedScheduledTaskUpdate(input: {
1022
1831
  (input.payload.rigId !== undefined && input.payload.rigId !== input.existing.rigId);
1023
1832
  const materialExecutionChange =
1024
1833
  authorityTargetChanged ||
1025
- input.payload.selectedHostMcpDelegations !== undefined ||
1026
- input.payload.connectionAuthorities !== undefined ||
1834
+ pinsProvided ||
1027
1835
  !isDeepStrictEqual(nextAgentConfig, input.existing.agentConfig) ||
1028
1836
  (input.payload.action !== undefined &&
1029
1837
  !isDeepStrictEqual(input.payload.action, input.existing.action)) ||
@@ -1034,15 +1842,31 @@ export async function validatedScheduledTaskUpdate(input: {
1034
1842
  (input.payload.metadata !== undefined &&
1035
1843
  !isDeepStrictEqual(input.payload.metadata, input.existing.metadata)) ||
1036
1844
  (input.existing.status === "paused" && input.payload.status === "active");
1037
- if (materialExecutionChange && nextRunMode !== "existing_session") {
1845
+ if (
1846
+ materialExecutionChange &&
1847
+ nextRunMode !== "existing_session" &&
1848
+ (!materializedReusable || nextAgentConfig.model !== input.existing.agentConfig.model)
1849
+ ) {
1038
1850
  const beforeUpdateCommit = workspaceCustomModelCommitGuard({
1039
1851
  settings: input.settings,
1040
1852
  accountId: input.existing.accountId,
1041
1853
  workspaceId: input.existing.workspaceId,
1042
1854
  modelId: nextAgentConfig.model ?? input.settings.openaiModel,
1855
+ claudeAuthoritySnapshot: await getScheduledTaskClaudeProviderAccountAuthoritySnapshot(
1856
+ input.db,
1857
+ input.existing.workspaceId,
1858
+ input.existing.id,
1859
+ ),
1860
+ scheduledTaskId: input.existing.id,
1043
1861
  });
1044
1862
  if (beforeUpdateCommit) update.beforeUpdateCommit = beforeUpdateCommit;
1045
1863
  }
1864
+ // A task accepted with a user-scoped SuperGrok v1 snapshot keeps its
1865
+ // causal human: the scheduled dispatch and the deployed admission fence
1866
+ // (`admit_scheduled_agent_run_execution`) still take the run's causal
1867
+ // subject from that stored snapshot, so a material edit by anyone else
1868
+ // would leave every later occurrence refused. Reading the stored value is
1869
+ // not deriving it from live state; it retires with that fence.
1046
1870
  const existingXaiAuthority = await getScheduledTaskXaiProviderAccountAuthoritySnapshot(
1047
1871
  input.db,
1048
1872
  input.existing.workspaceId,
@@ -1058,67 +1882,16 @@ export async function validatedScheduledTaskUpdate(input: {
1058
1882
  message: "changing a user-scoped xAI scheduled task requires the same causal human",
1059
1883
  });
1060
1884
  }
1061
- const existingDelegations = await getScheduledTaskPersonalConnectionDelegations(
1062
- input.db,
1063
- input.existing.workspaceId,
1064
- input.existing.id,
1065
- );
1066
- if (input.payload.connectionAuthorities === undefined) {
1067
- if (
1068
- materialExecutionChange &&
1069
- existingDelegations.some((delegation) => delegation.connectionType === "github_personal")
1070
- ) {
1071
- throw new HTTPException(409, {
1072
- message:
1073
- "material changes to a personal GitHub-authorized task require explicit connectionAuthorities",
1074
- });
1075
- }
1076
- if (
1077
- existingDelegations.length > 0 &&
1078
- !isDeepStrictEqual(nextAgentConfig.tools, input.existing.agentConfig.tools)
1079
- ) {
1080
- throw new HTTPException(409, {
1081
- message:
1082
- "changing tools on a connection-authorized task requires explicit connectionAuthorities",
1083
- });
1084
- }
1085
- if (existingDelegations.length > 0) {
1086
- if (
1087
- materialExecutionChange &&
1088
- existingDelegations.some(
1089
- (delegation) => delegation.ownerSubjectId !== input.grant.subjectId,
1090
- )
1091
- ) {
1092
- throw new HTTPException(409, {
1093
- message:
1094
- "preserving connection authority requires the same causal human; provide a new explicit selection",
1095
- });
1096
- }
1097
- if (authorityTargetChanged) {
1098
- update.cloneConnectionAuthorityFromRevision = input.existing.authorityRevision;
1099
- } else {
1100
- update.clonePersonalResourceAuthorityFromRevision = input.existing.authorityRevision;
1101
- }
1102
- }
1103
- } else if (input.payload.connectionAuthorities.length === 0) {
1104
- if (personalGitHubRepositoryResources(nextAgentConfig.resources).length > 0) {
1105
- throw new HTTPException(409, {
1106
- message:
1107
- "personal GitHub repository resources cannot be retained without connectionAuthorities",
1108
- });
1109
- }
1110
- update.personalConnectionDelegations = [];
1111
- } else {
1885
+ if (materialExecutionChange) {
1886
+ await assertScheduledTaskMutationOwner(input.db, input.grant, input.existing.id);
1887
+ const ownerSubjectId = input.existing.ownerSubjectId;
1112
1888
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
1113
1889
  input.db,
1114
- // Same object as the subject just below, and as the freeze call further
1115
- // down: the task was loaded with `getScheduledTask(db, grant.workspaceId,
1116
- // ...)`, so this is `input.existing.workspaceId`, sourced consistently.
1117
1890
  input.grant.workspaceId,
1118
1891
  input.settings,
1119
- { subjectId: input.grant.subjectId },
1892
+ ownerSubjectId ? { subjectId: ownerSubjectId } : {},
1120
1893
  );
1121
- const nextTarget = await validateScheduledTaskTarget({
1894
+ let nextTarget = await validateScheduledTaskTarget({
1122
1895
  db: input.db,
1123
1896
  sessionAuthorization: input.sessionAuthorization,
1124
1897
  authorizationSurface: input.authorizationSurface,
@@ -1132,26 +1905,120 @@ export async function validatedScheduledTaskUpdate(input: {
1132
1905
  rigId: input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId,
1133
1906
  agentConfig: nextAgentConfig,
1134
1907
  });
1135
- update.personalConnectionDelegations = await freezePersonalConnectionDelegations({
1908
+ if (materializedReusable) {
1909
+ nextTarget =
1910
+ reusableTarget ??
1911
+ (await getSession(input.db, input.existing.workspaceId, input.existing.reusableSessionId!));
1912
+ if (!nextTarget || nextTarget.accountId !== input.existing.accountId)
1913
+ throw new HTTPException(409, { message: "Scheduled task chat is unavailable" });
1914
+ }
1915
+ const updateWorkspaceDefaults = resolveWorkspaceSessionToolDefaults(
1916
+ (await requireWorkspace(input.db, input.grant.workspaceId)).settings,
1917
+ );
1918
+ // Pins are judged against what a run gets: its own tools plus the
1919
+ // workspace defaults it follows.
1920
+ const runToolsFor = (config: ScheduledTaskAgentConfig) =>
1921
+ scheduledTaskRunTools({
1922
+ agentConfig: config,
1923
+ settings: input.settings,
1924
+ runtimeSettings,
1925
+ workspaceDefaults: updateWorkspaceDefaults,
1926
+ });
1927
+ const nextRunTools = runToolsFor(nextAgentConfig);
1928
+ const priorRunTools = runToolsFor(input.existing.agentConfig);
1929
+ const nextConnectionTools = await scheduledConnectionTools(
1930
+ input.db,
1931
+ input.grant.workspaceId,
1932
+ runtimeSettings,
1933
+ nextTarget,
1934
+ nextRunTools,
1935
+ ownerSubjectId ?? undefined,
1936
+ );
1937
+ const priorTargetSessionId =
1938
+ input.existing.runMode === "existing_session"
1939
+ ? input.existing.targetSessionId
1940
+ : input.existing.runMode === "reusable_session"
1941
+ ? input.existing.reusableSessionId
1942
+ : null;
1943
+ const priorTarget = priorTargetSessionId
1944
+ ? priorTargetSessionId === nextTarget?.id
1945
+ ? nextTarget
1946
+ : await getSession(input.db, input.existing.workspaceId, priorTargetSessionId)
1947
+ : null;
1948
+ const priorConnectionResources = mergeResourceRefs(
1949
+ priorTarget?.resources ?? [],
1950
+ input.existing.agentConfig.resources,
1951
+ );
1952
+ const nextConnectionResources = mergeResourceRefs(
1953
+ nextTarget?.resources ?? [],
1954
+ nextAgentConfig.resources,
1955
+ );
1956
+ const priorConnectionTools = await scheduledConnectionTools(
1957
+ input.db,
1958
+ input.grant.workspaceId,
1959
+ runtimeSettings,
1960
+ priorTarget,
1961
+ priorRunTools,
1962
+ ownerSubjectId ?? undefined,
1963
+ );
1964
+ const priorAccountSurfaceIds = new Set(
1965
+ [...priorRunTools, ...(priorTarget?.tools ?? []), ...priorConnectionTools]
1966
+ .filter((tool) => tool.kind === "mcp")
1967
+ .map((tool) => tool.id),
1968
+ );
1969
+ if (personalGitHubRepositoryResources(priorConnectionResources).length)
1970
+ priorAccountSurfaceIds.add(PERSONAL_GITHUB_CONNECTION_SURFACE_ID);
1971
+ // Compared like for like with the prior surfaces: a connector the task or
1972
+ // its chat still names keeps its pin even while the chat has it turned
1973
+ // off, so only an edit that removes the connector removes the pin.
1974
+ const nextAccountSurfaceIds = new Set(
1975
+ [...nextRunTools, ...(nextTarget?.tools ?? []), ...nextConnectionTools]
1976
+ .filter((tool) => tool.kind === "mcp")
1977
+ .map((tool) => tool.id),
1978
+ );
1979
+ const nextSurfaceEligibility = scheduledConnectionSurfaceEligibility(
1980
+ runtimeSettings,
1981
+ nextTarget,
1982
+ (await requireWorkspace(input.db, input.grant.workspaceId)).settings,
1983
+ );
1984
+ if (nextSurfaceEligibility.googleDrivePublicationEnabled)
1985
+ nextAccountSurfaceIds.add(GOOGLE_DRIVE_PUBLICATION_SERVER_ID);
1986
+ if (personalGitHubRepositoryResources(nextConnectionResources).length)
1987
+ nextAccountSurfaceIds.add(PERSONAL_GITHUB_CONNECTION_SURFACE_ID);
1988
+ // Removing a connector or repository also removes its pin. Pins the
1989
+ // request supplies are validated strictly; retained pins are kept even
1990
+ // when their account is gone, so an edit never fails on a stale pin (the
1991
+ // run goes without that connector and the owner is told).
1992
+ const keptPins = (nextAgentConfig.pinnedAccounts ?? []).filter(
1993
+ (pin) =>
1994
+ input.payload.pinnedAccounts !== undefined ||
1995
+ nextAccountSurfaceIds.has(pin.serverId) ||
1996
+ !priorAccountSurfaceIds.has(pin.serverId),
1997
+ );
1998
+ await freezeConnectionAccounts({
1136
1999
  db: input.db,
1137
- // Both halves of the authority question come from ONE object. The task was
1138
- // loaded with `getScheduledTask(db, grant.workspaceId, ...)`, so this is
1139
- // the same workspace as `input.existing.workspaceId`; taking it from the
1140
- // grant means the workspace can never drift from the `accountId` that
1141
- // `personalConnectionDelegationSourceForGrant` reads off the same grant.
2000
+ accountId: input.grant.accountId,
1142
2001
  workspaceId: input.grant.workspaceId,
1143
- settings: runtimeSettings,
1144
- tools: [...nextAgentConfig.tools, { kind: "mcp", id: "opengeni" }],
1145
- resources: nextAgentConfig.resources,
1146
- source: personalConnectionDelegationSourceForGrant(input.grant),
1147
- authoritySelections: input.payload.connectionAuthorities,
1148
- rejectUnselectedActivatedConnections: true,
1149
- ...scheduledConnectionSurfaceEligibility(runtimeSettings, nextTarget),
2002
+ settings: nextTarget
2003
+ ? settingsWithSessionMcpServerMetadata(runtimeSettings, nextTarget.mcpServers)
2004
+ : runtimeSettings,
2005
+ tools: nextConnectionTools,
2006
+ resources: nextConnectionResources,
2007
+ source: ownerSubjectId
2008
+ ? { kind: "subject", subjectId: ownerSubjectId, accountId: input.existing.accountId }
2009
+ : { kind: "none" },
2010
+ authoritySelections: input.payload.pinnedAccounts ?? [],
2011
+ ...nextSurfaceEligibility,
1150
2012
  });
2013
+ update.agentConfig = withScheduledTaskPins(
2014
+ nextAgentConfig,
2015
+ keptPins,
2016
+ nextAgentConfig.pinnedAccountsRequired === true,
2017
+ );
1151
2018
  }
1152
2019
  if (
1153
2020
  !materialExecutionChange &&
1154
- input.payload.connectionAuthorities === undefined &&
2021
+ !pinsProvided &&
1155
2022
  update.clonePersonalResourceAuthorityFromRevision === undefined
1156
2023
  ) {
1157
2024
  // Administrative lifecycle/name edits preserve the exact revision-bound
@@ -1169,6 +2036,7 @@ export async function validatedScheduledTaskUpdate(input: {
1169
2036
  authorizationSurface: input.authorizationSurface,
1170
2037
  grant: input.grant,
1171
2038
  targetSessionId: existingTarget,
2039
+ departingTarget: true,
1172
2040
  runMode: "existing_session",
1173
2041
  variableSetId: input.existing.variableSetId,
1174
2042
  rigId: input.existing.rigId,
@@ -1189,13 +2057,24 @@ export async function validatedScheduledTaskUpdate(input: {
1189
2057
  rigId: input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId,
1190
2058
  agentConfig: update.agentConfig ?? input.existing.agentConfig,
1191
2059
  });
1192
- if (!knowledgeSource) {
2060
+ if (
2061
+ !knowledgeSource &&
2062
+ !(
2063
+ materializedReusable &&
2064
+ isDeepStrictEqual(nextAgentConfig.machineTarget, input.existing.agentConfig.machineTarget) &&
2065
+ nextAgentConfig.sandboxBackend === input.existing.agentConfig.sandboxBackend
2066
+ )
2067
+ ) {
1193
2068
  await validateScheduledTaskMachineTarget({
1194
2069
  settings: input.settings,
1195
2070
  db: input.db,
1196
2071
  grant: input.grant,
1197
2072
  runMode: nextRunMode,
1198
2073
  agentConfig: update.agentConfig ?? input.existing.agentConfig,
2074
+ audience:
2075
+ (input.payload.visibility ?? input.existing.visibility) === "private"
2076
+ ? "user_private"
2077
+ : "workspace_shared",
1199
2078
  });
1200
2079
  }
1201
2080
  if (
@@ -1209,28 +2088,9 @@ export async function validatedScheduledTaskUpdate(input: {
1209
2088
  Object.assign(update, scheduledTaskAuthorityUpdateForGrant(input.grant));
1210
2089
  const linkCapture = prepareExternalLinkTaskAdmission(
1211
2090
  input.authorization,
1212
- creationInitiatorForGrant(input.grant).actor,
2091
+ scheduledTaskInitiatorForGrant(input.grant).actor,
1213
2092
  );
1214
2093
  if (linkCapture) update.captureLinkAuthority = linkCapture;
1215
- if (input.payload.selectedHostMcpDelegations !== undefined) {
1216
- const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
1217
- input.db,
1218
- input.grant.workspaceId,
1219
- input.settings,
1220
- { subjectId: input.grant.subjectId },
1221
- );
1222
- const target =
1223
- nextRunMode === "existing_session" && nextTargetSessionId
1224
- ? await getSession(input.db, input.grant.workspaceId, nextTargetSessionId)
1225
- : null;
1226
- update.captureHostAuthority = prepareHostMcpTaskAdmission({
1227
- settings: runtimeSettings,
1228
- tools: target?.tools ?? nextAgentConfig.tools,
1229
- grant: input.grant,
1230
- ...(input.authorization ? { authorization: input.authorization } : {}),
1231
- selections: input.payload.selectedHostMcpDelegations,
1232
- });
1233
- }
1234
2094
  if (update.clonePersonalResourceAuthorityFromRevision !== undefined) {
1235
2095
  update.refreshPersonalResourceAuthority = false;
1236
2096
  }
@@ -1251,7 +2111,6 @@ export async function requireScheduledTaskForApi(
1251
2111
 
1252
2112
  export type ScheduledTaskRestoreState = {
1253
2113
  task: ScheduledTask;
1254
- personalConnectionDelegations: McpPersonalConnectionDelegation[];
1255
2114
  learning?: {
1256
2115
  context: KnowledgeContext;
1257
2116
  scope: "workspace" | "personal";
@@ -1261,17 +2120,10 @@ export type ScheduledTaskRestoreState = {
1261
2120
  };
1262
2121
 
1263
2122
  export async function captureScheduledTaskRestoreState(
1264
- db: Database,
2123
+ _db: Database,
1265
2124
  task: ScheduledTask,
1266
2125
  ): Promise<ScheduledTaskRestoreState> {
1267
- return {
1268
- task,
1269
- personalConnectionDelegations: await getScheduledTaskPersonalConnectionDelegations(
1270
- db,
1271
- task.workspaceId,
1272
- task.id,
1273
- ),
1274
- };
2126
+ return { task };
1275
2127
  }
1276
2128
 
1277
2129
  export async function restoreScheduledTask(
@@ -1287,7 +2139,6 @@ export async function restoreScheduledTask(
1287
2139
  overlapPolicy: task.overlapPolicy,
1288
2140
  action: task.action,
1289
2141
  agentConfig: task.agentConfig,
1290
- personalConnectionDelegations: previous.personalConnectionDelegations,
1291
2142
  ...(task.runMode === "existing_session"
1292
2143
  ? { targetSessionId: task.targetSessionId }
1293
2144
  : { reusableSessionId: task.reusableSessionId }),
@@ -1303,6 +2154,8 @@ async function validateKnowledgeSourceSyncAction(input: {
1303
2154
  grant: AccessGrant;
1304
2155
  action: KnowledgeSourceSyncAction;
1305
2156
  }): Promise<void> {
2157
+ if (isRetiredNativeAtlassianSource(input.action))
2158
+ throw new HTTPException(410, { message: ATLASSIAN_NATIVE_RETIRED_MESSAGE });
1306
2159
  if (input.action.initiatingSubjectId !== input.grant.subjectId) {
1307
2160
  throw new HTTPException(403, {
1308
2161
  message: "knowledge source sync must preserve the exact initiating subject",
@@ -1377,6 +2230,7 @@ export async function deleteScheduledTaskLifecycle(input: {
1377
2230
  workspaceId: string;
1378
2231
  taskId: string;
1379
2232
  subjectId: string;
2233
+ beforeDeleteCommit?: (tx: Database) => Promise<void>;
1380
2234
  preflightConnectorAuthorization: (task: ScheduledTask) => Promise<void>;
1381
2235
  cleanupConnectorAuthorization: (db: Database, task: ScheduledTask) => Promise<void>;
1382
2236
  processCleanupClaims?: (claims: readonly TemporalScheduleCleanupClaim[]) => Promise<void>;
@@ -1396,6 +2250,7 @@ export async function deleteScheduledTaskLifecycle(input: {
1396
2250
  input.taskId,
1397
2251
  );
1398
2252
  if (!task) throw new HTTPException(404, { message: "Scheduled task not found" });
2253
+ await input.beforeDeleteCommit?.(tx);
1399
2254
  const wasLive = task.deletedAt === null;
1400
2255
  if (
1401
2256
  scheduledTaskKnowledgeSource(task) &&
@@ -1437,21 +2292,49 @@ export async function syncCreatedScheduledTask(input: {
1437
2292
  task: ScheduledTask;
1438
2293
  }): Promise<void> {
1439
2294
  try {
1440
- await input.workflowClient.syncScheduledTask({ task: input.task });
2295
+ await input.workflowClient.syncScheduledTask({
2296
+ task: input.task,
2297
+ onFailure: async (db, error) => {
2298
+ let persistenceRestored = true;
2299
+ try {
2300
+ // The synchronizer keeps its lock through compensation and commit.
2301
+ // A savepoint also rolls back a failed compensation independently.
2302
+ await db.transaction(async (tx) => {
2303
+ await requireUnchangedScheduledTaskForCompensation(tx, input.task);
2304
+ await deleteScheduledTask(tx, input.task.workspaceId, input.task.id);
2305
+ });
2306
+ } catch {
2307
+ persistenceRestored = false;
2308
+ }
2309
+ return new ScheduledTaskSyncError(error, persistenceRestored);
2310
+ },
2311
+ });
1441
2312
  } catch (error) {
1442
- let persistenceRestored = true;
1443
- try {
1444
- // Creation rollback removes only the failed schedule. Connector desired
1445
- // state remains authoritative so a later connector save can rematerialize
1446
- // it; this is intentionally not the user-requested deletion lifecycle.
1447
- await deleteScheduledTask(input.db, input.task.workspaceId, input.task.id);
1448
- } catch {
1449
- persistenceRestored = false;
1450
- }
1451
- throw new ScheduledTaskSyncError(error, persistenceRestored);
2313
+ if (error instanceof ScheduledTaskSyncError) throw error;
2314
+ // Lock admission or transaction commit failed. No committed restoration
2315
+ // was acknowledged; never compensate outside the synchronizer's lock.
2316
+ throw new ScheduledTaskSyncError(error, false);
1452
2317
  }
1453
2318
  }
1454
2319
 
2320
+ async function requireUnchangedScheduledTaskForCompensation(
2321
+ db: Database,
2322
+ expected: ScheduledTask,
2323
+ ): Promise<void> {
2324
+ const current = await getScheduledTaskIncludingDeletedForUpdate(
2325
+ db,
2326
+ expected.workspaceId,
2327
+ expected.id,
2328
+ );
2329
+ if (!current) throw new ScheduledTaskHeadChangedError();
2330
+ const { deletedAt, ...task } = current;
2331
+ // The execution digest excludes names and lifecycle state. Compare the full
2332
+ // saved row under its write lock so a late sync failure cannot undo a newer
2333
+ // edit, pause, materialization or authority revision.
2334
+ if (deletedAt !== null || !isDeepStrictEqual(task, expected))
2335
+ throw new ScheduledTaskHeadChangedError();
2336
+ }
2337
+
1455
2338
  export async function syncUpdatedScheduledTask(input: {
1456
2339
  db: Database;
1457
2340
  workflowClient: SessionWorkflowClient;
@@ -1459,33 +2342,42 @@ export async function syncUpdatedScheduledTask(input: {
1459
2342
  task: ScheduledTask;
1460
2343
  }): Promise<void> {
1461
2344
  try {
1462
- await input.workflowClient.syncScheduledTask({ task: input.task });
1463
- } catch (error) {
1464
- let persistenceRestored = true;
1465
- try {
1466
- await input.db.transaction(async (tx) => {
1467
- const learning = input.previous.learning;
1468
- // Compensate only our exact settings version, while the updated task
1469
- // still names that owner scope. A concurrent edit is never overwritten.
1470
- if (learning)
1471
- await saveAgentLearningSettings(tx, learning.context, {
1472
- scope: learning.scope,
1473
- source: { kind: "scheduled_task", id: input.task.id },
1474
- operationId: crypto.randomUUID(),
1475
- expectedVersion: learning.expectedVersion,
1476
- settings: {
1477
- knowledge: "inherit",
1478
- instructions: "inherit",
1479
- skills: "inherit",
1480
- ...learning.settings,
1481
- },
2345
+ await input.workflowClient.syncScheduledTask({
2346
+ task: input.task,
2347
+ onFailure: async (db, error) => {
2348
+ let persistenceRestored = true;
2349
+ try {
2350
+ await db.transaction(async (tx) => {
2351
+ const learning = input.previous.learning;
2352
+ // Compensate only our exact settings version, while the updated task
2353
+ // still names that owner scope. A concurrent edit is never overwritten.
2354
+ if (learning)
2355
+ await saveAgentLearningSettings(tx, learning.context, {
2356
+ scope: learning.scope,
2357
+ source: { kind: "scheduled_task", id: input.task.id },
2358
+ operationId: crypto.randomUUID(),
2359
+ expectedVersion: learning.expectedVersion,
2360
+ settings: {
2361
+ knowledge: "inherit",
2362
+ instructions: "inherit",
2363
+ skills: "inherit",
2364
+ ...learning.settings,
2365
+ },
2366
+ });
2367
+ // Keep the learning-owner → task lock order used by ordinary edits.
2368
+ // Any learning compensation above rolls back with a stale task head.
2369
+ await requireUnchangedScheduledTaskForCompensation(tx, input.task);
2370
+ await restoreScheduledTask(tx, input.previous);
1482
2371
  });
1483
- await restoreScheduledTask(tx, input.previous);
1484
- });
1485
- } catch {
1486
- persistenceRestored = false;
1487
- }
1488
- throw new ScheduledTaskSyncError(error, persistenceRestored);
2372
+ } catch {
2373
+ persistenceRestored = false;
2374
+ }
2375
+ return new ScheduledTaskSyncError(error, persistenceRestored);
2376
+ },
2377
+ });
2378
+ } catch (error) {
2379
+ if (error instanceof ScheduledTaskSyncError) throw error;
2380
+ throw new ScheduledTaskSyncError(error, false);
1489
2381
  }
1490
2382
  }
1491
2383
 
@@ -1551,20 +2443,46 @@ async function validateScheduledTaskAgentConfig(input: {
1551
2443
  };
1552
2444
  workspaceId: string;
1553
2445
  toolsProvided?: boolean;
2446
+ /** Only an already materialized reusable chat may retain unused creation defaults. */
2447
+ retainedCreationConfig?: ScheduledTaskAgentConfig;
2448
+ /**
2449
+ * The connector policy the schedule already follows. An edit that does not
2450
+ * name a policy keeps it, so replacing the configuration (for example with
2451
+ * the tools a read returned) never turns the defaults into a fixed list.
2452
+ */
2453
+ existingToolPolicy?: ScheduledTaskAgentConfig["toolPolicy"];
1554
2454
  }): Promise<ScheduledTaskAgentConfig> {
1555
- const actor = creationInitiatorForGrant(input.grant).actor;
1556
- const parent = actor ? await getSession(input.db, input.workspaceId, actor.sessionId) : null;
1557
- if (actor && (!parent || parent.accountId !== input.grant.accountId)) {
2455
+ const existingChat = input.payload.runMode === "existing_session";
2456
+ if (existingChat)
2457
+ input = {
2458
+ ...input,
2459
+ payload: {
2460
+ ...input.payload,
2461
+ agentConfig: scheduledSessionMessageConfig(input.payload.agentConfig),
2462
+ },
2463
+ toolsProvided: true,
2464
+ };
2465
+ const unchangedCreationField = (key: keyof ScheduledTaskAgentConfig) =>
2466
+ input.retainedCreationConfig !== undefined &&
2467
+ isDeepStrictEqual(input.payload.agentConfig[key], input.retainedCreationConfig[key]);
2468
+ const actor = scheduledTaskInitiatorForGrant(input.grant).actor;
2469
+ const parent =
2470
+ actor && !existingChat ? await getSession(input.db, input.workspaceId, actor.sessionId) : null;
2471
+ if (!existingChat && actor && (!parent || parent.accountId !== input.grant.accountId)) {
1558
2472
  throw new HTTPException(403, {
1559
2473
  message: "Scheduled Skill selection requires the creating agent's session",
1560
2474
  });
1561
2475
  }
1562
2476
  let bundledSkillIds: ScheduledTaskAgentConfig["bundledSkillIds"];
1563
2477
  try {
1564
- bundledSkillIds = resolveBundledSkillSelection(
1565
- input.payload.agentConfig.bundledSkillIds,
1566
- parent?.bundledSkillIds,
1567
- );
2478
+ bundledSkillIds = existingChat
2479
+ ? undefined
2480
+ : unchangedCreationField("bundledSkillIds")
2481
+ ? input.payload.agentConfig.bundledSkillIds
2482
+ : resolveBundledSkillSelection(
2483
+ input.payload.agentConfig.bundledSkillIds,
2484
+ parent?.bundledSkillIds,
2485
+ );
1568
2486
  } catch (error) {
1569
2487
  throw new HTTPException(422, {
1570
2488
  message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
@@ -1575,10 +2493,13 @@ async function validateScheduledTaskAgentConfig(input: {
1575
2493
  // session choke points (a `scheduled_tasks:manage` holder could otherwise set
1576
2494
  // a model the host does not expose). An omitted model inherits the host
1577
2495
  // default downstream, which is always configured.
1578
- const model = canonicalConfiguredModel(input.settings, input.payload.agentConfig.model);
2496
+ const model = unchangedCreationField("model")
2497
+ ? input.payload.agentConfig.model
2498
+ : canonicalConfiguredModel(input.settings, input.payload.agentConfig.model);
1579
2499
  // Same policy vetting as the session choke points; an omitted model flows
1580
2500
  // through session creation later, where the effective default is vetted.
1581
- await assertWorkspaceModelPolicyAllows(input.db, input.settings, input.workspaceId, model);
2501
+ if (!unchangedCreationField("model"))
2502
+ await assertWorkspaceModelPolicyAllows(input.db, input.settings, input.workspaceId, model);
1582
2503
  const resources = normalizeResources(input.payload.agentConfig.resources ?? []);
1583
2504
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
1584
2505
  input.db,
@@ -1586,7 +2507,10 @@ async function validateScheduledTaskAgentConfig(input: {
1586
2507
  input.settings,
1587
2508
  { subjectId: input.grant.subjectId },
1588
2509
  );
1589
- const requestedTools = validateToolRefs(input.payload.agentConfig.tools ?? [], runtimeSettings);
2510
+ const retainTools = unchangedCreationField("tools");
2511
+ const requestedTools = retainTools
2512
+ ? input.payload.agentConfig.tools
2513
+ : validateToolRefs(input.payload.agentConfig.tools ?? [], runtimeSettings);
1590
2514
  const workspace = await requireWorkspace(input.db, input.workspaceId);
1591
2515
  if (
1592
2516
  parent?.tenancy?.visibility === "private" &&
@@ -1600,21 +2524,24 @@ async function validateScheduledTaskAgentConfig(input: {
1600
2524
  });
1601
2525
  }
1602
2526
  const workspaceSessionToolDefaults = resolveWorkspaceSessionToolDefaults(workspace.settings);
1603
- // A task whose creator did not choose tools gets the workspace's exact
1604
- // session defaults (or the deployment compatibility default), exactly like
1605
- // a session created without a tools key. Scheduled runs are sessions too;
1606
- // "no MCP servers at all" was a trap every pack/template instantiation path
1607
- // kept falling into (a maintenance task that cannot reach its workspace's
1608
- // notebook MCP cannot do its job).
1609
- const tools =
1610
- (input.toolsProvided ?? true)
1611
- ? requestedTools
1612
- : withWorkspaceDefaultMcpTools(
1613
- requestedTools,
1614
- input.settings,
1615
- runtimeSettings,
1616
- workspaceSessionToolDefaults,
1617
- );
2527
+ // A schedule that starts its own session and whose creator did not choose
2528
+ // tools follows the workspace's default connectors at each run, exactly
2529
+ // like a new chat: nothing about the defaults is copied into the schedule,
2530
+ // so a connector the workspace adds later reaches its next run. A request
2531
+ // may also say so explicitly (`toolPolicy`), with the defaults a person
2532
+ // turned off; its `tools` are then added on top. An edit keeps the policy
2533
+ // the schedule already follows. Only a new schedule created with a list of
2534
+ // tools and no policy gets exactly that list.
2535
+ // A knowledge sync reads one source and never follows the defaults.
2536
+ const toolPolicy =
2537
+ existingChat || input.payload.agentConfig.knowledgeSource
2538
+ ? undefined
2539
+ : (input.payload.agentConfig.toolPolicy ??
2540
+ input.existingToolPolicy ??
2541
+ (retainTools || (input.toolsProvided ?? true)
2542
+ ? undefined
2543
+ : { mode: "workspace_default" as const, excludedMcpServerIds: [] }));
2544
+ const tools = requestedTools;
1618
2545
  const prompt = input.payload.agentConfig.prompt.trim();
1619
2546
  if (!prompt) {
1620
2547
  throw new HTTPException(422, { message: "scheduled task prompt is required" });
@@ -1675,7 +2602,7 @@ async function validateScheduledTaskAgentConfig(input: {
1675
2602
  );
1676
2603
  }
1677
2604
  const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
1678
- if (requestedMaxDepth !== undefined) {
2605
+ if (requestedMaxDepth !== undefined && !unchangedCreationField("maxNestedAgentDepth")) {
1679
2606
  const workspaceMaxDepth = workspace.settings.maxNestedAgentDepth;
1680
2607
  const deploymentPolicy = await getNestedAgentDepthDeploymentPolicy(input.db);
1681
2608
  const inheritedMaxDepth =
@@ -1691,16 +2618,28 @@ async function validateScheduledTaskAgentConfig(input: {
1691
2618
  });
1692
2619
  }
1693
2620
  }
2621
+ const { toolPolicy: _requestedToolPolicy, ...requestedConfig } = input.payload.agentConfig;
1694
2622
  const validated = {
1695
- ...input.payload.agentConfig,
2623
+ ...requestedConfig,
1696
2624
  ...(bundledSkillIds !== undefined ? { bundledSkillIds } : {}),
1697
2625
  ...(model === undefined || model === null ? {} : { model }),
1698
2626
  prompt,
1699
2627
  resources,
1700
2628
  tools,
2629
+ ...(toolPolicy ? { toolPolicy } : {}),
1701
2630
  };
1702
- validateIncidentTelemetryPreflightSelection(input.settings, validated);
1703
- return validated;
2631
+ // Incident preflight names the connectors it needs; judge it on what a run
2632
+ // would get now, including the defaults the schedule follows.
2633
+ validateIncidentTelemetryPreflightSelection(input.settings, {
2634
+ ...validated,
2635
+ tools: scheduledTaskRunTools({
2636
+ agentConfig: validated,
2637
+ settings: input.settings,
2638
+ runtimeSettings,
2639
+ workspaceDefaults: workspaceSessionToolDefaults,
2640
+ }),
2641
+ });
2642
+ return existingChat ? scheduledSessionMessageConfig(validated) : validated;
1704
2643
  }
1705
2644
 
1706
2645
  /**
@@ -1710,7 +2649,7 @@ async function validateScheduledTaskAgentConfig(input: {
1710
2649
  * Mutable rig/variable-set metadata is revalidated again at dispatch.
1711
2650
  */
1712
2651
  export function validateIncidentTelemetryPreflightSelection(
1713
- settings: Pick<Settings, "allowedFirstPartyMcpTools" | "defaultFirstPartyMcpTools">,
2652
+ settings: FirstPartyMcpToolPolicySettings,
1714
2653
  agentConfig: ScheduledTaskAgentConfig,
1715
2654
  ): void {
1716
2655
  const executionClass = agentConfig.executionClass;
@@ -1732,7 +2671,7 @@ export function validateIncidentTelemetryPreflightSelection(
1732
2671
  }
1733
2672
 
1734
2673
  const selectedMcpServerIds = new Set(agentConfig.tools.map((tool) => tool.id));
1735
- // Scheduled dispatch always attaches the first-party OpenGeni MCP server.
2674
+ // Scheduled dispatch always attaches the first-party Opengeni MCP server.
1736
2675
  selectedMcpServerIds.add("opengeni");
1737
2676
  if (preflight.requiredMcpServerIds.some((id) => !selectedMcpServerIds.has(id))) {
1738
2677
  throw new HTTPException(422, {