@opengeni/core 0.28.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +1 -1
  2. package/dist/access/external-actor-authority.d.ts +47 -0
  3. package/dist/access/index.d.ts +182 -3
  4. package/dist/access/service-initiator.d.ts +3 -0
  5. package/dist/application/api-integration-servers.d.ts +20 -0
  6. package/dist/application/archived-session-imports.d.ts +6 -0
  7. package/dist/application/composer-submit.d.ts +24 -0
  8. package/dist/application/connect-authority.d.ts +13 -0
  9. package/dist/application/connect-operation.d.ts +31 -0
  10. package/dist/application/external-continuation.d.ts +15 -0
  11. package/dist/application/external-identity-lifecycle.d.ts +20 -0
  12. package/dist/application/external-link-work-admission.d.ts +12 -0
  13. package/dist/application/external-workspace-members.d.ts +34 -0
  14. package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
  15. package/dist/application/new-session-drafts.d.ts +39 -2
  16. package/dist/application/sandbox-recovery.d.ts +58 -0
  17. package/dist/application/session-commands.d.ts +26 -13
  18. package/dist/application/session-mcp-credential-rotation.d.ts +29 -0
  19. package/dist/application/session-tenancy.d.ts +19 -0
  20. package/dist/application/user-resource-grants.d.ts +20 -0
  21. package/dist/billing/limits.d.ts +11 -0
  22. package/dist/canonical-human-identities.d.ts +7 -2
  23. package/dist/canonical-human-identities.js +17 -4
  24. package/dist/canonical-human-identities.js.map +1 -1
  25. package/dist/{chunk-6WKPWE5S.js → chunk-2MKMRGSY.js} +108 -24
  26. package/dist/chunk-2MKMRGSY.js.map +1 -0
  27. package/dist/{chunk-NYPMQ7NO.js → chunk-GVO3N37G.js} +205 -37
  28. package/dist/chunk-GVO3N37G.js.map +1 -0
  29. package/dist/{chunk-JJU6I5XD.js → chunk-LCQXNR62.js} +36 -10
  30. package/dist/chunk-LCQXNR62.js.map +1 -0
  31. package/dist/chunk-MM6N7CTO.js +387 -0
  32. package/dist/chunk-MM6N7CTO.js.map +1 -0
  33. package/dist/chunk-QO5GVFFO.js +235 -0
  34. package/dist/chunk-QO5GVFFO.js.map +1 -0
  35. package/dist/codex-model-availability.d.ts +14 -0
  36. package/dist/default-session-model.d.ts +139 -0
  37. package/dist/dependencies.d.ts +87 -4
  38. package/dist/developer-setup-credential-ceiling.d.ts +10 -0
  39. package/dist/domain/agent-config-resolution.d.ts +49 -0
  40. package/dist/domain/automations.d.ts +58 -0
  41. package/dist/domain/capabilities.d.ts +22 -4
  42. package/dist/domain/company-profile-agent-admin.d.ts +25 -0
  43. package/dist/domain/connection-account-selection-error.d.ts +8 -0
  44. package/dist/domain/connector-tool-permissions.d.ts +36 -0
  45. package/dist/domain/direct-model-provider.d.ts +3 -0
  46. package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
  47. package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
  48. package/dist/domain/editable-artifacts/ports.d.ts +10 -0
  49. package/dist/domain/editable-artifacts/service.d.ts +4 -3
  50. package/dist/domain/environments.d.ts +5 -9
  51. package/dist/domain/external-creation-attribution.d.ts +6 -0
  52. package/dist/domain/file-owner.d.ts +8 -0
  53. package/dist/domain/github-action-policies.d.ts +36 -0
  54. package/dist/domain/github-repository-bindings.d.ts +117 -0
  55. package/dist/domain/github-skill-source.d.ts +5 -0
  56. package/dist/domain/governed-learning-slack-publication.d.ts +45 -0
  57. package/dist/domain/insights-timing.d.ts +16 -0
  58. package/dist/domain/insights-usage.d.ts +27 -0
  59. package/dist/domain/insights.d.ts +28 -1
  60. package/dist/domain/knowledge-files.d.ts +20 -0
  61. package/dist/domain/knowledge-messages.d.ts +39 -0
  62. package/dist/domain/knowledge-messages.test.d.ts +1 -0
  63. package/dist/domain/knowledge-preparation.d.ts +10 -0
  64. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  65. package/dist/domain/knowledge-search.d.ts +15 -0
  66. package/dist/domain/knowledge-search.test.d.ts +1 -0
  67. package/dist/domain/knowledge.d.ts +9 -0
  68. package/dist/domain/mcp-account-bindings.d.ts +19 -0
  69. package/dist/domain/mcp-account-routes.d.ts +21 -0
  70. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  71. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  72. package/dist/domain/organization-membership-lifecycle.d.ts +1 -0
  73. package/dist/domain/personal-connection-delegations.d.ts +87 -4
  74. package/dist/domain/pr-review.d.ts +249 -0
  75. package/dist/domain/resources.d.ts +24 -3
  76. package/dist/domain/scheduled-task-access.d.ts +247 -0
  77. package/dist/domain/scheduled-tasks.d.ts +103 -6
  78. package/dist/domain/session-connection-accounts.d.ts +16 -0
  79. package/dist/domain/session-file-access.d.ts +4 -0
  80. package/dist/domain/session-native-connection-replacement.d.ts +14 -0
  81. package/dist/domain/session-start-timing.d.ts +7 -0
  82. package/dist/domain/session-tool-policy.d.ts +93 -4
  83. package/dist/domain/sessions.d.ts +275 -28
  84. package/dist/domain/skill-imports.d.ts +1 -0
  85. package/dist/domain/skill-search.d.ts +30 -0
  86. package/dist/domain/skills.d.ts +117 -0
  87. package/dist/domain/slack-bot.d.ts +46 -1
  88. package/dist/domain/workspace-members.d.ts +8 -1
  89. package/dist/editable-artifact-live/application.d.ts +12 -3
  90. package/dist/editable-artifact-live/ports.d.ts +0 -1
  91. package/dist/editable-artifact-live/server.d.ts +5 -2
  92. package/dist/editable-artifact-live/ticket.d.ts +2 -2
  93. package/dist/editable-artifact-live/types.d.ts +14 -2
  94. package/dist/editable-artifact-live.js +2 -2
  95. package/dist/editable-artifacts.js +10 -4
  96. package/dist/index.d.ts +49 -3
  97. package/dist/index.js +18812 -6833
  98. package/dist/index.js.map +1 -1
  99. package/dist/managed-auth-session-sets.d.ts +131 -0
  100. package/dist/managed-auth-session-sets.js +51 -0
  101. package/dist/managed-auth-session-sets.js.map +1 -0
  102. package/dist/managed-session.d.ts +98 -11
  103. package/dist/model-catalog.d.ts +117 -0
  104. package/dist/product-usage-metrics.d.ts +30 -0
  105. package/dist/rigs/index.d.ts +22 -5
  106. package/dist/rigs/provider-images.d.ts +1 -1
  107. package/dist/sandbox/fleet.d.ts +59 -10
  108. package/dist/sandbox/routing.d.ts +22 -2
  109. package/dist/sandbox/runtime-settings.d.ts +56 -0
  110. package/dist/session-authorization.d.ts +38 -1
  111. package/dist/site-session-origin.d.ts +9 -0
  112. package/dist/transcription.d.ts +12 -1
  113. package/dist/turn-surface.d.ts +18 -0
  114. package/dist/user-presence.d.ts +32 -0
  115. package/package.json +18 -11
  116. package/src/access/external-actor-authority.ts +94 -0
  117. package/src/access/index.ts +1386 -44
  118. package/src/access/service-initiator.ts +50 -0
  119. package/src/application/api-integration-servers.ts +215 -0
  120. package/src/application/archived-session-imports.ts +161 -0
  121. package/src/application/composer-submit.ts +77 -0
  122. package/src/application/connect-authority.ts +77 -0
  123. package/src/application/connect-operation.ts +80 -0
  124. package/src/application/external-continuation.ts +129 -0
  125. package/src/application/external-identity-lifecycle.ts +48 -0
  126. package/src/application/external-link-work-admission.ts +90 -0
  127. package/src/application/external-workspace-members.ts +232 -0
  128. package/src/application/modal-native-worker-host-transport.ts +263 -0
  129. package/src/application/new-session-drafts.ts +222 -29
  130. package/src/application/sandbox-recovery.ts +78 -0
  131. package/src/application/session-commands.ts +430 -73
  132. package/src/application/session-mcp-credential-rotation.ts +217 -0
  133. package/src/application/session-tenancy.ts +445 -0
  134. package/src/application/user-resource-grants.ts +154 -0
  135. package/src/billing/limits.ts +103 -24
  136. package/src/canonical-human-identities.ts +22 -3
  137. package/src/codex-model-availability.ts +136 -0
  138. package/src/default-session-model.ts +656 -0
  139. package/src/dependencies.ts +97 -5
  140. package/src/developer-setup-credential-ceiling.ts +60 -0
  141. package/src/domain/agent-config-resolution.ts +148 -0
  142. package/src/domain/automations.ts +270 -0
  143. package/src/domain/capabilities.ts +244 -151
  144. package/src/domain/company-profile-agent-admin.ts +56 -0
  145. package/src/domain/connection-account-selection-error.ts +19 -0
  146. package/src/domain/connector-tool-permissions.ts +371 -0
  147. package/src/domain/direct-model-provider.ts +62 -0
  148. package/src/domain/editable-artifacts/agent-application.ts +61 -15
  149. package/src/domain/editable-artifacts/durable-export.ts +100 -5
  150. package/src/domain/editable-artifacts/in-memory.ts +6 -0
  151. package/src/domain/editable-artifacts/ports.ts +11 -0
  152. package/src/domain/editable-artifacts/service.ts +32 -1
  153. package/src/domain/editable-artifacts/snapshot-compaction.ts +9 -5
  154. package/src/domain/editable-artifacts/snapshot-verifier-genesis.ts +10 -8
  155. package/src/domain/editable-artifacts/snapshot-verifier.ts +10 -3
  156. package/src/domain/environments.ts +25 -45
  157. package/src/domain/external-creation-attribution.ts +22 -0
  158. package/src/domain/file-owner.ts +67 -0
  159. package/src/domain/github-action-policies.ts +169 -0
  160. package/src/domain/github-repository-bindings.ts +295 -0
  161. package/src/domain/github-skill-source.ts +233 -0
  162. package/src/domain/governed-learning-slack-publication.ts +157 -0
  163. package/src/domain/insights-timing.ts +95 -0
  164. package/src/domain/insights-usage.ts +67 -0
  165. package/src/domain/insights.ts +270 -169
  166. package/src/domain/knowledge-files.ts +46 -0
  167. package/src/domain/knowledge-messages.test.ts +91 -0
  168. package/src/domain/knowledge-messages.ts +106 -0
  169. package/src/domain/knowledge-preparation.test.ts +276 -0
  170. package/src/domain/knowledge-preparation.ts +147 -0
  171. package/src/domain/knowledge-search.test.ts +561 -0
  172. package/src/domain/knowledge-search.ts +256 -0
  173. package/src/domain/knowledge.ts +133 -0
  174. package/src/domain/mcp-account-bindings.ts +171 -0
  175. package/src/domain/mcp-account-routes.ts +121 -0
  176. package/src/domain/native-mcp-connection-admission.ts +14 -0
  177. package/src/domain/organization-integration-catalog.ts +88 -0
  178. package/src/domain/organization-membership-lifecycle.ts +10 -0
  179. package/src/domain/personal-connection-delegations.ts +758 -115
  180. package/src/domain/pr-review.ts +687 -0
  181. package/src/domain/resources.ts +182 -25
  182. package/src/domain/scheduled-task-access.ts +1052 -0
  183. package/src/domain/scheduled-tasks.ts +1952 -215
  184. package/src/domain/session-connection-accounts.ts +67 -0
  185. package/src/domain/session-file-access.ts +47 -0
  186. package/src/domain/session-native-connection-replacement.ts +64 -0
  187. package/src/domain/session-start-timing.ts +55 -0
  188. package/src/domain/session-tool-policy.ts +443 -19
  189. package/src/domain/sessions.ts +3417 -619
  190. package/src/domain/skill-imports.ts +137 -17
  191. package/src/domain/skill-search.ts +221 -0
  192. package/src/domain/skills.ts +104 -0
  193. package/src/domain/slack-bot.ts +128 -3
  194. package/src/domain/workspace-members.ts +34 -2
  195. package/src/editable-artifact-live/application.ts +29 -4
  196. package/src/editable-artifact-live/ports.ts +0 -1
  197. package/src/editable-artifact-live/server.ts +167 -13
  198. package/src/editable-artifact-live/ticket.ts +87 -6
  199. package/src/editable-artifact-live/types.ts +16 -2
  200. package/src/editable-artifact-live/wire.ts +0 -10
  201. package/src/index.ts +69 -4
  202. package/src/managed-auth-session-sets.ts +371 -0
  203. package/src/managed-session.ts +520 -2
  204. package/src/model-catalog.ts +873 -0
  205. package/src/product-usage-metrics.ts +101 -0
  206. package/src/rigs/index.ts +179 -63
  207. package/src/rigs/provider-images.ts +2 -3
  208. package/src/sandbox/fleet.ts +471 -59
  209. package/src/sandbox/routing.ts +160 -33
  210. package/src/sandbox/runtime-settings.ts +281 -0
  211. package/src/session-authorization.ts +262 -78
  212. package/src/site-session-origin.ts +22 -0
  213. package/src/transcription.ts +13 -1
  214. package/src/turn-surface.ts +45 -0
  215. package/src/user-presence.ts +134 -0
  216. package/dist/chunk-6WKPWE5S.js.map +0 -1
  217. package/dist/chunk-IBOEYG6N.js +0 -34
  218. package/dist/chunk-IBOEYG6N.js.map +0 -1
  219. package/dist/chunk-JJU6I5XD.js.map +0 -1
  220. package/dist/chunk-NYPMQ7NO.js.map +0 -1
  221. package/dist/domain/durable-learning-slack-publication.d.ts +0 -72
  222. package/dist/domain/packs.d.ts +0 -55
  223. package/src/domain/durable-learning-slack-publication.ts +0 -202
  224. package/src/domain/packs.ts +0 -556
@@ -1,38 +1,105 @@
1
- import type { Settings } from "@opengeni/config";
1
+ import {
2
+ getSessionTurnClaudeProviderAccountAuthoritySnapshot,
3
+ getScheduledTaskClaudeProviderAccountAuthoritySnapshot,
4
+ resolveClaudeProviderAccountAuthoritySnapshotForAcceptance,
5
+ } from "@opengeni/db";
6
+ import {
7
+ SCHEDULED_SLACK_BOT_POSTING_TOOLS,
8
+ scheduledTaskKnowledgeSource,
9
+ requireScheduledTaskKnowledgeSource,
10
+ scheduledOccurrencePayloadUtf8Bytes,
11
+ mergeResourceRefs,
12
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_MAX_BYTES,
13
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_INGRESS_HEADROOM_BYTES,
14
+ } from "@opengeni/contracts";
15
+ import {
16
+ allowedFirstPartyMcpToolsForSession,
17
+ resolveFirstPartyMcpToolPolicy,
18
+ type Settings,
19
+ } from "@opengeni/config";
20
+ import {
21
+ ATLASSIAN_NATIVE_RETIRED_MESSAGE,
22
+ isRetiredNativeAtlassianSource,
23
+ isRetiredNativeAtlassianTask,
24
+ } from "@opengeni/contracts/atlassian-native-retirement";
2
25
  import type {
3
26
  AccessGrant,
4
- McpPersonalConnectionDelegation,
5
27
  KnowledgeSourceSyncAction,
28
+ Permission,
6
29
  ScheduledTask,
7
30
  ScheduledTaskAgentConfig,
8
31
  Session,
32
+ ToolRef,
9
33
  SessionAuthorizationPort,
10
34
  SessionAuthorizationSurface,
11
35
  CreateScheduledTaskRequest as CreateScheduledTaskPayload,
12
36
  UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
13
37
  XaiProviderAccountAuthoritySnapshotV1,
38
+ ClaudeProviderAccountAuthoritySnapshotV1,
39
+ } from "@opengeni/contracts";
40
+ import {
41
+ DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
42
+ OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
43
+ resolveWorkspaceSessionToolDefaults,
44
+ resolveBundledSkillSelection,
45
+ SessionAgentAccess,
46
+ SessionScopeSubjectId,
47
+ SessionMemoryScope,
48
+ readTurnExecutionPolicyV1,
14
49
  } from "@opengeni/contracts";
