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