15
- import { OPENGENI_SLACK_BOT_SESSION_METADATA_KEY } from "@opengeni/contracts";
16
50
  import {
17
51
  createScheduledTask,
52
+ scheduledTaskMutationOwnerMatches,
53
+ getAgentLearningSettings,
54
+ saveAgentLearningSettings,
55
+ type KnowledgeContext,
56
+ dbSql,
18
57
  deleteScheduledTask,
19
58
  getConnectionMetadata,
59
+ getEnrollment,
60
+ getLiveEnrollmentConnection,
20
61
  getKnowledgeSourceForSyncAuthority,
21
62
  getNestedAgentDepthDeploymentPolicy,
22
63
  getRig,
64
+ getScheduledScopedRigVersionMetadata,
23
65
  getScheduledTask,
24
- getScheduledTaskPersonalConnectionDelegations,
66
+ getScheduledTaskIncludingDeletedForUpdate,
67
+ getScheduledTaskXaiProviderAccountAuthoritySnapshot,
68
+ getSandbox,
25
69
  getSessionTurnXaiProviderAccountAuthoritySnapshot,
26
70
  getSession,
71
+ getSessionTurnForAttempt,
72
+ getSessionAuthorityProjection,
73
+ getWorkspaceDefaultRigId,
74
+ withSessionRlsActorContext,
75
+ nestedPostgresSqlState,
27
76
  requireWorkspace,
28
77
  scopedKnowledgeScopeKey,
29
78
  updateScheduledTask,
79
+ ScheduledTaskHeadChangedError,
80
+ withWorkspaceSubjectRls,
30
81
  resolveXaiProviderAccountAuthoritySnapshotForAcceptance,
31
82
  type Database,
83
+ type ScheduledTaskCreatorPolicy,
84
+ type SessionCommandActor,
85
+ type TemporalScheduleCleanupClaim,
32
86
  type UpdateScheduledTaskInput,
33
87
  } from "@opengeni/db";
34
88
  import { HTTPException } from "hono/http-exception";
35
- import { hasPermission, requirePermission } from "../access";
89
+ import { GOOGLE_DRIVE_PUBLICATION_SERVER_ID } from "@opengeni/contracts/google-drive";
90
+ import { PERSONAL_GITHUB_CONNECTION_SURFACE_ID } from "@opengeni/contracts/personal-github";
91
+ import { personalGitHubRepositoryResources } from "./resources";
92
+ import { knowledgeContextForAccess } from "./knowledge";
93
+ import { fileOwnerContextForAccess, fileOwnerContextForAgent } from "./file-owner";
94
+ import { isDeepStrictEqual } from "node:util";
95
+ import {
96
+ hasPermission,
97
+ requireExplicitPermissionDelegation,
98
+ isDeveloperSetupAuthorization,
99
+ isDeveloperSetupGrant,
100
+ requirePermission,
101
+ type AccessGrantAuthorization,
102
+ } from "../access";
36
103
  import {
37
104
  requireSessionAuthorization,
38
105
  SessionAuthorizationDeniedError,
@@ -40,29 +107,37 @@ import {
40
107
  } from "../session-authorization";
41
108
  import type { SessionWorkflowClient } from "../dependencies";
42
109
  import type { ObjectStorageDependency } from "../dependencies";
110
+ import { lockActiveCustomModelForAdmission, workspaceCustomModelReference } from "../model-catalog";
43
111
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
112
+ import {
113
+ resolveSessionToolPolicy,
114
+ workspaceSessionToolPolicyDefaultServerIds,
115
+ } from "./session-tool-policy";
116
+ import { prepareExternalLinkTaskAdmission } from "../application/external-link-work-admission";
44
117
  import { validateVariableSetAttachment } from "./environments";
45
118
  import {
46
- freezePersonalConnectionDelegations,
119
+ freezeConnectionAccounts,
47
120
  personalConnectionDelegationSourceForGrant,
48
- personalConnectionDelegationsEqual,
49
121
  } from "./personal-connection-delegations";
50
122
  import {
51
123
  assertWorkspaceModelPolicyAllows,
52
124
  canonicalConfiguredModel,
53
125
  creationInitiatorForGrant,
126
+ modelUnavailableHttpException,
127
+ settingsWithSessionMcpServerMetadata,
54
128
  } from "./sessions";
55
129
  import {
56
130
  hasReservedOpenGeniSlackBotSessionMetadata,
57
- scheduledSlackBotConnectionId,
58
131
  validateOpenGeniSlackBotConnectionSelection,
132
+ validateScheduledTaskSlackChannel,
133
+ type ScheduledTaskSlackChannelVerifier,
59
134
  } from "./slack-bot";
60
135
  import {
61
136
  normalizeResources,
62
137
  validateFileResources,
63
138
  validateGitHubRepositorySelection,
64
139
  validateToolRefs,
65
- withDefaultEnabledCapabilityMcpTools,
140
+ withWorkspaceDefaultMcpTools,
66
141
  } from "./resources";
67
142
 
68
143
  /**
@@ -85,26 +160,186 @@ export function scheduledTaskToolsProvided(rawPayload: unknown): boolean {
85
160
  );
86
161
  }
87
162
 
163
+ /** Message data and captured account choices survive; the destination owns execution. */
164
+ function scheduledSessionMessageConfig(config: ScheduledTaskAgentConfig): ScheduledTaskAgentConfig {
165
+ return {
166
+ prompt: config.prompt,
167
+ resources: config.resources,
168
+ tools: [],
169
+ metadata: config.metadata,
170
+ ...(config.approvalTimeoutSeconds !== undefined
171
+ ? { approvalTimeoutSeconds: config.approvalTimeoutSeconds }
172
+ : {}),
173
+ ...(config.connectionAccounts !== undefined
174
+ ? { connectionAccounts: config.connectionAccounts }
175
+ : {}),
176
+ ...(config.connectionAccountsFrozen ? { connectionAccountsFrozen: true } : {}),
177
+ };
178
+ }
179
+
180
+ function workspaceCustomModelCommitGuard(input: {
181
+ settings: Settings;
182
+ accountId: string;
183
+ workspaceId: string;
184
+ modelId: string;
185
+ claudeAuthoritySnapshot?: ClaudeProviderAccountAuthoritySnapshotV1;
186
+ }): ((tx: Database) => Promise<void>) | undefined {
187
+ const reference = workspaceCustomModelReference(input.settings, input.modelId);
188
+ if (!reference) return undefined;
189
+ return async (tx: Database): Promise<void> => {
190
+ const active = await lockActiveCustomModelForAdmission(tx, {
191
+ accountId: input.accountId,
192
+ workspaceId: input.workspaceId,
193
+ reference,
194
+ ...(input.claudeAuthoritySnapshot
195
+ ? { claudeAuthority: { authoritySnapshot: input.claudeAuthoritySnapshot } }
196
+ : {}),
197
+ });
198
+ if (!active) {
199
+ throw modelUnavailableHttpException(input.modelId);
200
+ }
201
+ };
202
+ }
203
+
204
+ /** Resolve the target's current tool policy before choosing its owner's accounts. */
205
+ export async function scheduledConnectionTools(
206
+ db: Database,
207
+ workspaceId: string,
208
+ settings: Settings,
209
+ target: Session | null,
210
+ taskTools: ToolRef[],
211
+ subjectId?: string,
212
+ ): Promise<ToolRef[]> {
213
+ if (!target) return [...taskTools, { kind: "mcp", id: "opengeni" }];
214
+ return resolveSessionToolPolicy({
215
+ toolPolicy: target.toolPolicy,
216
+ sessionTools: target.tools,
217
+ availableMcpServerIds: [
218
+ "opengeni",
219
+ ...settings.mcpServers.map(({ id }) => id),
220
+ ...target.mcpServers.map(({ id }) => id),
221
+ ],
222
+ defaultMcpServerIds: await workspaceSessionToolPolicyDefaultServerIds(
223
+ db,
224
+ workspaceId,
225
+ settings,
226
+ subjectId,
227
+ ),
228
+ }).toolRefs;
229
+ }
230
+
231
+ export function scheduledConnectionSurfaceEligibility(
232
+ settings: Settings,
233
+ target: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions"> | null,
234
+ ): { googleDrivePublicationEnabled: boolean; atlassianEnabled: boolean } {
235
+ const tools = target?.firstPartyMcpTools ?? resolveFirstPartyMcpToolPolicy(settings).default;
236
+ const permissions = target?.firstPartyMcpPermissions ?? DEFAULT_FIRST_PARTY_MCP_PERMISSIONS;
237
+ return {
238
+ googleDrivePublicationEnabled:
239
+ tools.includes("editable_artifact_export") &&
240
+ tools.includes("editable_artifact_export_status") &&
241
+ permissions.includes("artifacts:read") &&
242
+ permissions.includes("artifacts:publish"),
243
+ atlassianEnabled: false,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * An organization/workspace API key or the deployment's configured key is a
249
+ * machine principal. It is never a person, so it can never be a schedule's
250
+ * execution owner (a human revision authorizer): its schedules are ownerless
251
+ * and run under service authority, exactly like a delegated service's.
252
+ */
253
+ function isScheduledTaskMachinePrincipal(grant: AccessGrant): boolean {
254
+ return grant.principalKind === "api_key" || grant.principalKind === "configured_key";
255
+ }
256
+
257
+ /** Service attribution cannot become a personal schedule execution owner. */
258
+ function scheduledTaskInitiatorForGrant(grant: AccessGrant) {
259
+ const creator = creationInitiatorForGrant(grant);
260
+ if (
261
+ creator.initiator?.kind === "subject" &&
262
+ (personalConnectionDelegationSourceForGrant(grant).kind === "none" ||
263
+ isScheduledTaskMachinePrincipal(grant))
264
+ ) {
265
+ return { ...creator, initiator: { ...creator.initiator, kind: "service" as const } };
266
+ }
267
+ return creator;
268
+ }
269
+
88
270
  export async function createValidatedScheduledTask(input: {
89
271
  settings: Settings;
90
272
  db: Database;
91
273
  objectStorage: ObjectStorageDependency;
92
274
  grant: AccessGrant;
275
+ authorization?: AccessGrantAuthorization;
93
276
  payload: CreateScheduledTaskPayload;
94
277
  // Whether the caller explicitly set agentConfig.tools (see
95
278
  // scheduledTaskToolsProvided). Absent tools get the workspace's enabled
96
279
  // capability MCP servers, mirroring session creation.
97
280
  toolsProvided?: boolean;
98
- // Set for pack-installation-inherited attachments that were already
99
- // authorized with variable-sets:use when the pack was enabled.
100
- variableSetPreauthorized?: boolean;
101
281
  sessionAuthorization?: SessionAuthorizationPort | null | undefined;
102
282
  authorizationSurface?: SessionAuthorizationSurface | undefined;
283
+ /** Proves the bot may post in a newly chosen task Slack channel. */
284
+ verifySlackChannel?: ScheduledTaskSlackChannelVerifier | undefined;
103
285
  }): Promise<ScheduledTask> {
104
- // API parsing fills this default, but pack installers and older internal
105
- // callers can still invoke the shared validator with the pre-action shape.
286
+ if (input.payload.runMode === "existing_session") {
287
+ const target = await validateScheduledTaskTarget({
288
+ ...input,
289
+ targetSessionId: input.payload.targetSessionId,
290
+ runMode: "existing_session",
291
+ variableSetId: input.payload.variableSetId,
292
+ rigId: input.payload.rigId,
293
+ agentConfig: input.payload.agentConfig,
294
+ });
295
+ if (input.payload.agentConfig.knowledgeSource)
296
+ throw new HTTPException(422, {
297
+ message: "Source ingestion requires a separate scheduled agent",
298
+ });
299
+ if (input.payload.variableSetId && input.payload.variableSetId !== target?.variableSetId)
300
+ throw new HTTPException(422, {
301
+ message:
302
+ "Existing-chat schedules use the chat's Variable Sets. Change the chat's attachments separately.",
303
+ });
304
+ if (input.payload.rigId && input.payload.rigId !== target?.rigId)
305
+ throw new HTTPException(422, {
306
+ message:
307
+ "Existing-chat schedules use the chat's environment. Change the chat's attachments separately.",
308
+ });
309
+ await requireScheduledMessageVariableSetUse({
310
+ db: input.db,
311
+ grant: input.grant,
312
+ target,
313
+ });
314
+ input = {
315
+ ...input,
316
+ payload: {
317
+ ...input.payload,
318
+ agentConfig: scheduledSessionMessageConfig(input.payload.agentConfig),
319
+ variableSetId: null,
320
+ rigId: null,
321
+ },
322
+ };
323
+ }
324
+ const learning = "agentLearning" in input.payload ? input.payload.agentLearning : undefined;
325
+ const learningContext =
326
+ learning && input.authorization
327
+ ? await knowledgeContextForAccess(
328
+ { db: input.db },
329
+ input.authorization,
330
+ "scheduled_tasks:manage",
331
+ )
332
+ : null;
333
+ if (learning && learningContext?.actor.kind !== "human") {
334
+ throw new HTTPException(403, {
335
+ message: "Only an authenticated person can configure Agent learning",
336
+ });
337
+ }
338
+ if (learningContext?.actor.kind === "human" && learning)
339
+ learningContext.actor.settingsScopes = [learning.scope];
340
+ // Internal callers can omit the action; agent turns are the default.
106
341
  const action = input.payload.action ?? ({ kind: "agent_turn" } as const);
107
- const knowledgeAction = action.kind === "knowledge_source_sync" ? action : null;
342
+ const knowledgeAction = input.payload.agentConfig.knowledgeSource ?? null;
108
343
  if (knowledgeAction) {
109
344
  await validateKnowledgeSourceSyncAction({
110
345
  db: input.db,
@@ -112,58 +347,180 @@ export async function createValidatedScheduledTask(input: {
112
347
  action: knowledgeAction,
113
348
  });
114
349
  }
115
- const agentConfig = knowledgeAction
116
- ? input.payload.agentConfig
117
- : await validateScheduledTaskAgentConfig({
118
- ...input,
119
- workspaceId: input.grant.workspaceId,
120
- });
350
+ const agentConfig: ScheduledTaskAgentConfig = await validateScheduledTaskAgentConfig({
351
+ ...input,
352
+ workspaceId: input.grant.workspaceId,
353
+ });
354
+ agentConfig.connectionAccounts = input.payload.connectionAccounts ?? [];
355
+ await validateScheduledTaskSlackChannel({
356
+ grant: input.grant,
357
+ authorization: input.authorization,
358
+ previous: null,
359
+ next: agentConfig,
360
+ runMode: input.payload.runMode,
361
+ reusableSessionCanPost: null,
362
+ verifySlackChannel: input.verifySlackChannel,
363
+ });
364
+ if (knowledgeAction && input.payload.overlapPolicy === "allow_concurrent")
365
+ throw new HTTPException(422, { message: "Source tasks require skip or buffer_one overlap" });
121
366
  const id = crypto.randomUUID();
122
367
  validateScheduledTaskSchedule(input.payload.schedule);
123
- const target = knowledgeAction
124
- ? null
125
- : await validateScheduledTaskTarget({
126
- db: input.db,
127
- sessionAuthorization: input.sessionAuthorization,
128
- authorizationSurface: input.authorizationSurface,
129
- grant: input.grant,
130
- targetSessionId: input.payload.targetSessionId,
131
- runMode: input.payload.runMode,
132
- variableSetId: input.payload.variableSetId,
133
- rigId: input.payload.rigId,
134
- agentConfig,
368
+ const target = await validateScheduledTaskTarget({
369
+ db: input.db,
370
+ sessionAuthorization: input.sessionAuthorization,
371
+ authorizationSurface: input.authorizationSurface,
372
+ grant: input.grant,
373
+ targetSessionId: input.payload.targetSessionId,
374
+ runMode: input.payload.runMode,
375
+ variableSetId: input.payload.variableSetId,
376
+ rigId: input.payload.rigId,
377
+ // An omitted Sandbox Environment adopts the target session's own one below.
378
+ agentConfig,
379
+ });
380
+ if (
381
+ knowledgeAction?.destination.kind === "personal" &&
382
+ input.payload.runMode !== "new_session_per_run"
383
+ )
384
+ throw new HTTPException(422, {
385
+ message: "Personal source tasks create a private session for each run",
386
+ });
387
+ if (learning) {
388
+ const workspace = await requireWorkspace(input.db, input.grant.workspaceId);
389
+ const destination =
390
+ knowledgeAction?.destination.kind === "personal" ||
391
+ workspace.kind === "personal" ||
392
+ target?.tenancy?.visibility === "private" ||
393
+ target?.memoryScope === "user"
394
+ ? "personal"
395
+ : "workspace";
396
+ if (learning.scope !== destination)
397
+ throw new HTTPException(422, {
398
+ message: `This task saves Knowledge in the ${destination} scope. Set its Agent learning override for that scope.`,
135
399
  });
136
- if (!knowledgeAction && input.payload.variableSetId) {
400
+ }
401
+ if (!knowledgeAction) {
402
+ agentConfig.connectionAccountsFrozen = true;
403
+ await validateScheduledTaskMachineTarget({
404
+ settings: input.settings,
405
+ db: input.db,
406
+ grant: input.grant,
407
+ runMode: input.payload.runMode,
408
+ agentConfig,
409
+ });
410
+ }
411
+ if (input.payload.variableSetId) {
137
412
  await validateVariableSetAttachment(
138
413
  { settings: input.settings, db: input.db },
139
414
  input.grant,
140
415
  input.grant.workspaceId,
141
416
  input.payload.variableSetId,
142
- { preauthorized: input.variableSetPreauthorized ?? false },
143
417
  );
144
418
  }
145
419
  // The rig is stored on the task and resolved to its ACTIVE version per fire
146
420
  // (at dispatch), so validate only that the id names a rig in the workspace —
147
421
  // NOT that it has an active version now (that is a fire-time concern). RLS
148
422
  // makes a cross-workspace id indistinguishable from missing → both 422.
423
+ // A generated session binds the environment and its default Variable Sets,
424
+ // so an explicit choice needs the same attachment authority as session
425
+ // create; an existing-session task only matches its target's environment.
149
426
  if (!knowledgeAction && input.payload.rigId) {
150
- await requireScheduledTaskRig(input.db, input.grant.workspaceId, input.payload.rigId);
151
- }
152
- const personalConnectionDelegations = knowledgeAction
153
- ? []
154
- : await freezePersonalConnectionDelegations({
427
+ const rig = await requireScheduledTaskRig(
428
+ input.db,
429
+ {
430
+ accountId: input.grant.accountId,
431
+ workspaceId: input.grant.workspaceId,
432
+ subjectId: input.grant.subjectId,
433
+ },
434
+ input.payload.rigId,
435
+ );
436
+ if (input.payload.runMode !== "existing_session") {
437
+ await requireScheduledTaskRigVariableSetAttachments({
438
+ settings: input.settings,
155
439
  db: input.db,
440
+ grant: input.grant,
156
441
  workspaceId: input.grant.workspaceId,
157
- settings: await settingsWithEnabledCapabilityMcpServers(
158
- input.db,
159
- input.grant.workspaceId,
160
- input.settings,
161
- { subjectId: input.grant.subjectId },
162
- ),
163
- tools: [...agentConfig.tools, { kind: "mcp", id: "opengeni" }],
164
- source: personalConnectionDelegationSourceForGrant(input.grant),
442
+ rig,
443
+ });
444
+ }
445
+ }
446
+ const rigId = knowledgeAction
447
+ ? (input.payload.rigId ?? null)
448
+ : await resolveScheduledTaskCreateRigId({
449
+ settings: input.settings,
450
+ db: input.db,
451
+ grant: input.grant,
452
+ requestedRigId: input.payload.rigId,
453
+ runMode: input.payload.runMode,
454
+ target,
455
+ agentConfig,
165
456
  });
166
- const creationInitiator = creationInitiatorForGrant(input.grant);
457
+ const runtimeSettings = knowledgeAction
458
+ ? null
459
+ : await settingsWithEnabledCapabilityMcpServers(
460
+ input.db,
461
+ input.grant.workspaceId,
462
+ input.settings,
463
+ { subjectId: input.grant.subjectId },
464
+ );
465
+ // Existing-session runs use the target's persisted MCP configuration, with
466
+ // session definitions taking precedence over deployment servers of the same
467
+ // ID. Metadata selects the destination; captured grants still supply authority.
468
+ const effectiveRuntimeSettings =
469
+ runtimeSettings && target
470
+ ? settingsWithSessionMcpServerMetadata(runtimeSettings, target.mcpServers)
471
+ : runtimeSettings;
472
+ const acceptedConnections =
473
+ knowledgeAction || !effectiveRuntimeSettings
474
+ ? { personalConnectionDelegations: [], mcpAccountBindings: [] }
475
+ : await freezeConnectionAccounts({
476
+ db: input.db,
477
+ accountId: input.grant.accountId,
478
+ workspaceId: input.grant.workspaceId,
479
+ settings: effectiveRuntimeSettings,
480
+ tools: await scheduledConnectionTools(
481
+ input.db,
482
+ input.grant.workspaceId,
483
+ effectiveRuntimeSettings,
484
+ target,
485
+ agentConfig.tools,
486
+ input.grant.subjectId,
487
+ ),
488
+ resources: mergeResourceRefs(target?.resources ?? [], agentConfig.resources),
489
+ source: personalConnectionDelegationSourceForGrant(input.grant),
490
+ authoritySelections: input.payload.connectionAccounts,
491
+ ...scheduledConnectionSurfaceEligibility(effectiveRuntimeSettings, target),
492
+ });
493
+ const { personalConnectionDelegations, mcpAccountBindings } = acceptedConnections;
494
+ if (!knowledgeAction) {
495
+ const boundRoutes = new Set((mcpAccountBindings ?? []).map((binding) => binding.serverId));
496
+ agentConfig.connectionAccounts = [
497
+ ...(mcpAccountBindings ?? []).map(({ canonicalServerId, connectionId }) => ({
498
+ serverId: canonicalServerId,
499
+ connectionId,
500
+ })),
501
+ ...personalConnectionDelegations
502
+ .filter(
503
+ (item) =>
504
+ !boundRoutes.has(item.serverId) &&
505
+ (!item.connectionType ||
506
+ item.connectionType === "mcp" ||
507
+ item.connectionType === "github_personal"),
508
+ )
509
+ .map(({ serverId, connectionId }) => ({ serverId, connectionId })),
510
+ ];
511
+ }
512
+ const creationInitiator = scheduledTaskInitiatorForGrant(input.grant);
513
+ const captureLinkAuthority = prepareExternalLinkTaskAdmission(
514
+ input.authorization,
515
+ creationInitiator.actor,
516
+ );
517
+ const creatorPolicy = await frozenScheduledTaskCreatorPolicy({
518
+ db: input.db,
519
+ settings: input.settings,
520
+ grant: input.grant,
521
+ authorization: input.authorization,
522
+ actor: creationInitiator.actor ?? null,
523
+ });
167
524
  const xaiProviderAccountAuthoritySnapshot: XaiProviderAccountAuthoritySnapshotV1 =
168
525
  creationInitiator.actor
169
526
  ? await getSessionTurnXaiProviderAccountAuthoritySnapshot(
@@ -176,28 +533,385 @@ export async function createValidatedScheduledTask(input: {
176
533
  workspaceId: input.grant.workspaceId,
177
534
  subjectId: input.grant.subjectId,
178
535
  });
179
- return await createScheduledTask(input.db, {
180
- id,
181
- accountId: input.grant.accountId,
182
- workspaceId: input.grant.workspaceId,
183
- name: trimmedScheduledTaskName(input.payload.name),
184
- status: input.payload.status,
185
- schedule: input.payload.schedule,
186
- temporalScheduleId: scheduledTaskTemporalScheduleId(id),
187
- runMode: input.payload.runMode,
188
- overlapPolicy: input.payload.overlapPolicy,
189
- action,
190
- agentConfig,
191
- ...(creationInitiator.initiator ? { createdBy: creationInitiator.initiator } : {}),
192
- ...(creationInitiator.context ? { createdByContext: creationInitiator.context } : {}),
193
- createdByActor: creationInitiator.actor ?? null,
194
- personalConnectionDelegations,
195
- xaiProviderAccountAuthoritySnapshot,
196
- targetSessionId: target?.id ?? null,
197
- variableSetId: input.payload.variableSetId ?? null,
198
- rigId: input.payload.rigId ?? null,
199
- metadata: input.payload.metadata,
536
+ const claudeProviderAccountAuthoritySnapshot: ClaudeProviderAccountAuthoritySnapshotV1 =
537
+ creationInitiator.actor
538
+ ? await getSessionTurnClaudeProviderAccountAuthoritySnapshot(
539
+ input.db,
540
+ input.grant.workspaceId,
541
+ creationInitiator.actor.sessionId,
542
+ creationInitiator.actor.turnId,
543
+ )
544
+ : await resolveClaudeProviderAccountAuthoritySnapshotForAcceptance(input.db, {
545
+ workspaceId: input.grant.workspaceId,
546
+ subjectId: input.grant.subjectId,
547
+ });
548
+ const beforeCreateCommit =
549
+ input.payload.runMode !== "existing_session"
550
+ ? workspaceCustomModelCommitGuard({
551
+ settings: input.settings,
552
+ accountId: input.grant.accountId,
553
+ workspaceId: input.grant.workspaceId,
554
+ modelId: agentConfig.model ?? input.settings.openaiModel,
555
+ claudeAuthoritySnapshot: claudeProviderAccountAuthoritySnapshot,
556
+ })
557
+ : undefined;
558
+ return await withScheduledTaskAuthorityWriteErrors(() =>
559
+ input.db.transaction(async (transaction) => {
560
+ const task = await createScheduledTask(transaction, {
561
+ id,
562
+ accountId: input.grant.accountId,
563
+ workspaceId: input.grant.workspaceId,
564
+ name: trimmedScheduledTaskName(input.payload.name),
565
+ status: input.payload.status,
566
+ schedule: input.payload.schedule,
567
+ temporalScheduleId: scheduledTaskTemporalScheduleId(id),
568
+ runMode: input.payload.runMode,
569
+ overlapPolicy: input.payload.overlapPolicy,
570
+ action,
571
+ agentConfig,
572
+ ...(creationInitiator.initiator ? { createdBy: creationInitiator.initiator } : {}),
573
+ ...(creationInitiator.context ? { createdByContext: creationInitiator.context } : {}),
574
+ createdByActor: creationInitiator.actor ?? null,
575
+ ...(captureLinkAuthority ? { captureLinkAuthority } : {}),
576
+ xaiProviderAccountAuthoritySnapshot,
577
+ claudeProviderAccountAuthoritySnapshot,
578
+ creatorPolicy,
579
+ targetSessionId: target?.id ?? null,
580
+ variableSetId: input.payload.variableSetId ?? null,
581
+ rigId,
582
+ metadata: input.payload.metadata,
583
+ ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
584
+ });
585
+ if (learning && learningContext)
586
+ await saveAgentLearningSettings(transaction, learningContext, {
587
+ scope: learning.scope,
588
+ source: { kind: "scheduled_task", id: task.id },
589
+ operationId: crypto.randomUUID(),
590
+ expectedVersion: 0,
591
+ settings: learning.settings,
592
+ });
593
+ return task;
594
+ }),
595
+ );
596
+ }
597
+
598
+ /**
599
+ * Freeze authenticated credential restrictions for every creation lane, and
600
+ * the creating session's boundary onto an agent-created task so the
601
+ * sessions generated for it inherit exactly what the creator could see and
602
+ * do, never the deployment default. Tools are the session's effective
603
+ * model-visible selection under the deployment ceiling; permissions are the
604
+ * session's effective first-party set intersected with what the calling
605
+ * grant actually holds (a narrowly delegated spawn token cannot hand a
606
+ * schedule more than itself). The session access policy is copied from the
607
+ * projection when it exposes those facts; each absent fact is stored as null
608
+ * so a generated session keeps its own default for that key.
609
+ */
610
+ export async function frozenScheduledTaskCreatorPolicy(input: {
611
+ db: Database;
612
+ settings: Settings;
613
+ grant: AccessGrant;
614
+ authorization?: AccessGrantAuthorization | undefined;
615
+ actor: Extract<SessionCommandActor, { type: "agent_attempt" }> | null;
616
+ }): Promise<ScheduledTaskCreatorPolicy | null> {
617
+ let restricted =
618
+ (input.authorization?.grant === input.grant &&
619
+ isDeveloperSetupAuthorization(input.authorization)) ||
620
+ isDeveloperSetupGrant(input.grant);
621
+ if (!input.actor) {
622
+ if (input.grant.permissionMode === "explicit") {
623
+ const permissions = DEFAULT_FIRST_PARTY_MCP_PERMISSIONS.filter((permission) =>
624
+ hasPermission(input.grant.permissions, permission, "explicit"),
625
+ );
626
+ if (permissions.length === 0) {
627
+ throw new HTTPException(403, {
628
+ message:
629
+ "the organization key holds no first-party MCP permission it could delegate to scheduled runs",
630
+ });
631
+ }
632
+ return {
633
+ firstPartyMcpTools: null,
634
+ firstPartyMcpPermissions: permissions,
635
+ sessionPolicy: null,
636
+ ...(restricted ? { credentialRestriction: "developer_setup" as const } : {}),
637
+ };
638
+ }
639
+ // A restriction is not an agent tool/permission selection. Keep the exact
640
+ // first-party and session defaults of ordinary API/service/asUser tasks.
641
+ return restricted
642
+ ? {
643
+ firstPartyMcpTools: null,
644
+ firstPartyMcpPermissions: null,
645
+ sessionPolicy: null,
646
+ credentialRestriction: "developer_setup",
647
+ }
648
+ : null;
649
+ }
650
+ const session = await getSession(input.db, input.grant.workspaceId, input.actor.sessionId);
651
+ if (!session) {
652
+ throw new HTTPException(403, {
653
+ message: "the calling agent session is not available in this workspace",
654
+ });
655
+ }
656
+ const turn = await getSessionTurnForAttempt(
657
+ input.db,
658
+ input.grant.workspaceId,
659
+ input.actor.sessionId,
660
+ input.actor.attemptId,
661
+ );
662
+ if (!turn || turn.id !== input.actor.turnId) {
663
+ throw new HTTPException(403, { message: "the calling agent attempt is not available" });
664
+ }
665
+ // These are server-frozen policies, not task/agentConfig metadata. The DB
666
+ // insert also verifies this exact actor under its ownership locks.
667
+ const turnPolicy = readTurnExecutionPolicyV1(turn.metadata);
668
+ const sessionPolicy = readTurnExecutionPolicyV1(session.metadata);
669
+ restricted ||= Boolean(
670
+ (turnPolicy.kind === "valid" &&
671
+ turnPolicy.policy.credentialRestriction === "developer_setup") ||
672
+ (sessionPolicy.kind === "valid" &&
673
+ sessionPolicy.policy.credentialRestriction === "developer_setup"),
674
+ );
675
+ const firstPartyMcpTools = allowedFirstPartyMcpToolsForSession(
676
+ input.settings,
677
+ session.firstPartyMcpTools,
678
+ );
679
+ const firstPartyMcpPermissions = (
680
+ session.firstPartyMcpPermissions ?? [...DEFAULT_FIRST_PARTY_MCP_PERMISSIONS]
681
+ ).filter((permission) =>
682
+ hasPermission(input.grant.permissions, permission, input.grant.permissionMode),
683
+ );
684
+ requireExplicitPermissionDelegation(input.grant, firstPartyMcpPermissions);
685
+ if (firstPartyMcpPermissions.length === 0) {
686
+ throw new HTTPException(403, {
687
+ message:
688
+ "the calling agent session holds no first-party MCP permission it could delegate to scheduled runs",
689
+ });
690
+ }
691
+ // The projection facts are validated through the access-scope contract so
692
+ // only a well-formed value is frozen; anything else stores null for that key.
693
+ const projection = session as unknown as Record<string, unknown>;
694
+ const agentAccess = SessionAgentAccess.safeParse(projection["agentAccess"]);
695
+ const scopeSubjectId = SessionScopeSubjectId.safeParse(projection["scopeSubjectId"]);
696
+ const memoryScope = SessionMemoryScope.safeParse(projection["memoryScope"]);
697
+ return {
698
+ firstPartyMcpTools,
699
+ firstPartyMcpPermissions,
700
+ ...(restricted ? { credentialRestriction: "developer_setup" as const } : {}),
701
+ sessionPolicy: {
702
+ agentAccess: agentAccess.success ? agentAccess.data : null,
703
+ scopeSubjectId: scopeSubjectId.success ? scopeSubjectId.data : null,
704
+ memoryScope: memoryScope.success ? memoryScope.data : null,
705
+ },
706
+ };
707
+ }
708
+
709
+ function nestedPostgresMessage(error: unknown): string | null {
710
+ const queue: unknown[] = [error];
711
+ const seen = new Set<unknown>();
712
+ while (queue.length > 0 && seen.size < 64) {
713
+ const current = queue.shift();
714
+ if (!current || typeof current !== "object" || seen.has(current)) continue;
715
+ seen.add(current);
716
+ const record = current as Record<string, unknown>;
717
+ if (typeof record.code === "string" && typeof record.message === "string") {
718
+ return record.message;
719
+ }
720
+ for (const key of ["cause", "errors", "error", "originalError"]) {
721
+ const nested = record[key];
722
+ if (Array.isArray(nested)) queue.push(...nested);
723
+ else if (nested !== undefined) queue.push(nested);
724
+ }
725
+ }
726
+ return null;
727
+ }
728
+
729
+ /**
730
+ * Scheduled-task authority writes fail closed inside SECURITY DEFINER seams
731
+ * (42501): a non-human writer delegating resources, an authorizer without
732
+ * active workspace membership, or a foreign human retaining another subject's
733
+ * grants. Those are caller-resolvable conflicts, not server faults.
734
+ */
735
+ export async function withScheduledTaskAuthorityWriteErrors<T>(run: () => Promise<T>): Promise<T> {
736
+ try {
737
+ return await run();
738
+ } catch (error) {
739
+ if (error instanceof ScheduledTaskHeadChangedError)
740
+ throw new HTTPException(409, {
741
+ message: "Scheduled task changed. Reload it before saving.",
742
+ });
743
+ if (nestedPostgresSqlState(error) === "40001")
744
+ throw new HTTPException(409, {
745
+ message: "Scheduled task learning settings changed. Reload the task before saving.",
746
+ });
747
+ if (nestedPostgresSqlState(error) === "42501") {
748
+ const detail = nestedPostgresMessage(error);
749
+ throw new HTTPException(409, {
750
+ message: detail
751
+ ? `scheduled task authority denied: ${detail}`
752
+ : "scheduled task authority denied",
753
+ });
754
+ }
755
+ throw error;
756
+ }
757
+ }
758
+
759
+ /** API/MCP-facing update that maps authority-write denials to 409. */
760
+ export async function updateScheduledTaskForApi(
761
+ db: Database,
762
+ grant: AccessGrant,
763
+ taskId: string,
764
+ update: UpdateScheduledTaskInput,
765
+ learning?: {
766
+ authorization: AccessGrantAuthorization;
767
+ request: NonNullable<UpdateScheduledTaskPayload["agentLearning"]>;
768
+ restoreState: ScheduledTaskRestoreState;
769
+ },
770
+ ): Promise<ScheduledTask> {
771
+ const workspaceId = grant.workspaceId;
772
+ const beforeUpdateCommit = update.beforeUpdateCommit;
773
+ update = {
774
+ ...update,
775
+ beforeUpdateCommit: async (tx) => {
776
+ await assertScheduledTaskMutationOwner(tx, grant, taskId);
777
+ await beforeUpdateCommit?.(tx);
778
+ },
779
+ };
780
+ if (learning) {
781
+ const context = await knowledgeContextForAccess(
782
+ { db },
783
+ learning.authorization,
784
+ "scheduled_tasks:manage",
785
+ );
786
+ if (context.actor.kind !== "human")
787
+ throw new HTTPException(403, {
788
+ message: "Only an authenticated person can configure Agent learning",
789
+ });
790
+ context.actor.settingsScopes = [learning.request.scope];
791
+ return withScheduledTaskAuthorityWriteErrors(() =>
792
+ db.transaction(async (tx) => {
793
+ const source = { kind: "scheduled_task" as const, id: taskId };
794
+ const baselineScope = learning.request.baselineScope ?? learning.request.scope;
795
+ // Lock policy owners in one order before comparing the draft baseline.
796
+ // A destination change must not race an edit in its previous owner layer.
797
+ const subjectId = context.actor.kind === "human" ? context.actor.subjectId : "";
798
+ const owners = [
799
+ ...new Set(
800
+ [baselineScope, learning.request.scope].map((scope) =>
801
+ scope === "personal" ? `personal:${subjectId}` : `workspace:${workspaceId}`,
802
+ ),
803
+ ),
804
+ ].sort();
805
+ for (const owner of owners)
806
+ await tx.execute(
807
+ dbSql`SELECT pg_advisory_xact_lock(hashtextextended(${`agent-learning:${context.accountId}:${owner}`},0))`,
808
+ );
809
+ const baseline = await getAgentLearningSettings(tx, context, baselineScope, source);
810
+ if (baseline.version !== learning.request.expectedVersion)
811
+ throw new HTTPException(409, {
812
+ message: "Scheduled task learning settings changed. Reload the task before saving.",
813
+ });
814
+ const task = await updateScheduledTask(tx, workspaceId, taskId, update);
815
+ const prior = await getAgentLearningSettings(tx, context, learning.request.scope, source);
816
+ const saved = await saveAgentLearningSettings(tx, context, {
817
+ scope: learning.request.scope,
818
+ operationId: learning.request.operationId,
819
+ expectedVersion:
820
+ baselineScope === learning.request.scope
821
+ ? learning.request.expectedVersion
822
+ : prior.version,
823
+ source,
824
+ settings: {
825
+ knowledge: "inherit",
826
+ instructions: "inherit",
827
+ skills: "inherit",
828
+ ...learning.request.settings,
829
+ },
830
+ });
831
+ learning.restoreState.learning = {
832
+ context,
833
+ scope: learning.request.scope,
834
+ settings: prior.settings,
835
+ expectedVersion: saved.version,
836
+ };
837
+ return task;
838
+ }),
839
+ );
840
+ }
841
+ return await withScheduledTaskAuthorityWriteErrors(() =>
842
+ updateScheduledTask(db, workspaceId, taskId, update),
843
+ );
844
+ }
845
+
846
+ export async function assertScheduledTaskMutationOwner(
847
+ db: Database,
848
+ grant: AccessGrant,
849
+ taskId: string,
850
+ ): Promise<void> {
851
+ const writer = scheduledTaskInitiatorForGrant(grant);
852
+ const matches = await scheduledTaskMutationOwnerMatches(db, {
853
+ workspaceId: grant.workspaceId,
854
+ taskId,
855
+ ...(writer.initiator ? { createdBy: writer.initiator } : {}),
856
+ ...(writer.context ? { createdByContext: writer.context } : {}),
857
+ createdByActor: writer.actor ?? null,
200
858
  });
859
+ if (!matches)
860
+ throw new HTTPException(403, {
861
+ message: "Only the schedule owner can change or run it. Copy it to create your own schedule.",
862
+ });
863
+ }
864
+
865
+ export async function triggerScheduledTaskForGrant(
866
+ db: Database,
867
+ grant: AccessGrant,
868
+ workflowClient: SessionWorkflowClient,
869
+ input: Parameters<SessionWorkflowClient["triggerScheduledTask"]>[0],
870
+ ): Promise<void> {
871
+ await db.transaction(async (tx) => {
872
+ await assertScheduledTaskMutationOwner(tx, grant, input.task.id);
873
+ if (isRetiredNativeAtlassianTask(input.task))
874
+ throw new HTTPException(410, { message: ATLASSIAN_NATIVE_RETIRED_MESSAGE });
875
+ const actor = creationInitiatorForGrant(grant).actor ?? null;
876
+ const restriction = await scheduledTaskCredentialRestrictionForGrant(tx, grant, actor);
877
+ // A caller cannot supply or clear the trusted ceiling. Ownerless and
878
+ // same-human schedules can be triggered by a different credential than
879
+ // their creator; restrict that accepted run, not the durable task.
880
+ const { credentialRestriction: _untrustedRestriction, ...trigger } = input;
881
+ await workflowClient.triggerScheduledTask({
882
+ ...trigger,
883
+ ...(restriction ? { credentialRestriction: restriction } : {}),
884
+ });
885
+ });
886
+ }
887
+
888
+ async function scheduledTaskCredentialRestrictionForGrant(
889
+ db: Database,
890
+ grant: AccessGrant,
891
+ actor: Extract<SessionCommandActor, { type: "agent_attempt" }> | null,
892
+ ): Promise<"developer_setup" | undefined> {
893
+ if (isDeveloperSetupGrant(grant)) return "developer_setup";
894
+ if (!actor) return undefined;
895
+ const session = await getSession(db, grant.workspaceId, actor.sessionId);
896
+ const turn = await getSessionTurnForAttempt(
897
+ db,
898
+ grant.workspaceId,
899
+ actor.sessionId,
900
+ actor.attemptId,
901
+ );
902
+ if (!session || !turn || turn.id !== actor.turnId) {
903
+ throw new HTTPException(403, { message: "the calling agent attempt is not available" });
904
+ }
905
+ const policies = [
906
+ readTurnExecutionPolicyV1(turn.metadata),
907
+ readTurnExecutionPolicyV1(session.metadata),
908
+ ];
909
+ return policies.some(
910
+ (policy) =>
911
+ policy.kind === "valid" && policy.policy.credentialRestriction === "developer_setup",
912
+ )
913
+ ? "developer_setup"
914
+ : undefined;
201
915
  }
202
916
 
203
917
  export async function validateScheduledTaskTarget(input: {
@@ -211,6 +925,8 @@ export async function validateScheduledTaskTarget(input: {
211
925
  rigId: string | null | undefined;
212
926
  agentConfig: ScheduledTaskAgentConfig;
213
927
  missingTargetStatus?: 404 | 422;
928
+ /** Leaving a cancelled chat still requires control, but not revivability. */
929
+ departingTarget?: boolean;
214
930
  }): Promise<Session | null> {
215
931
  if (input.runMode !== "existing_session") {
216
932
  if (input.targetSessionId) {
@@ -262,38 +978,89 @@ export async function validateScheduledTaskTarget(input: {
262
978
  if (!session || session.accountId !== input.grant.accountId) {
263
979
  throw new HTTPException(404, { message: "target session not found" });
264
980
  }
265
- if (session.status === "cancelled") {
981
+ if (session.status === "cancelled" && !input.departingTarget) {
266
982
  throw new HTTPException(409, {
267
983
  message: "target session is cancelled; choose a revivable session",
268
984
  });
269
985
  }
270
- if ((session.variableSetId ?? null) !== (input.variableSetId ?? null)) {
986
+ // The target owns execution settings. Admission freezes and revalidates its
987
+ // current policy under the scheduled owner; duplicated task fields confer no access.
988
+ return session;
989
+ }
990
+
991
+ export async function validateScheduledTaskMachineTarget(input: {
992
+ settings: Settings;
993
+ db: Database;
994
+ grant: AccessGrant;
995
+ runMode: ScheduledTask["runMode"];
996
+ agentConfig: ScheduledTaskAgentConfig;
997
+ requireOnline?: boolean;
998
+ }): Promise<{
999
+ sandboxId: string;
1000
+ enrollmentId: string;
1001
+ sandboxOs: Session["sandboxOs"];
1002
+ } | null> {
1003
+ const machineTarget = input.agentConfig.machineTarget;
1004
+ if (!machineTarget) {
1005
+ if (
1006
+ input.runMode !== "existing_session" &&
1007
+ (input.agentConfig.sandboxBackend ?? input.settings.sandboxBackend) === "selfhosted"
1008
+ ) {
1009
+ throw new HTTPException(422, {
1010
+ message:
1011
+ "self-hosted scheduled tasks require a Connected Machine; select a machine before saving",
1012
+ });
1013
+ }
1014
+ return null;
1015
+ }
1016
+ if (input.runMode === "existing_session") {
271
1017
  throw new HTTPException(422, {
272
- message: "target session variableSet attachment does not match the scheduled task",
1018
+ message: "machineTarget cannot be used with an existing-session target",
273
1019
  });
274
1020
  }
275
- if (input.rigId && input.rigId !== session.rigId) {
1021
+ if (!input.settings.sandboxOwnershipEnabled || !input.settings.sandboxSelfhostedEnabled) {
276
1022
  throw new HTTPException(422, {
277
- message: "target session rig does not match the scheduled task",
1023
+ message: "Connected Machines are not enabled for scheduled tasks in this deployment",
278
1024
  });
279
1025
  }
280
- if (
281
- input.agentConfig.sandboxBackend !== undefined &&
282
- input.agentConfig.sandboxBackend !== session.sandboxBackend
283
- ) {
1026
+ const access = {
1027
+ accountId: input.grant.accountId,
1028
+ workspaceId: input.grant.workspaceId,
1029
+ subjectId: input.grant.subjectId,
1030
+ };
1031
+ const sandbox = await getSandbox(input.db, access, machineTarget.targetSandboxId);
1032
+ if (!sandbox || sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
284
1033
  throw new HTTPException(422, {
285
- message: "target session sandbox backend does not match the scheduled task",
1034
+ message: "the selected Connected Machine is unavailable",
286
1035
  });
287
1036
  }
288
- if (
289
- scheduledSlackBotConnectionId(session.metadata) !==
290
- (input.agentConfig.slackBotConnectionId ?? null)
291
- ) {
1037
+ if (sandbox.scope === "user") {
292
1038
  throw new HTTPException(422, {
293
- message: "target session OpenGeni Slack bot binding does not match the scheduled task",
1039
+ message:
1040
+ "personal Connected Machines cannot run unattended schedules; select a workspace or organization machine",
294
1041
  });
295
1042
  }
296
- return session;
1043
+ const enrollment = input.requireOnline
1044
+ ? await getLiveEnrollmentConnection(input.db, access, sandbox.enrollmentId)
1045
+ : await getEnrollment(input.db, access, sandbox.enrollmentId);
1046
+ if (!enrollment || enrollment.status !== "active") {
1047
+ throw new HTTPException(422, {
1048
+ message: input.requireOnline
1049
+ ? "the selected Connected Machine is offline"
1050
+ : "the selected Connected Machine is unavailable",
1051
+ });
1052
+ }
1053
+ if (input.requireOnline && !enrollment.workspaceRoot) {
1054
+ throw new HTTPException(422, {
1055
+ message:
1056
+ "the selected Connected Machine has not reported a workspace root; reconnect it with a current agent",
1057
+ });
1058
+ }
1059
+ return {
1060
+ sandboxId: sandbox.id,
1061
+ enrollmentId: sandbox.enrollmentId,
1062
+ sandboxOs: enrollment.os,
1063
+ };
297
1064
  }
298
1065
 
299
1066
  export function scheduledTaskForGrant(task: ScheduledTask, grant: AccessGrant): ScheduledTask {
@@ -313,17 +1080,152 @@ export function scheduledTaskRunForGrant<T extends { sessionId: string | null }>
313
1080
  return { ...run, sessionId: null };
314
1081
  }
315
1082
 
1083
+ export function scheduledTaskAuthorityUpdateForGrant(
1084
+ grant: AccessGrant,
1085
+ ): Pick<
1086
+ UpdateScheduledTaskInput,
1087
+ | "refreshPersonalResourceAuthority"
1088
+ | "authorityUpdatedBy"
1089
+ | "authorityUpdatedByContext"
1090
+ | "authorityUpdatedByActor"
1091
+ > {
1092
+ const writer = scheduledTaskInitiatorForGrant(grant);
1093
+ return {
1094
+ refreshPersonalResourceAuthority: true,
1095
+ ...(writer.initiator ? { authorityUpdatedBy: writer.initiator } : {}),
1096
+ ...(writer.context ? { authorityUpdatedByContext: writer.context } : {}),
1097
+ authorityUpdatedByActor: writer.actor ?? null,
1098
+ };
1099
+ }
1100
+
1101
+ /**
1102
+ * The Sandbox Environment a new task stores. An explicit id or null is the
1103
+ * caller's choice. An omitted value is resolved once, here, and frozen on the
1104
+ * task, the way session create resolves an omitted rigId:
1105
+ * - an existing-session task keeps its target session's own environment;
1106
+ * - a Connected Machine task stores none: environment setup and Variable Set
1107
+ * injection never reach a machine, so a binding would only add a fire-time
1108
+ * failure mode;
1109
+ * - otherwise the workspace default, when the creator can see it and it has an
1110
+ * active version. A stale default degrades to none, like session create.
1111
+ * The default's own Variable Sets must be attachable by the creator, exactly
1112
+ * as session create requires, so a workspace default never gives a task
1113
+ * secrets its creator could not attach. Later changes to the workspace default
1114
+ * do not move an existing task.
1115
+ */
1116
+ async function resolveScheduledTaskCreateRigId(input: {
1117
+ settings: Settings;
1118
+ db: Database;
1119
+ grant: AccessGrant;
1120
+ requestedRigId: string | null | undefined;
1121
+ runMode: ScheduledTask["runMode"];
1122
+ target: Session | null;
1123
+ agentConfig: ScheduledTaskAgentConfig;
1124
+ }): Promise<string | null> {
1125
+ if (input.requestedRigId !== undefined) return input.requestedRigId;
1126
+ if (input.runMode === "existing_session") return input.target?.rigId ?? null;
1127
+ if (input.agentConfig.machineTarget) return null;
1128
+ const defaultRigId = await getWorkspaceDefaultRigId(input.db, input.grant.workspaceId);
1129
+ if (!defaultRigId) return null;
1130
+ const rig = await getRig(
1131
+ input.db,
1132
+ {
1133
+ accountId: input.grant.accountId,
1134
+ workspaceId: input.grant.workspaceId,
1135
+ subjectId: input.grant.subjectId,
1136
+ },
1137
+ defaultRigId,
1138
+ );
1139
+ if (!rig?.activeVersion) return null;
1140
+ await requireScheduledTaskRigVariableSetAttachments({
1141
+ settings: input.settings,
1142
+ db: input.db,
1143
+ grant: input.grant,
1144
+ workspaceId: input.grant.workspaceId,
1145
+ rig,
1146
+ });
1147
+ return rig.id;
1148
+ }
1149
+
1150
+ type ScheduledTaskRig = NonNullable<Awaited<ReturnType<typeof getRig>>>;
1151
+
1152
+ /**
1153
+ * Binding a Sandbox Environment to generated sessions layers its active
1154
+ * version's default Variable Sets into every turn, so the task writer must be
1155
+ * allowed to attach each of them, exactly as session create requires. An
1156
+ * environment without an active version has nothing to check yet.
1157
+ */
1158
+ async function requireScheduledTaskRigVariableSetAttachments(input: {
1159
+ settings: Settings;
1160
+ db: Database;
1161
+ grant: AccessGrant;
1162
+ workspaceId: string;
1163
+ rig: ScheduledTaskRig;
1164
+ }): Promise<void> {
1165
+ for (const variableSetId of new Set(input.rig.activeVersion?.defaultVariableSetIds ?? [])) {
1166
+ await validateVariableSetAttachment(
1167
+ { settings: input.settings, db: input.db },
1168
+ input.grant,
1169
+ input.workspaceId,
1170
+ variableSetId,
1171
+ );
1172
+ }
1173
+ }
1174
+
1175
+ /** Message authority covers both direct sets and the environment's defaults. */
1176
+ async function requireScheduledMessageVariableSetUse(input: {
1177
+ db: Database;
1178
+ grant: AccessGrant;
1179
+ target?: Session | null;
1180
+ variableSetId?: string | null;
1181
+ rigId?: string | null;
1182
+ }): Promise<void> {
1183
+ if (hasPermission(input.grant.permissions, "variable-sets:use", input.grant.permissionMode))
1184
+ return;
1185
+ const directSets = input.target
1186
+ ? Boolean(input.target.variableSetId || input.target.variableSetIds?.length)
1187
+ : Boolean(input.variableSetId);
1188
+ if (directSets) requirePermission(input.grant, "variable-sets:use");
1189
+ const rigId = input.target ? input.target.rigId : input.rigId;
1190
+ if (!rigId) return;
1191
+ const access = {
1192
+ accountId: input.grant.accountId,
1193
+ workspaceId: input.grant.workspaceId,
1194
+ subjectId: input.grant.subjectId,
1195
+ };
1196
+ let defaultVariableSetIds: string[];
1197
+ if (input.target) {
1198
+ // A chat rides its exact retained version. The rig's active version only
1199
+ // determines defaults for newly generated chats.
1200
+ if (!input.target.rigVersionId) return;
1201
+ const pinned = await getScheduledScopedRigVersionMetadata(
1202
+ input.db,
1203
+ access,
1204
+ rigId,
1205
+ input.target.rigVersionId,
1206
+ );
1207
+ if (!pinned)
1208
+ throw new HTTPException(409, { message: "Scheduled task chat environment is unavailable" });
1209
+ defaultVariableSetIds = pinned.version.defaultVariableSetIds;
1210
+ } else {
1211
+ const rig = await requireScheduledTaskRig(input.db, access, rigId);
1212
+ defaultVariableSetIds = rig.activeVersion?.defaultVariableSetIds ?? [];
1213
+ }
1214
+ if (defaultVariableSetIds.length) requirePermission(input.grant, "variable-sets:use");
1215
+ }
1216
+
316
1217
  // Validate a scheduled task's rig reference: it must name a rig in the
317
1218
  // workspace. A missing/cross-workspace id is a 422 (RLS-invisible == missing).
318
1219
  async function requireScheduledTaskRig(
319
1220
  db: Database,
320
- workspaceId: string,
1221
+ access: { accountId: string; workspaceId: string; subjectId: string },
321
1222
  rigId: string,
322
- ): Promise<void> {
323
- const rig = await getRig(db, workspaceId, rigId);
1223
+ ): Promise<ScheduledTaskRig> {
1224
+ const rig = await getRig(db, access, rigId);
324
1225
  if (!rig) {
325
1226
  throw new HTTPException(422, { message: `unknown rigId: ${rigId}` });
326
1227
  }
1228
+ return rig;
327
1229
  }
328
1230
 
329
1231
  export async function validatedScheduledTaskUpdate(input: {
@@ -331,76 +1233,238 @@ export async function validatedScheduledTaskUpdate(input: {
331
1233
  db: Database;
332
1234
  objectStorage: ObjectStorageDependency;
333
1235
  grant: AccessGrant;
1236
+ authorization?: AccessGrantAuthorization;
334
1237
  existing: ScheduledTask;
335
1238
  payload: UpdateScheduledTaskPayload;
336
1239
  /** See createValidatedScheduledTask; only consulted when agentConfig is updated. */
337
1240
  toolsProvided?: boolean;
338
1241
  sessionAuthorization?: SessionAuthorizationPort | null | undefined;
339
1242
  authorizationSurface?: SessionAuthorizationSurface | undefined;
1243
+ /** Proves the bot may post in a newly chosen task Slack channel. */
1244
+ verifySlackChannel?: ScheduledTaskSlackChannelVerifier | undefined;
340
1245
  }): Promise<UpdateScheduledTaskInput> {
341
- const update: UpdateScheduledTaskInput = {};
342
- const existingKnowledge = input.existing.action.kind === "knowledge_source_sync";
343
- if (input.payload.action && input.payload.action.kind !== input.existing.action.kind) {
1246
+ const requestedPrompt =
1247
+ input.payload.prompt ??
1248
+ input.payload.agentConfigPatch?.prompt ??
1249
+ input.payload.agentConfig?.prompt;
1250
+ if (requestedPrompt !== undefined && !requestedPrompt.trim())
1251
+ throw new HTTPException(422, { message: "scheduled task prompt is required" });
1252
+ const retainedPrompt =
1253
+ input.payload.agentConfig === undefined
1254
+ ? (requestedPrompt ?? input.existing.agentConfig.prompt)
1255
+ : undefined;
1256
+ if (
1257
+ input.payload.expectedExecutionDigest !== undefined &&
1258
+ input.payload.expectedExecutionDigest !== input.existing.executionDigest
1259
+ )
344
1260
  throw new HTTPException(409, {
345
- message: "scheduled task action kind is immutable; create a new schedule",
1261
+ message: "Scheduled task changed. Reload it before saving.",
1262
+ });
1263
+ if (input.payload.prompt !== undefined)
1264
+ input = {
1265
+ ...input,
1266
+ payload: {
1267
+ ...input.payload,
1268
+ agentConfigPatch: {
1269
+ ...input.payload.agentConfigPatch,
1270
+ prompt: input.payload.prompt,
1271
+ },
1272
+ },
1273
+ };
1274
+ if (input.payload.targetSessionId && input.payload.runMode === undefined)
1275
+ input = {
1276
+ ...input,
1277
+ payload: { ...input.payload, runMode: "existing_session" },
1278
+ };
1279
+ const requestedMode = input.payload.runMode ?? input.existing.runMode;
1280
+ if (requestedMode === "existing_session") {
1281
+ const config = input.payload.agentConfig ?? input.existing.agentConfig;
1282
+ if (config.knowledgeSource)
1283
+ throw new HTTPException(422, {
1284
+ message: "Source ingestion requires a separate scheduled agent",
1285
+ });
1286
+ const target = await validateScheduledTaskTarget({
1287
+ ...input,
1288
+ runMode: "existing_session",
1289
+ targetSessionId: input.payload.targetSessionId ?? input.existing.targetSessionId,
1290
+ variableSetId: null,
1291
+ rigId: null,
1292
+ agentConfig: scheduledSessionMessageConfig(config),
346
1293
  });
347
- }
348
- if (existingKnowledge) {
349
1294
  if (
350
- input.payload.agentConfig !== undefined ||
351
- input.payload.runMode !== undefined ||
352
- input.payload.targetSessionId !== undefined ||
353
- input.payload.variableSetId !== undefined ||
354
- input.payload.rigId !== undefined
355
- ) {
1295
+ input.payload.variableSetId &&
1296
+ ![target?.variableSetId, ...(target?.variableSetIds ?? [])].includes(
1297
+ input.payload.variableSetId,
1298
+ )
1299
+ )
356
1300
  throw new HTTPException(422, {
357
- message: "knowledge source schedules do not accept agent/session configuration",
1301
+ message:
1302
+ "Existing-chat schedules use the chat's Variable Sets. Change the chat's attachments separately.",
358
1303
  });
359
- }
360
- if (input.payload.overlapPolicy === "allow_concurrent") {
1304
+ if (input.payload.rigId && input.payload.rigId !== target?.rigId)
361
1305
  throw new HTTPException(422, {
362
- message: "knowledge source schedules require skip or buffer_one overlap",
1306
+ message:
1307
+ "Existing-chat schedules use the chat's environment. Change the chat's attachments separately.",
363
1308
  });
364
- }
365
- if (input.payload.action?.kind === "knowledge_source_sync") {
366
- await validateKnowledgeSourceSyncAction({
367
- db: input.db,
368
- grant: input.grant,
369
- action: input.payload.action,
1309
+ const retargeting =
1310
+ input.existing.runMode !== "existing_session" ||
1311
+ target?.id !== input.existing.targetSessionId;
1312
+ const previousTargetSessionId =
1313
+ input.existing.runMode === "existing_session"
1314
+ ? input.existing.targetSessionId
1315
+ : input.existing.runMode === "reusable_session"
1316
+ ? input.existing.reusableSessionId
1317
+ : null;
1318
+ const previousTarget =
1319
+ retargeting && previousTargetSessionId
1320
+ ? await validateScheduledTaskTarget({
1321
+ ...input,
1322
+ runMode: "existing_session",
1323
+ targetSessionId: previousTargetSessionId,
1324
+ departingTarget: true,
1325
+ variableSetId: null,
1326
+ rigId: null,
1327
+ agentConfig: scheduledSessionMessageConfig(input.existing.agentConfig),
1328
+ })
1329
+ : null;
1330
+ const previousVariableSets = previousTarget
1331
+ ? [
1332
+ ...new Set(
1333
+ [previousTarget.variableSetId, ...(previousTarget.variableSetIds ?? [])].filter(
1334
+ (id): id is string => Boolean(id),
1335
+ ),
1336
+ ),
1337
+ ]
1338
+ : input.existing.variableSetId
1339
+ ? [input.existing.variableSetId]
1340
+ : [];
1341
+ const targetVariableSets = new Set([target?.variableSetId, ...(target?.variableSetIds ?? [])]);
1342
+ const removedVariableSetIds = retargeting
1343
+ ? previousVariableSets.filter((id) => !targetVariableSets.has(id))
1344
+ : [];
1345
+ const previousRigId = previousTarget ? previousTarget.rigId : input.existing.rigId;
1346
+ const changedRig = retargeting && previousRigId && previousRigId !== target?.rigId;
1347
+ if ((removedVariableSetIds.length || changedRig) && !input.payload.adoptSessionSettings) {
1348
+ const detail = {
1349
+ code: "scheduled_target_access_change",
1350
+ targetSessionId: target!.id,
1351
+ removedVariableSetIds,
1352
+ removedVariableSetCount: removedVariableSetIds.length,
1353
+ removedRigId: changedRig ? previousRigId : null,
1354
+ resolution:
1355
+ "Review the destination's access, then retry with adoptSessionSettings=true and the reviewed expectedExecutionDigest to use that chat's attachments.",
1356
+ };
1357
+ throw new HTTPException(409, {
1358
+ message:
1359
+ "The destination chat has different attachments. Review the access change before moving this schedule.",
1360
+ cause: detail,
370
1361
  });
371
- update.action = input.payload.action;
372
- }
373
- if (input.payload.name !== undefined)
374
- update.name = trimmedScheduledTaskName(input.payload.name);
375
- if (input.payload.status !== undefined) update.status = input.payload.status;
376
- if (input.payload.schedule !== undefined) {
377
- validateScheduledTaskSchedule(input.payload.schedule);
378
- update.schedule = input.payload.schedule;
379
- }
380
- if (input.payload.overlapPolicy !== undefined) {
381
- update.overlapPolicy = input.payload.overlapPolicy;
382
1362
  }
383
- if (input.payload.metadata !== undefined) update.metadata = input.payload.metadata;
384
- return update;
1363
+ if (input.payload.agentConfig || input.payload.agentConfigPatch || retargeting)
1364
+ await requireScheduledMessageVariableSetUse({ db: input.db, grant: input.grant, target });
1365
+ // Server-side normalization preserves exact message fields and authority choices;
1366
+ // callers never reconstruct an incomplete get projection to remove old settings.
1367
+ input = {
1368
+ ...input,
1369
+ payload: {
1370
+ ...input.payload,
1371
+ ...(retargeting
1372
+ ? {
1373
+ agentConfig: scheduledSessionMessageConfig({
1374
+ ...config,
1375
+ ...input.payload.agentConfigPatch,
1376
+ prompt: input.payload.agentConfigPatch?.prompt ?? config.prompt,
1377
+ }),
1378
+ agentConfigPatch: undefined,
1379
+ variableSetId: null,
1380
+ rigId: null,
1381
+ }
1382
+ : {}),
1383
+ },
1384
+ };
1385
+ }
1386
+ if (input.payload.agentLearning) {
1387
+ const context = input.authorization
1388
+ ? await knowledgeContextForAccess(
1389
+ { db: input.db },
1390
+ input.authorization,
1391
+ "scheduled_tasks:manage",
1392
+ )
1393
+ : null;
1394
+ if (context?.actor.kind !== "human")
1395
+ throw new HTTPException(403, {
1396
+ message: "Only an authenticated person can configure Agent learning",
1397
+ });
1398
+ }
1399
+ const update: UpdateScheduledTaskInput = {
1400
+ expectedExecutionDigest: input.existing.executionDigest,
1401
+ };
1402
+ const requestedKnowledgeSource = input.payload.agentConfig?.knowledgeSource ?? null;
1403
+ if (
1404
+ isRetiredNativeAtlassianTask(input.existing) &&
1405
+ (input.payload.status === "active" || requestedKnowledgeSource)
1406
+ )
1407
+ throw new HTTPException(410, { message: ATLASSIAN_NATIVE_RETIRED_MESSAGE });
1408
+ const existingKnowledgeSource = scheduledTaskKnowledgeSource(input.existing);
1409
+ // Editing an ordinary source task's prompt/settings must not orphan its
1410
+ // connector binding. Deleting the task is the explicit source-disable path.
1411
+ if (
1412
+ input.payload.agentConfig &&
1413
+ input.existing.agentConfig.knowledgeSource &&
1414
+ !input.payload.agentConfig.knowledgeSource
1415
+ ) {
1416
+ input = {
1417
+ ...input,
1418
+ payload: {
1419
+ ...input.payload,
1420
+ agentConfig: {
1421
+ ...input.payload.agentConfig,
1422
+ knowledgeSource: input.existing.agentConfig.knowledgeSource,
1423
+ },
1424
+ },
1425
+ };
1426
+ }
1427
+ if (input.payload.action && input.payload.action.kind !== "agent_turn") {
1428
+ throw new HTTPException(422, { message: "Source ingestion uses an ordinary agent task" });
1429
+ }
1430
+ const knowledgeSource =
1431
+ input.payload.agentConfig?.knowledgeSource ?? scheduledTaskKnowledgeSource(input.existing);
1432
+ if (
1433
+ knowledgeSource &&
1434
+ requestedKnowledgeSource &&
1435
+ !isDeepStrictEqual(requestedKnowledgeSource, existingKnowledgeSource)
1436
+ ) {
1437
+ await validateKnowledgeSourceSyncAction({
1438
+ db: input.db,
1439
+ grant: input.grant,
1440
+ action: knowledgeSource,
1441
+ });
385
1442
  }
1443
+ if (
1444
+ knowledgeSource &&
1445
+ (input.payload.overlapPolicy ?? input.existing.overlapPolicy) === "allow_concurrent"
1446
+ )
1447
+ throw new HTTPException(422, { message: "Source tasks require skip or buffer_one overlap" });
1448
+ if (
1449
+ knowledgeSource?.destination.kind === "personal" &&
1450
+ (input.payload.runMode ?? input.existing.runMode) !== "new_session_per_run"
1451
+ )
1452
+ throw new HTTPException(422, {
1453
+ message: "Personal source tasks create a private session for each run",
1454
+ });
386
1455
  const existingTarget = input.existing.targetSessionId;
387
1456
  const nextRunMode = input.payload.runMode ?? input.existing.runMode;
1457
+ const materializedReusable =
1458
+ input.existing.runMode === "reusable_session" &&
1459
+ nextRunMode === "reusable_session" &&
1460
+ input.existing.reusableSessionId !== null;
1461
+ let reusableTarget: Session | null = null;
388
1462
  const nextTargetSessionId =
389
1463
  input.payload.targetSessionId !== undefined
390
1464
  ? input.payload.targetSessionId
391
1465
  : nextRunMode === "existing_session"
392
1466
  ? existingTarget
393
1467
  : null;
394
- if (
395
- input.existing.runMode === "reusable_session" &&
396
- input.existing.reusableSessionId &&
397
- nextRunMode === "existing_session"
398
- ) {
399
- throw new HTTPException(409, {
400
- message:
401
- "cannot target an existing session after this task created its reusable session; create a new task",
402
- });
403
- }
404
1468
  if (input.payload.name !== undefined) {
405
1469
  update.name = trimmedScheduledTaskName(input.payload.name);
406
1470
  }
@@ -424,8 +1488,7 @@ export async function validatedScheduledTaskUpdate(input: {
424
1488
  const nextVariableSetId = input.payload.variableSetId;
425
1489
  if (
426
1490
  (input.existing.variableSetId ?? null) !== (nextVariableSetId ?? null) &&
427
- input.existing.runMode === "reusable_session" &&
428
- input.existing.reusableSessionId
1491
+ materializedReusable
429
1492
  ) {
430
1493
  throw new HTTPException(409, {
431
1494
  message:
@@ -433,10 +1496,10 @@ export async function validatedScheduledTaskUpdate(input: {
433
1496
  });
434
1497
  }
435
1498
  if (nextVariableSetId === null) {
436
- if (input.existing.variableSetId !== null) {
1499
+ if (input.existing.variableSetId !== null && nextRunMode !== "existing_session") {
437
1500
  // Detaching is also an attachment change: it strips the secrets a
438
1501
  // task's instructions were designed around.
439
- requirePermission(input.grant, "variable-sets:use");
1502
+ requirePermission(input.grant, "variable-sets:attach");
440
1503
  }
441
1504
  update.variableSetId = null;
442
1505
  } else {
@@ -450,73 +1513,396 @@ export async function validatedScheduledTaskUpdate(input: {
450
1513
  }
451
1514
  }
452
1515
  if (input.payload.rigId !== undefined) {
453
- // The rig binds fresh per fire, so changing it on a reusable-session task is
454
- // harmless for the LIVE session (which keeps its own frozen version) and
455
- // only affects subsequent new-session fires — no live-session guard needed.
1516
+ if (materializedReusable && input.payload.rigId !== input.existing.rigId) {
1517
+ throw new HTTPException(409, {
1518
+ message: "A reusable-session task cannot change rigId after materialization; recreate it",
1519
+ });
1520
+ }
456
1521
  if (input.payload.rigId !== null) {
457
- await requireScheduledTaskRig(input.db, input.existing.workspaceId, input.payload.rigId);
1522
+ await requireScheduledTaskRig(
1523
+ input.db,
1524
+ {
1525
+ accountId: input.existing.accountId,
1526
+ workspaceId: input.existing.workspaceId,
1527
+ subjectId: input.grant.subjectId,
1528
+ },
1529
+ input.payload.rigId,
1530
+ );
458
1531
  }
459
1532
  update.rigId = input.payload.rigId;
460
1533
  }
461
- if (input.payload.agentConfig !== undefined) {
1534
+ // An edit that newly binds an environment to generated sessions needs the
1535
+ // same Variable Set attachment authority as create: a changed rigId, or a
1536
+ // switch away from an existing-session target that keeps the environment
1537
+ // the task adopted from that session.
1538
+ const nextRigId = input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId;
1539
+ if (
1540
+ nextRigId !== null &&
1541
+ !knowledgeSource &&
1542
+ nextRunMode !== "existing_session" &&
1543
+ (nextRigId !== input.existing.rigId || input.existing.runMode === "existing_session")
1544
+ ) {
1545
+ await requireScheduledTaskRigVariableSetAttachments({
1546
+ settings: input.settings,
1547
+ db: input.db,
1548
+ grant: input.grant,
1549
+ workspaceId: input.existing.workspaceId,
1550
+ rig: await requireScheduledTaskRig(
1551
+ input.db,
1552
+ {
1553
+ accountId: input.existing.accountId,
1554
+ workspaceId: input.existing.workspaceId,
1555
+ subjectId: input.grant.subjectId,
1556
+ },
1557
+ nextRigId,
1558
+ ),
1559
+ });
1560
+ }
1561
+ if (input.payload.agentConfig !== undefined || input.payload.agentConfigPatch !== undefined) {
462
1562
  // Editing the instructions of a task that injects workspace secrets is
463
1563
  // equivalent to attaching those secrets to new instructions, so it
464
1564
  // requires variable-sets:use even though plain task edits do not.
465
- const willHaveVariableSet =
466
- input.payload.variableSetId !== undefined
467
- ? input.payload.variableSetId !== null
468
- : Boolean(input.existing.variableSetId);
469
- if (willHaveVariableSet) {
470
- requirePermission(input.grant, "variable-sets:use");
1565
+ if (materializedReusable) {
1566
+ reusableTarget = await getSession(
1567
+ input.db,
1568
+ input.existing.workspaceId,
1569
+ input.existing.reusableSessionId!,
1570
+ );
1571
+ if (!reusableTarget || reusableTarget.accountId !== input.existing.accountId)
1572
+ throw new HTTPException(409, { message: "Scheduled task chat is unavailable" });
1573
+ }
1574
+ // Existing-chat targets were checked above. A materialized reusable chat
1575
+ // likewise owns its live attachments; obsolete creation defaults do not
1576
+ // determine whether editing its scheduled message needs secret-use access.
1577
+ if (nextRunMode !== "existing_session")
1578
+ await requireScheduledMessageVariableSetUse({
1579
+ db: input.db,
1580
+ grant: input.grant,
1581
+ target: reusableTarget,
1582
+ variableSetId:
1583
+ input.payload.variableSetId !== undefined
1584
+ ? input.payload.variableSetId
1585
+ : input.existing.variableSetId,
1586
+ rigId: nextRigId,
1587
+ });
1588
+ }
1589
+ if (input.payload.agentConfigPatch) {
1590
+ const patch = input.payload.agentConfigPatch;
1591
+ const model =
1592
+ patch.model === undefined ? undefined : canonicalConfiguredModel(input.settings, patch.model);
1593
+ if (model !== undefined && model !== null) {
1594
+ await assertWorkspaceModelPolicyAllows(
1595
+ input.db,
1596
+ input.settings,
1597
+ input.existing.workspaceId,
1598
+ model,
1599
+ );
471
1600
  }
1601
+ // Validate only the newly supplied model settings, not a reconstructed
1602
+ // bounded input. Legacy stored text, resources and selections stay exact.
1603
+ update.agentConfig = {
1604
+ ...input.existing.agentConfig,
1605
+ ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}),
1606
+ ...(model !== undefined && model !== null ? { model } : {}),
1607
+ ...(patch.reasoningEffort !== undefined ? { reasoningEffort: patch.reasoningEffort } : {}),
1608
+ };
1609
+ // Validation performs asynchronous authority checks before persistence.
1610
+ // Reuse the locked-row CAS so that merging this snapshot cannot erase a
1611
+ // concurrent config edit. The caller receives 409, never an automatic retry.
1612
+ update.expectedExecutionDigest = input.existing.executionDigest;
1613
+ }
1614
+ if (input.payload.agentConfig !== undefined) {
472
1615
  const nextAgentConfig = await validateScheduledTaskAgentConfig({
473
1616
  settings: input.settings,
474
1617
  db: input.db,
475
1618
  objectStorage: input.objectStorage,
476
1619
  grant: input.grant,
477
1620
  workspaceId: input.existing.workspaceId,
478
- payload: { agentConfig: input.payload.agentConfig },
1621
+ ...(input.authorization ? { authorization: input.authorization } : {}),
1622
+ payload: {
1623
+ agentConfig: input.payload.agentConfig,
1624
+ runMode: nextRunMode,
1625
+ targetSessionId:
1626
+ nextRunMode === "existing_session"
1627
+ ? nextTargetSessionId
1628
+ : input.existing.reusableSessionId,
1629
+ },
479
1630
  ...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
1631
+ ...(materializedReusable ? { retainedCreationConfig: input.existing.agentConfig } : {}),
480
1632
  });
481
1633
  if (
482
- input.existing.reusableSessionId &&
483
- input.existing.runMode === "reusable_session" &&
1634
+ materializedReusable &&
484
1635
  (input.existing.agentConfig.slackBotConnectionId ?? null) !==
485
1636
  (nextAgentConfig.slackBotConnectionId ?? null)
486
1637
  ) {
487
1638
  throw new HTTPException(409, {
488
1639
  message:
489
- "cannot change the OpenGeni Slack bot connection of a task with a live reusable session; recreate the task",
1640
+ "cannot change the Opengeni Slack bot connection of a task with a live reusable session; recreate the task",
490
1641
  });
491
1642
  }
1643
+ // Update text is exact, even when a full form submission reuses the saved
1644
+ // prompt while changing another setting. Creation keeps its trim convention.
1645
+ nextAgentConfig.prompt = retainedPrompt ?? input.payload.agentConfig.prompt;
1646
+ if (retainedPrompt !== undefined) {
1647
+ nextAgentConfig.resources = input.existing.agentConfig.resources;
1648
+ }
492
1649
  update.agentConfig = nextAgentConfig;
1650
+ }
1651
+ if (
1652
+ (update.agentConfig && !input.payload.agentConfigPatch) ||
1653
+ input.payload.connectionAccounts !== undefined
1654
+ ) {
1655
+ update.agentConfig = {
1656
+ ...(update.agentConfig ?? input.existing.agentConfig),
1657
+ connectionAccounts:
1658
+ input.payload.connectionAccounts ?? input.existing.agentConfig.connectionAccounts ?? [],
1659
+ };
1660
+ }
1661
+ const nextAgentConfig = {
1662
+ ...(update.agentConfig ?? input.existing.agentConfig),
1663
+ };
1664
+ // Retargeting rewrites a narrow patch into a full config above. Check the
1665
+ // exact final message after restoring retained fields, regardless of path.
1666
+ // An unedited legacy prompt remains movable even when above today's limit.
1667
+ if (
1668
+ requestedPrompt !== undefined &&
1669
+ scheduledOccurrencePayloadUtf8Bytes(nextAgentConfig) >
1670
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_MAX_BYTES -
1671
+ SCHEDULED_TASK_OCCURRENCE_PAYLOAD_INGRESS_HEADROOM_BYTES
1672
+ )
1673
+ throw new HTTPException(422, {
1674
+ message: "Updated scheduled message and attachments exceed the supported occurrence payload",
1675
+ });
1676
+ await validateScheduledTaskSlackChannel({
1677
+ grant: input.grant,
1678
+ authorization: input.authorization,
1679
+ previous: input.existing.agentConfig,
1680
+ next: nextAgentConfig,
1681
+ runMode: nextRunMode,
1682
+ reusableSessionCanPost:
1683
+ input.existing.runMode === "reusable_session" && input.existing.reusableSessionId !== null
1684
+ ? async () => {
1685
+ const chat = await getSession(
1686
+ input.db,
1687
+ input.existing.workspaceId,
1688
+ input.existing.reusableSessionId!,
1689
+ );
1690
+ return SCHEDULED_SLACK_BOT_POSTING_TOOLS.every(
1691
+ (tool) => chat?.firstPartyMcpTools?.includes(tool) === true,
1692
+ );
1693
+ }
1694
+ : null,
1695
+ verifySlackChannel: input.verifySlackChannel,
1696
+ });
1697
+ const authorityTargetChanged =
1698
+ nextRunMode !== input.existing.runMode ||
1699
+ nextTargetSessionId !== input.existing.targetSessionId ||
1700
+ (input.payload.variableSetId !== undefined &&
1701
+ input.payload.variableSetId !== input.existing.variableSetId) ||
1702
+ (input.payload.rigId !== undefined && input.payload.rigId !== input.existing.rigId);
1703
+ const materialExecutionChange =
1704
+ authorityTargetChanged ||
1705
+ input.payload.connectionAccounts !== undefined ||
1706
+ !isDeepStrictEqual(nextAgentConfig, input.existing.agentConfig) ||
1707
+ (input.payload.action !== undefined &&
1708
+ !isDeepStrictEqual(input.payload.action, input.existing.action)) ||
1709
+ (input.payload.schedule !== undefined &&
1710
+ !isDeepStrictEqual(input.payload.schedule, input.existing.schedule)) ||
1711
+ (input.payload.overlapPolicy !== undefined &&
1712
+ input.payload.overlapPolicy !== input.existing.overlapPolicy) ||
1713
+ (input.payload.metadata !== undefined &&
1714
+ !isDeepStrictEqual(input.payload.metadata, input.existing.metadata)) ||
1715
+ (input.existing.status === "paused" && input.payload.status === "active");
1716
+ if (
1717
+ materialExecutionChange &&
1718
+ nextRunMode !== "existing_session" &&
1719
+ (!materializedReusable || nextAgentConfig.model !== input.existing.agentConfig.model)
1720
+ ) {
1721
+ const beforeUpdateCommit = workspaceCustomModelCommitGuard({
1722
+ settings: input.settings,
1723
+ accountId: input.existing.accountId,
1724
+ workspaceId: input.existing.workspaceId,
1725
+ modelId: nextAgentConfig.model ?? input.settings.openaiModel,
1726
+ claudeAuthoritySnapshot: await getScheduledTaskClaudeProviderAccountAuthoritySnapshot(
1727
+ input.db,
1728
+ input.existing.workspaceId,
1729
+ input.existing.id,
1730
+ ),
1731
+ });
1732
+ if (beforeUpdateCommit) update.beforeUpdateCommit = beforeUpdateCommit;
1733
+ }
1734
+ const existingXaiAuthority = await getScheduledTaskXaiProviderAccountAuthoritySnapshot(
1735
+ input.db,
1736
+ input.existing.workspaceId,
1737
+ input.existing.id,
1738
+ );
1739
+ if (
1740
+ existingXaiAuthority.scope === "user" &&
1741
+ materialExecutionChange &&
1742
+ (input.existing.createdBy.kind !== "subject" ||
1743
+ input.existing.createdBy.subjectId !== input.grant.subjectId)
1744
+ ) {
1745
+ throw new HTTPException(409, {
1746
+ message: "changing a user-scoped xAI scheduled task requires the same causal human",
1747
+ });
1748
+ }
1749
+ if (materialExecutionChange) {
1750
+ await assertScheduledTaskMutationOwner(input.db, input.grant, input.existing.id);
1751
+ const ownerSubjectId = input.existing.ownerSubjectId;
493
1752
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
494
1753
  input.db,
495
- input.existing.workspaceId,
1754
+ input.grant.workspaceId,
496
1755
  input.settings,
497
- { subjectId: input.grant.subjectId },
1756
+ ownerSubjectId ? { subjectId: ownerSubjectId } : {},
498
1757
  );
499
- const personalConnectionDelegations = await freezePersonalConnectionDelegations({
1758
+ let nextTarget = await validateScheduledTaskTarget({
500
1759
  db: input.db,
501
- workspaceId: input.existing.workspaceId,
502
- settings: runtimeSettings,
503
- tools: [...nextAgentConfig.tools, { kind: "mcp", id: "opengeni" }],
504
- source: personalConnectionDelegationSourceForGrant(input.grant),
1760
+ sessionAuthorization: input.sessionAuthorization,
1761
+ authorizationSurface: input.authorizationSurface,
1762
+ grant: input.grant,
1763
+ targetSessionId: nextTargetSessionId,
1764
+ runMode: nextRunMode,
1765
+ variableSetId:
1766
+ input.payload.variableSetId !== undefined
1767
+ ? input.payload.variableSetId
1768
+ : input.existing.variableSetId,
1769
+ rigId: input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId,
1770
+ agentConfig: nextAgentConfig,
505
1771
  });
506
- if (input.existing.reusableSessionId && input.existing.runMode === "reusable_session") {
507
- const existingDelegations = await getScheduledTaskPersonalConnectionDelegations(
508
- input.db,
509
- input.existing.workspaceId,
510
- input.existing.id,
1772
+ if (materializedReusable) {
1773
+ nextTarget =
1774
+ reusableTarget ??
1775
+ (await getSession(input.db, input.existing.workspaceId, input.existing.reusableSessionId!));
1776
+ if (!nextTarget || nextTarget.accountId !== input.existing.accountId)
1777
+ throw new HTTPException(409, { message: "Scheduled task chat is unavailable" });
1778
+ }
1779
+ const nextConnectionTools = await scheduledConnectionTools(
1780
+ input.db,
1781
+ input.grant.workspaceId,
1782
+ runtimeSettings,
1783
+ nextTarget,
1784
+ nextAgentConfig.tools,
1785
+ ownerSubjectId ?? undefined,
1786
+ );
1787
+ const priorTargetSessionId =
1788
+ input.existing.runMode === "existing_session"
1789
+ ? input.existing.targetSessionId
1790
+ : input.existing.runMode === "reusable_session"
1791
+ ? input.existing.reusableSessionId
1792
+ : null;
1793
+ const priorTarget = priorTargetSessionId
1794
+ ? priorTargetSessionId === nextTarget?.id
1795
+ ? nextTarget
1796
+ : await getSession(input.db, input.existing.workspaceId, priorTargetSessionId)
1797
+ : null;
1798
+ const priorConnectionResources = mergeResourceRefs(
1799
+ priorTarget?.resources ?? [],
1800
+ input.existing.agentConfig.resources,
1801
+ );
1802
+ const nextConnectionResources = mergeResourceRefs(
1803
+ nextTarget?.resources ?? [],
1804
+ nextAgentConfig.resources,
1805
+ );
1806
+ const priorConnectionTools = await scheduledConnectionTools(
1807
+ input.db,
1808
+ input.grant.workspaceId,
1809
+ runtimeSettings,
1810
+ priorTarget,
1811
+ input.existing.agentConfig.tools,
1812
+ ownerSubjectId ?? undefined,
1813
+ );
1814
+ const priorAccountSurfaceIds = new Set(
1815
+ [...input.existing.agentConfig.tools, ...(priorTarget?.tools ?? []), ...priorConnectionTools]
1816
+ .filter((tool) => tool.kind === "mcp")
1817
+ .map((tool) => tool.id),
1818
+ );
1819
+ if (personalGitHubRepositoryResources(priorConnectionResources).length)
1820
+ priorAccountSurfaceIds.add(PERSONAL_GITHUB_CONNECTION_SURFACE_ID);
1821
+ const nextMcpIds = new Set(
1822
+ nextConnectionTools.filter((tool) => tool.kind === "mcp").map((tool) => tool.id),
1823
+ );
1824
+ const nextAccountSurfaceIds = new Set(nextMcpIds);
1825
+ const nextSurfaceEligibility = scheduledConnectionSurfaceEligibility(
1826
+ runtimeSettings,
1827
+ nextTarget,
1828
+ );
1829
+ if (nextSurfaceEligibility.googleDrivePublicationEnabled)
1830
+ nextAccountSurfaceIds.add(GOOGLE_DRIVE_PUBLICATION_SERVER_ID);
1831
+ if (personalGitHubRepositoryResources(nextConnectionResources).length)
1832
+ nextAccountSurfaceIds.add(PERSONAL_GITHUB_CONNECTION_SURFACE_ID);
1833
+ const movingChat =
1834
+ nextRunMode !== input.existing.runMode ||
1835
+ nextTargetSessionId !== input.existing.targetSessionId;
1836
+ // Removing a selected tool or repository also removes its inherited account choice.
1837
+ // Keep explicit caller selections subject to normal validation, and retain
1838
+ // account choices for unchanged tools and repository selections.
1839
+ const authoritySelections = (nextAgentConfig.connectionAccounts ?? []).filter(
1840
+ (selection) =>
1841
+ input.payload.connectionAccounts !== undefined ||
1842
+ (movingChat
1843
+ ? nextAccountSurfaceIds.has(selection.serverId)
1844
+ : !priorAccountSurfaceIds.has(selection.serverId) ||
1845
+ nextAccountSurfaceIds.has(selection.serverId)),
1846
+ );
1847
+ const acceptedConnections = await freezeConnectionAccounts({
1848
+ db: input.db,
1849
+ accountId: input.grant.accountId,
1850
+ workspaceId: input.grant.workspaceId,
1851
+ settings: nextTarget
1852
+ ? settingsWithSessionMcpServerMetadata(runtimeSettings, nextTarget.mcpServers)
1853
+ : runtimeSettings,
1854
+ tools: nextConnectionTools,
1855
+ resources: nextConnectionResources,
1856
+ source: ownerSubjectId
1857
+ ? { kind: "subject", subjectId: ownerSubjectId, accountId: input.existing.accountId }
1858
+ : { kind: "none" },
1859
+ authoritySelections,
1860
+ // An edit replaces an accepted account set exactly. Omitting a connector
1861
+ // from that set must not attach newly available accounts implicitly.
1862
+ authoritySelectionsFrozen: input.existing.agentConfig.connectionAccountsFrozen === true,
1863
+ ...nextSurfaceEligibility,
1864
+ });
1865
+ // A model-only patch still revalidates authority above, but is not an
1866
+ // access refresh. Preserve exact existing selections, including legacy
1867
+ // absent fields, unless the caller also changed accounts or the target.
1868
+ if (
1869
+ !input.payload.agentConfigPatch ||
1870
+ input.payload.connectionAccounts !== undefined ||
1871
+ authorityTargetChanged
1872
+ ) {
1873
+ const routeIds = new Set(
1874
+ (acceptedConnections.mcpAccountBindings ?? []).map((binding) => binding.serverId),
511
1875
  );
512
- if (!personalConnectionDelegationsEqual(existingDelegations, personalConnectionDelegations)) {
513
- throw new HTTPException(409, {
514
- message:
515
- "cannot change personal MCP connections of a task with a live reusable session; recreate the task",
516
- });
517
- }
1876
+ nextAgentConfig.connectionAccounts = [
1877
+ ...(acceptedConnections.mcpAccountBindings ?? []).map(
1878
+ ({ canonicalServerId, connectionId }) => ({
1879
+ serverId: canonicalServerId,
1880
+ connectionId,
1881
+ }),
1882
+ ),
1883
+ ...acceptedConnections.personalConnectionDelegations
1884
+ .filter(
1885
+ (item) =>
1886
+ !routeIds.has(item.serverId) &&
1887
+ (!item.connectionType ||
1888
+ item.connectionType === "mcp" ||
1889
+ item.connectionType === "github_personal"),
1890
+ )
1891
+ .map(({ serverId, connectionId }) => ({ serverId, connectionId })),
1892
+ ];
1893
+ nextAgentConfig.connectionAccountsFrozen = true;
518
1894
  }
519
- update.personalConnectionDelegations = personalConnectionDelegations;
1895
+ update.agentConfig = nextAgentConfig;
1896
+ }
1897
+ if (
1898
+ !materialExecutionChange &&
1899
+ input.payload.connectionAccounts === undefined &&
1900
+ update.clonePersonalResourceAuthorityFromRevision === undefined
1901
+ ) {
1902
+ // Administrative lifecycle/name edits preserve the exact revision-bound
1903
+ // causal human. They must not silently re-authorize retained Variable Set,
1904
+ // Rig, Connection, or xAI authority under the manager performing the edit.
1905
+ update.clonePersonalResourceAuthorityFromRevision = input.existing.authorityRevision;
520
1906
  }
521
1907
  if (
522
1908
  existingTarget &&
@@ -528,6 +1914,7 @@ export async function validatedScheduledTaskUpdate(input: {
528
1914
  authorizationSurface: input.authorizationSurface,
529
1915
  grant: input.grant,
530
1916
  targetSessionId: existingTarget,
1917
+ departingTarget: true,
531
1918
  runMode: "existing_session",
532
1919
  variableSetId: input.existing.variableSetId,
533
1920
  rigId: input.existing.rigId,
@@ -548,13 +1935,39 @@ export async function validatedScheduledTaskUpdate(input: {
548
1935
  rigId: input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId,
549
1936
  agentConfig: update.agentConfig ?? input.existing.agentConfig,
550
1937
  });
1938
+ if (
1939
+ !knowledgeSource &&
1940
+ !(
1941
+ materializedReusable &&
1942
+ isDeepStrictEqual(nextAgentConfig.machineTarget, input.existing.agentConfig.machineTarget) &&
1943
+ nextAgentConfig.sandboxBackend === input.existing.agentConfig.sandboxBackend
1944
+ )
1945
+ ) {
1946
+ await validateScheduledTaskMachineTarget({
1947
+ settings: input.settings,
1948
+ db: input.db,
1949
+ grant: input.grant,
1950
+ runMode: nextRunMode,
1951
+ agentConfig: update.agentConfig ?? input.existing.agentConfig,
1952
+ });
1953
+ }
551
1954
  if (
552
1955
  input.payload.targetSessionId !== undefined ||
553
1956
  input.existing.runMode === "existing_session" ||
554
- nextRunMode === "existing_session"
1957
+ nextRunMode === "existing_session" ||
1958
+ (input.existing.runMode === "reusable_session" && nextRunMode !== "reusable_session")
555
1959
  ) {
556
1960
  update.targetSessionId = nextTargetSessionId;
557
1961
  }
1962
+ Object.assign(update, scheduledTaskAuthorityUpdateForGrant(input.grant));
1963
+ const linkCapture = prepareExternalLinkTaskAdmission(
1964
+ input.authorization,
1965
+ scheduledTaskInitiatorForGrant(input.grant).actor,
1966
+ );
1967
+ if (linkCapture) update.captureLinkAuthority = linkCapture;
1968
+ if (update.clonePersonalResourceAuthorityFromRevision !== undefined) {
1969
+ update.refreshPersonalResourceAuthority = false;
1970
+ }
558
1971
  return update;
559
1972
  }
560
1973
 
@@ -572,21 +1985,19 @@ export async function requireScheduledTaskForApi(
572
1985
 
573
1986
  export type ScheduledTaskRestoreState = {
574
1987
  task: ScheduledTask;
575
- personalConnectionDelegations: McpPersonalConnectionDelegation[];
1988
+ learning?: {
1989
+ context: KnowledgeContext;
1990
+ scope: "workspace" | "personal";
1991
+ settings: import("@opengeni/contracts").AgentLearningOverridePatch;
1992
+ expectedVersion: number;
1993
+ };
576
1994
  };
577
1995
 
578
1996
  export async function captureScheduledTaskRestoreState(
579
- db: Database,
1997
+ _db: Database,
580
1998
  task: ScheduledTask,
581
1999
  ): Promise<ScheduledTaskRestoreState> {
582
- return {
583
- task,
584
- personalConnectionDelegations: await getScheduledTaskPersonalConnectionDelegations(
585
- db,
586
- task.workspaceId,
587
- task.id,
588
- ),
589
- };
2000
+ return { task };
590
2001
  }
591
2002
 
592
2003
  export async function restoreScheduledTask(
@@ -602,13 +2013,13 @@ export async function restoreScheduledTask(
602
2013
  overlapPolicy: task.overlapPolicy,
603
2014
  action: task.action,
604
2015
  agentConfig: task.agentConfig,
605
- personalConnectionDelegations: previous.personalConnectionDelegations,
606
2016
  ...(task.runMode === "existing_session"
607
2017
  ? { targetSessionId: task.targetSessionId }
608
2018
  : { reusableSessionId: task.reusableSessionId }),
609
2019
  variableSetId: task.variableSetId,
610
2020
  rigId: task.rigId,
611
2021
  metadata: task.metadata,
2022
+ clonePersonalResourceAuthorityFromRevision: task.authorityRevision,
612
2023
  });
613
2024
  }
614
2025
 
@@ -617,6 +2028,8 @@ async function validateKnowledgeSourceSyncAction(input: {
617
2028
  grant: AccessGrant;
618
2029
  action: KnowledgeSourceSyncAction;
619
2030
  }): Promise<void> {
2031
+ if (isRetiredNativeAtlassianSource(input.action))
2032
+ throw new HTTPException(410, { message: ATLASSIAN_NATIVE_RETIRED_MESSAGE });
620
2033
  if (input.action.initiatingSubjectId !== input.grant.subjectId) {
621
2034
  throw new HTTPException(403, {
622
2035
  message: "knowledge source sync must preserve the exact initiating subject",
@@ -679,24 +2092,123 @@ export class ScheduledTaskSyncError extends Error {
679
2092
  }
680
2093
  }
681
2094
 
2095
+ /**
2096
+ * One deletion lifecycle shared by HTTP and first-party MCP adapters.
2097
+ * Connector authorization is proven before the one-way tombstone. The same
2098
+ * transaction detaches reclaimable task resources and persists both external
2099
+ * cleanup obligations; best-effort processing is only an acceleration of that
2100
+ * durable receipt.
2101
+ */
2102
+ export async function deleteScheduledTaskLifecycle(input: {
2103
+ db: Database;
2104
+ workspaceId: string;
2105
+ taskId: string;
2106
+ subjectId: string;
2107
+ beforeDeleteCommit?: (tx: Database) => Promise<void>;
2108
+ preflightConnectorAuthorization: (task: ScheduledTask) => Promise<void>;
2109
+ cleanupConnectorAuthorization: (db: Database, task: ScheduledTask) => Promise<void>;
2110
+ processCleanupClaims?: (claims: readonly TemporalScheduleCleanupClaim[]) => Promise<void>;
2111
+ }): Promise<{
2112
+ task: ScheduledTask;
2113
+ changed: boolean;
2114
+ cleanup: TemporalScheduleCleanupClaim | null;
2115
+ }> {
2116
+ const result = await withWorkspaceSubjectRls(
2117
+ input.db,
2118
+ input.workspaceId,
2119
+ input.subjectId,
2120
+ async (tx) => {
2121
+ const task = await getScheduledTaskIncludingDeletedForUpdate(
2122
+ tx,
2123
+ input.workspaceId,
2124
+ input.taskId,
2125
+ );
2126
+ if (!task) throw new HTTPException(404, { message: "Scheduled task not found" });
2127
+ await input.beforeDeleteCommit?.(tx);
2128
+ const wasLive = task.deletedAt === null;
2129
+ if (
2130
+ scheduledTaskKnowledgeSource(task) &&
2131
+ (requireScheduledTaskKnowledgeSource(task).initiatingSubjectId !== input.subjectId ||
2132
+ requireScheduledTaskKnowledgeSource(task).connection.ownerSubjectId !== input.subjectId)
2133
+ ) {
2134
+ throw new HTTPException(403, {
2135
+ message: "knowledge source schedule requires the exact initiating subject",
2136
+ });
2137
+ }
2138
+ if (wasLive && scheduledTaskKnowledgeSource(task)) {
2139
+ await input.preflightConnectorAuthorization(task);
2140
+ }
2141
+ const deletion = scheduledTaskKnowledgeSource(task)
2142
+ ? await (async () => {
2143
+ await input.cleanupConnectorAuthorization(tx, task);
2144
+ return await deleteScheduledTask(tx, input.workspaceId, task.id, {
2145
+ connectorCleanupSubjectId: input.subjectId,
2146
+ connectorCleanupCompleted: true,
2147
+ expectedAuthorityRevision: task.authorityRevision,
2148
+ expectedExecutionDigest: task.executionDigest,
2149
+ });
2150
+ })()
2151
+ : await deleteScheduledTask(tx, input.workspaceId, task.id);
2152
+ return { task, deletion };
2153
+ },
2154
+ );
2155
+ const { task, deletion } = result;
2156
+ const { cleanup, changed } = deletion;
2157
+ if (cleanup && input.processCleanupClaims) {
2158
+ await input.processCleanupClaims([cleanup]);
2159
+ }
2160
+ return { task, changed, cleanup };
2161
+ }
2162
+
682
2163
  export async function syncCreatedScheduledTask(input: {
683
2164
  db: Database;
684
2165
  workflowClient: SessionWorkflowClient;
685
2166
  task: ScheduledTask;
686
2167
  }): Promise<void> {
687
2168
  try {
688
- await input.workflowClient.syncScheduledTask({ task: input.task });
2169
+ await input.workflowClient.syncScheduledTask({
2170
+ task: input.task,
2171
+ onFailure: async (db, error) => {
2172
+ let persistenceRestored = true;
2173
+ try {
2174
+ // The synchronizer keeps its lock through compensation and commit.
2175
+ // A savepoint also rolls back a failed compensation independently.
2176
+ await db.transaction(async (tx) => {
2177
+ await requireUnchangedScheduledTaskForCompensation(tx, input.task);
2178
+ await deleteScheduledTask(tx, input.task.workspaceId, input.task.id);
2179
+ });
2180
+ } catch {
2181
+ persistenceRestored = false;
2182
+ }
2183
+ return new ScheduledTaskSyncError(error, persistenceRestored);
2184
+ },
2185
+ });
689
2186
  } catch (error) {
690
- let persistenceRestored = true;
691
- try {
692
- await deleteScheduledTask(input.db, input.task.workspaceId, input.task.id);
693
- } catch {
694
- persistenceRestored = false;
695
- }
696
- throw new ScheduledTaskSyncError(error, persistenceRestored);
2187
+ if (error instanceof ScheduledTaskSyncError) throw error;
2188
+ // Lock admission or transaction commit failed. No committed restoration
2189
+ // was acknowledged; never compensate outside the synchronizer's lock.
2190
+ throw new ScheduledTaskSyncError(error, false);
697
2191
  }
698
2192
  }
699
2193
 
2194
+ async function requireUnchangedScheduledTaskForCompensation(
2195
+ db: Database,
2196
+ expected: ScheduledTask,
2197
+ ): Promise<void> {
2198
+ const current = await getScheduledTaskIncludingDeletedForUpdate(
2199
+ db,
2200
+ expected.workspaceId,
2201
+ expected.id,
2202
+ );
2203
+ if (!current) throw new ScheduledTaskHeadChangedError();
2204
+ const { deletedAt, ...task } = current;
2205
+ // The execution digest excludes names and lifecycle state. Compare the full
2206
+ // saved row under its write lock so a late sync failure cannot undo a newer
2207
+ // edit, pause, materialization or authority revision.
2208
+ if (deletedAt !== null || !isDeepStrictEqual(task, expected))
2209
+ throw new ScheduledTaskHeadChangedError();
2210
+ }
2211
+
700
2212
  export async function syncUpdatedScheduledTask(input: {
701
2213
  db: Database;
702
2214
  workflowClient: SessionWorkflowClient;
@@ -704,15 +2216,42 @@ export async function syncUpdatedScheduledTask(input: {
704
2216
  task: ScheduledTask;
705
2217
  }): Promise<void> {
706
2218
  try {
707
- await input.workflowClient.syncScheduledTask({ task: input.task });
2219
+ await input.workflowClient.syncScheduledTask({
2220
+ task: input.task,
2221
+ onFailure: async (db, error) => {
2222
+ let persistenceRestored = true;
2223
+ try {
2224
+ await db.transaction(async (tx) => {
2225
+ const learning = input.previous.learning;
2226
+ // Compensate only our exact settings version, while the updated task
2227
+ // still names that owner scope. A concurrent edit is never overwritten.
2228
+ if (learning)
2229
+ await saveAgentLearningSettings(tx, learning.context, {
2230
+ scope: learning.scope,
2231
+ source: { kind: "scheduled_task", id: input.task.id },
2232
+ operationId: crypto.randomUUID(),
2233
+ expectedVersion: learning.expectedVersion,
2234
+ settings: {
2235
+ knowledge: "inherit",
2236
+ instructions: "inherit",
2237
+ skills: "inherit",
2238
+ ...learning.settings,
2239
+ },
2240
+ });
2241
+ // Keep the learning-owner → task lock order used by ordinary edits.
2242
+ // Any learning compensation above rolls back with a stale task head.
2243
+ await requireUnchangedScheduledTaskForCompensation(tx, input.task);
2244
+ await restoreScheduledTask(tx, input.previous);
2245
+ });
2246
+ } catch {
2247
+ persistenceRestored = false;
2248
+ }
2249
+ return new ScheduledTaskSyncError(error, persistenceRestored);
2250
+ },
2251
+ });
708
2252
  } catch (error) {
709
- let persistenceRestored = true;
710
- try {
711
- await restoreScheduledTask(input.db, input.previous);
712
- } catch {
713
- persistenceRestored = false;
714
- }
715
- throw new ScheduledTaskSyncError(error, persistenceRestored);
2253
+ if (error instanceof ScheduledTaskSyncError) throw error;
2254
+ throw new ScheduledTaskSyncError(error, false);
716
2255
  }
717
2256
  }
718
2257
 
@@ -770,19 +2309,65 @@ async function validateScheduledTaskAgentConfig(input: {
770
2309
  db: Database;
771
2310
  objectStorage: ObjectStorageDependency;
772
2311
  grant: AccessGrant;
773
- payload: { agentConfig: ScheduledTaskAgentConfig };
2312
+ authorization?: AccessGrantAuthorization;
2313
+ payload: {
2314
+ agentConfig: ScheduledTaskAgentConfig;
2315
+ runMode?: string;
2316
+ targetSessionId?: string | null | undefined;
2317
+ };
774
2318
  workspaceId: string;
775
2319
  toolsProvided?: boolean;
2320
+ /** Only an already materialized reusable chat may retain unused creation defaults. */
2321
+ retainedCreationConfig?: ScheduledTaskAgentConfig;
776
2322
  }): Promise<ScheduledTaskAgentConfig> {
2323
+ const existingChat = input.payload.runMode === "existing_session";
2324
+ if (existingChat)
2325
+ input = {
2326
+ ...input,
2327
+ payload: {
2328
+ ...input.payload,
2329
+ agentConfig: scheduledSessionMessageConfig(input.payload.agentConfig),
2330
+ },
2331
+ toolsProvided: true,
2332
+ };
2333
+ const unchangedCreationField = (key: keyof ScheduledTaskAgentConfig) =>
2334
+ input.retainedCreationConfig !== undefined &&
2335
+ isDeepStrictEqual(input.payload.agentConfig[key], input.retainedCreationConfig[key]);
2336
+ const actor = scheduledTaskInitiatorForGrant(input.grant).actor;
2337
+ const parent =
2338
+ actor && !existingChat ? await getSession(input.db, input.workspaceId, actor.sessionId) : null;
2339
+ if (!existingChat && actor && (!parent || parent.accountId !== input.grant.accountId)) {
2340
+ throw new HTTPException(403, {
2341
+ message: "Scheduled Skill selection requires the creating agent's session",
2342
+ });
2343
+ }
2344
+ let bundledSkillIds: ScheduledTaskAgentConfig["bundledSkillIds"];
2345
+ try {
2346
+ bundledSkillIds = existingChat
2347
+ ? undefined
2348
+ : unchangedCreationField("bundledSkillIds")
2349
+ ? input.payload.agentConfig.bundledSkillIds
2350
+ : resolveBundledSkillSelection(
2351
+ input.payload.agentConfig.bundledSkillIds,
2352
+ parent?.bundledSkillIds,
2353
+ );
2354
+ } catch (error) {
2355
+ throw new HTTPException(422, {
2356
+ message: error instanceof Error ? error.message : "Invalid bundled Skill selection",
2357
+ });
2358
+ }
777
2359
  // Reject a curated-out model before touching the DB: a scheduled task is a
778
2360
  // session the worker runs later, so it must pass the same allow-list as the
779
2361
  // session choke points (a `scheduled_tasks:manage` holder could otherwise set
780
2362
  // a model the host does not expose). An omitted model inherits the host
781
2363
  // default downstream, which is always configured.
782
- const model = canonicalConfiguredModel(input.settings, input.payload.agentConfig.model);
2364
+ const model = unchangedCreationField("model")
2365
+ ? input.payload.agentConfig.model
2366
+ : canonicalConfiguredModel(input.settings, input.payload.agentConfig.model);
783
2367
  // Same policy vetting as the session choke points; an omitted model flows
784
2368
  // through session creation later, where the effective default is vetted.
785
- await assertWorkspaceModelPolicyAllows(input.db, input.settings, input.workspaceId, model);
2369
+ if (!unchangedCreationField("model"))
2370
+ await assertWorkspaceModelPolicyAllows(input.db, input.settings, input.workspaceId, model);
786
2371
  const resources = normalizeResources(input.payload.agentConfig.resources ?? []);
787
2372
  const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(
788
2373
  input.db,
@@ -790,16 +2375,38 @@ async function validateScheduledTaskAgentConfig(input: {
790
2375
  input.settings,
791
2376
  { subjectId: input.grant.subjectId },
792
2377
  );
793
- const requestedTools = validateToolRefs(input.payload.agentConfig.tools ?? [], runtimeSettings);
794
- // A task whose creator did not choose tools gets the workspace's enabled
795
- // capability MCP servers, exactly like a session created without a tools
796
- // key. Scheduled runs are sessions too; "no MCP servers at all" was a trap
797
- // every pack/template instantiation path kept falling into (a maintenance
798
- // task that cannot reach its workspace's notebook MCP cannot do its job).
2378
+ const retainTools = unchangedCreationField("tools");
2379
+ const requestedTools = retainTools
2380
+ ? input.payload.agentConfig.tools
2381
+ : validateToolRefs(input.payload.agentConfig.tools ?? [], runtimeSettings);
2382
+ const workspace = await requireWorkspace(input.db, input.workspaceId);
2383
+ if (
2384
+ parent?.tenancy?.visibility === "private" &&
2385
+ workspace.kind !== "personal" &&
2386
+ input.payload.runMode !== "existing_session" &&
2387
+ !input.payload.targetSessionId
2388
+ ) {
2389
+ throw new HTTPException(422, {
2390
+ message:
2391
+ "Schedule this private chat as the existing-session target, or create the task in your Personal workspace.",
2392
+ });
2393
+ }
2394
+ const workspaceSessionToolDefaults = resolveWorkspaceSessionToolDefaults(workspace.settings);
2395
+ // A task whose creator did not choose tools gets the workspace's exact
2396
+ // session defaults (or the deployment compatibility default), exactly like
2397
+ // a session created without a tools key. Scheduled runs are sessions too;
2398
+
2399
+ // kept falling into (a maintenance task that cannot reach its workspace's
2400
+ // notebook MCP cannot do its job).
799
2401
  const tools =
800
- (input.toolsProvided ?? true)
2402
+ retainTools || (input.toolsProvided ?? true)
801
2403
  ? requestedTools
802
- : withDefaultEnabledCapabilityMcpTools(requestedTools, input.settings, runtimeSettings);
2404
+ : withWorkspaceDefaultMcpTools(
2405
+ requestedTools,
2406
+ input.settings,
2407
+ runtimeSettings,
2408
+ workspaceSessionToolDefaults,
2409
+ );
803
2410
  const prompt = input.payload.agentConfig.prompt.trim();
804
2411
  if (!prompt) {
805
2412
  throw new HTTPException(422, { message: "scheduled task prompt is required" });
@@ -813,7 +2420,44 @@ async function validateScheduledTaskAgentConfig(input: {
813
2420
  if (resources.some((resource) => resource.kind === "file") && !input.objectStorage) {
814
2421
  throw new HTTPException(503, { message: "object storage is not configured" });
815
2422
  }
816
- await validateFileResources(input.db, input.workspaceId, resources);
2423
+ const fileActor =
2424
+ resources.some((resource) => resource.kind === "file") &&
2425
+ hasPermission(input.grant.permissions, "files:read")
2426
+ ? input.authorization
2427
+ ? await fileOwnerContextForAccess({ db: input.db }, input.authorization, "files:read")
2428
+ : input.grant.principalKind === "agent_attempt"
2429
+ ? await fileOwnerContextForAgent({ db: input.db }, input.grant, "files:read")
2430
+ : { subjectId: input.grant.subjectId, privateFileOwnerSubjectId: null }
2431
+ : { subjectId: input.grant.subjectId, privateFileOwnerSubjectId: null };
2432
+ await withSessionRlsActorContext(fileActor, async () => {
2433
+ const target =
2434
+ input.payload.targetSessionId && input.payload.runMode !== "new_session_per_run"
2435
+ ? await getSessionAuthorityProjection(
2436
+ input.db,
2437
+ input.workspaceId,
2438
+ input.payload.targetSessionId,
2439
+ )
2440
+ : null;
2441
+ const privateDestination =
2442
+ workspace.kind === "personal" ||
2443
+ (target?.visibility === "user_private" &&
2444
+ target.ownerSubjectId === fileActor.privateFileOwnerSubjectId) ||
2445
+ (target?.memoryScope === "user" &&
2446
+ target.scopeSubjectId === fileActor.privateFileOwnerSubjectId);
2447
+ await validateFileResources(
2448
+ input.db,
2449
+ input.grant.accountId,
2450
+ input.workspaceId,
2451
+ input.grant.subjectId,
2452
+ resources,
2453
+ {
2454
+ ...fileActor,
2455
+ privateFileOwnerSubjectId: privateDestination
2456
+ ? (fileActor.privateFileOwnerSubjectId ?? null)
2457
+ : null,
2458
+ },
2459
+ );
2460
+ });
817
2461
  if (input.payload.agentConfig.slackBotConnectionId) {
818
2462
  await validateOpenGeniSlackBotConnectionSelection(
819
2463
  input.db,
@@ -823,8 +2467,7 @@ async function validateScheduledTaskAgentConfig(input: {
823
2467
  );
824
2468
  }
825
2469
  const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
826
- if (requestedMaxDepth !== undefined) {
827
- const workspace = await requireWorkspace(input.db, input.workspaceId);
2470
+ if (requestedMaxDepth !== undefined && !unchangedCreationField("maxNestedAgentDepth")) {
828
2471
  const workspaceMaxDepth = workspace.settings.maxNestedAgentDepth;
829
2472
  const deploymentPolicy = await getNestedAgentDepthDeploymentPolicy(input.db);
830
2473
  const inheritedMaxDepth =
@@ -840,13 +2483,107 @@ async function validateScheduledTaskAgentConfig(input: {
840
2483
  });
841
2484
  }
842
2485
  }
843
- return {
2486
+ const validated = {
844
2487
  ...input.payload.agentConfig,
2488
+ ...(bundledSkillIds !== undefined ? { bundledSkillIds } : {}),
845
2489
  ...(model === undefined || model === null ? {} : { model }),
846
2490
  prompt,
847
2491
  resources,
848
2492
  tools,
849
2493
  };
2494
+ validateIncidentTelemetryPreflightSelection(input.settings, validated);
2495
+ return existingChat ? scheduledSessionMessageConfig(validated) : validated;
2496
+ }
2497
+
2498
+ /**
2499
+ * Static incident-telemetry admission validates only already-selected task
2500
+ * authority. It never discovers a resource, reads a variable value, probes a
2501
+ * provider, or treats ambient worker credentials as responder capability.
2502
+ * Mutable rig/variable-set metadata is revalidated again at dispatch.
2503
+ */
2504
+ export function validateIncidentTelemetryPreflightSelection(
2505
+ settings: Pick<Settings, "allowedFirstPartyMcpTools" | "defaultFirstPartyMcpTools">,
2506
+ agentConfig: ScheduledTaskAgentConfig,
2507
+ ): void {
2508
+ const executionClass = agentConfig.executionClass;
2509
+ const preflight = agentConfig.incidentTelemetryPreflight;
2510
+ if (executionClass === undefined && preflight === undefined) return;
2511
+ if (executionClass !== "incident_telemetry" || !preflight) {
2512
+ throw new HTTPException(422, {
2513
+ message:
2514
+ "executionClass=incident_telemetry and incidentTelemetryPreflight must be configured together",
2515
+ });
2516
+ }
2517
+
2518
+ for (const required of preflight.requiredResources) {
2519
+ if (!agentConfig.resources.some((selected) => isDeepStrictEqual(selected, required))) {
2520
+ throw new HTTPException(422, {
2521
+ message: "incidentTelemetryPreflight.requiredResources must be exact selected resources",
2522
+ });
2523
+ }
2524
+ }
2525
+
2526
+ const selectedMcpServerIds = new Set(agentConfig.tools.map((tool) => tool.id));
2527
+ // Scheduled dispatch always attaches the first-party OpenGeni MCP server.
2528
+ selectedMcpServerIds.add("opengeni");
2529
+ if (preflight.requiredMcpServerIds.some((id) => !selectedMcpServerIds.has(id))) {
2530
+ throw new HTTPException(422, {
2531
+ message: "incidentTelemetryPreflight.requiredMcpServerIds must be exact selected MCP servers",
2532
+ });
2533
+ }
2534
+
2535
+ const selectedFirstPartyTools = new Set(resolveFirstPartyMcpToolPolicy(settings).default);
2536
+ if (preflight.requiredFirstPartyMcpTools.some((tool) => !selectedFirstPartyTools.has(tool))) {
2537
+ throw new HTTPException(422, {
2538
+ message:
2539
+ "incidentTelemetryPreflight.requiredFirstPartyMcpTools must be present in the selected first-party tool policy",
2540
+ });
2541
+ }
2542
+
2543
+ const selectedFirstPartyPermissions = new Set<Permission>(DEFAULT_FIRST_PARTY_MCP_PERMISSIONS);
2544
+ if (
2545
+ (preflight.requiredFirstPartyMcpPermissions ?? []).some(
2546
+ (permission) => !selectedFirstPartyPermissions.has(permission),
2547
+ )
2548
+ ) {
2549
+ throw new HTTPException(422, {
2550
+ message:
2551
+ "incidentTelemetryPreflight.requiredFirstPartyMcpPermissions must be present in the scheduled responder permission policy",
2552
+ });
2553
+ }
2554
+
2555
+ const route = preflight.dataSource.route;
2556
+ if (route.kind === "mcp" && !selectedMcpServerIds.has(route.serverId)) {
2557
+ throw new HTTPException(422, {
2558
+ message: "incidentTelemetryPreflight.dataSource.route must use a selected MCP server",
2559
+ });
2560
+ }
2561
+ if (route.kind === "first_party" && !selectedFirstPartyTools.has(route.tool)) {
2562
+ throw new HTTPException(422, {
2563
+ message: "incidentTelemetryPreflight.dataSource.route must use a selected first-party tool",
2564
+ });
2565
+ }
2566
+ if (route.kind === "variable_set") {
2567
+ const declaredSets = new Set(preflight.requiredVariableSetNames);
2568
+ const declaredVariables = new Set(preflight.requiredVariableNames);
2569
+ if (
2570
+ !declaredSets.has(route.variableSetName) ||
2571
+ route.variableNames.some((name) => !declaredVariables.has(name))
2572
+ ) {
2573
+ throw new HTTPException(422, {
2574
+ message:
2575
+ "incidentTelemetryPreflight.dataSource.route variable metadata must be declared as required",
2576
+ });
2577
+ }
2578
+ }
2579
+ if (route.kind === "rig_credential_hook") {
2580
+ if (!preflight.requiredRig?.credentialHookIds.includes(route.credentialHookId)) {
2581
+ throw new HTTPException(422, {
2582
+ message:
2583
+ "incidentTelemetryPreflight.dataSource.route rig hook must be declared as required",
2584
+ });
2585
+ }
2586
+ }
850
2587
  }
851
2588
 
852
2589
  function validateScheduledTaskSchedule(schedule: ScheduledTask["schedule"]): void {