@k2b/cloud 0.45.0 → 0.46.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.
- package/package.json +5 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/styles/global.css +2 -0
package/src/ai/migrate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import { CapabilityActionReviewDataSchema } from "../contracts/capabilities";
|
|
3
3
|
import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
|
|
4
4
|
import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
|
|
5
|
+
import { migrateAiMaps } from "./map-store";
|
|
5
6
|
import { migrateAiMessageQueue } from "./message-queue";
|
|
6
7
|
import { migrateAiModelAccess } from "./model-access-migrate";
|
|
7
8
|
import { migrateAiQuotas } from "./quotas-migrate";
|
|
@@ -81,6 +82,8 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
81
82
|
END $$
|
|
82
83
|
`.simple();
|
|
83
84
|
|
|
85
|
+
await migrateAiMaps();
|
|
86
|
+
|
|
84
87
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
85
88
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_conversations_short_id ON ai.conversations(short_id)`.simple();
|
|
86
89
|
await backfillAiShortIds(
|
package/src/ai/protocol.ts
CHANGED
|
@@ -258,6 +258,7 @@ export const buildBlocksFromMessages = (
|
|
|
258
258
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
259
259
|
toolReviews?: Record<string, AiStoredActionReview>;
|
|
260
260
|
toolChecks?: Record<string, AiActionCheck>;
|
|
261
|
+
toolInterimResults?: Record<string, unknown>;
|
|
261
262
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
262
263
|
websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
|
|
263
264
|
} | null;
|
|
@@ -287,6 +288,7 @@ export const buildBlocksFromMessages = (
|
|
|
287
288
|
presentation: meta?.toolPresentations?.[block.id],
|
|
288
289
|
review: meta?.toolReviews?.[block.id],
|
|
289
290
|
check: meta?.toolChecks?.[block.id],
|
|
291
|
+
result: meta?.toolInterimResults?.[block.id],
|
|
290
292
|
// A turn that ended before an approved call returned records the approval on the call's message, and an
|
|
291
293
|
// approval it left waiting as expired.
|
|
292
294
|
...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
|
|
@@ -311,7 +313,7 @@ export const buildBlocksFromMessages = (
|
|
|
311
313
|
blocks[at!] = {
|
|
312
314
|
...existing,
|
|
313
315
|
status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
|
|
314
|
-
result: message.result,
|
|
316
|
+
result: message.isError && existing.result !== undefined ? existing.result : message.result,
|
|
315
317
|
isError: message.isError,
|
|
316
318
|
...(createdAt ? { at: createdAt } : {}),
|
|
317
319
|
...(outcome === "approved" ? { approved: true } : {}),
|
package/src/ai/routes.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
} from "./http";
|
|
67
67
|
import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
|
|
68
68
|
import { aiMaintenanceJobs } from "./maintenance";
|
|
69
|
+
import { aiMapRoutes } from "./map-routes";
|
|
69
70
|
import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
|
|
70
71
|
import { aiMemoryLearningRuns } from "./memory-learning-runs";
|
|
71
72
|
import { createCloudAiMemoryTool } from "./memory-tool";
|
|
@@ -338,6 +339,7 @@ export const aiRoutes = (() => {
|
|
|
338
339
|
.use("*", noTransform())
|
|
339
340
|
.use(rateLimit())
|
|
340
341
|
.use("*", auth.requireRole("authenticated"))
|
|
342
|
+
.route("/conversations", aiMapRoutes)
|
|
341
343
|
.get("/status", async (c) => {
|
|
342
344
|
const [status, enabled] = await Promise.all([
|
|
343
345
|
assistantAiSettingsState(c.get("accessSubject")),
|
package/src/ai/store.ts
CHANGED
|
@@ -7,12 +7,14 @@ import { type CapabilityActionReviewPresentation, CapabilityActionReviewPresenta
|
|
|
7
7
|
import { logger } from "../services/logging";
|
|
8
8
|
import { toPgTextArray } from "../services/postgres";
|
|
9
9
|
import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
|
|
10
|
+
import { AiMapSchema } from "./map-contracts";
|
|
10
11
|
|
|
11
12
|
export { AI_TOOL_CHECK_STALE_MS };
|
|
12
13
|
|
|
13
14
|
import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
|
|
14
15
|
import { projectSettledAiToolChecks } from "./capability-result";
|
|
15
16
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
17
|
+
import { projectAiMaps } from "./map-store";
|
|
16
18
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
17
19
|
import type { AiTurnBlock } from "./protocol";
|
|
18
20
|
import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
|
|
@@ -1515,6 +1517,17 @@ export const aiConversations: AiConversationService = {
|
|
|
1515
1517
|
SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
1516
1518
|
FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
1517
1519
|
`;
|
|
1520
|
+
// A fork owns independent snapshots. Rewrite only copied map references, never the source conversation.
|
|
1521
|
+
const mapRows = await tx<
|
|
1522
|
+
{ id: string; snapshot: unknown; retry: unknown }[]
|
|
1523
|
+
>`SELECT id,snapshot,retry FROM ai.maps WHERE conversation_id=${input.sourceConversationId}::uuid AND (id IN (SELECT (message->'result'->'map'->>'id')::uuid FROM ai.messages WHERE conversation_id=${target.id}::uuid AND role='tool_result' AND message->>'name'='map') OR snapshot->>'originCallId' IN (SELECT block->>'id' FROM ai.messages CROSS JOIN LATERAL jsonb_array_elements(message->'content') block WHERE conversation_id=${target.id}::uuid AND role='assistant' AND block->>'type'='tool_call' AND block->>'name'='map'))`;
|
|
1524
|
+
for (const saved of mapRows) {
|
|
1525
|
+
const id = crypto.randomUUID();
|
|
1526
|
+
const snapshot = AiMapSchema.parse(typeof saved.snapshot === "string" ? JSON.parse(saved.snapshot) : saved.snapshot);
|
|
1527
|
+
const next = { ...snapshot, id, conversationId: target.short_id };
|
|
1528
|
+
await tx`INSERT INTO ai.maps(id,conversation_id,revision,snapshot,retry) VALUES(${id}::uuid,${target.id}::uuid,${snapshot.revision},${JSON.stringify(next)}::text::jsonb,${saved.retry ? JSON.stringify(saved.retry) : null}::text::jsonb)`;
|
|
1529
|
+
await tx`UPDATE ai.messages SET message=jsonb_set(message,'{result,map}',${JSON.stringify({ ...next })}::text::jsonb) WHERE conversation_id=${target.id}::uuid AND role='tool_result' AND message->>'name'='map' AND message->'result'->'map'->>'id'=${saved.id}`;
|
|
1530
|
+
}
|
|
1518
1531
|
return rowToConversation(target);
|
|
1519
1532
|
});
|
|
1520
1533
|
},
|
|
@@ -2569,7 +2582,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2569
2582
|
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
2570
2583
|
ORDER BY seq ASC, (kind = 'summary')::int ASC
|
|
2571
2584
|
`;
|
|
2572
|
-
return rows.map(rowToMessage);
|
|
2585
|
+
return projectAiMaps(rows.map(rowToMessage));
|
|
2573
2586
|
},
|
|
2574
2587
|
|
|
2575
2588
|
listMessagesPage: async (input) => {
|
|
@@ -2598,7 +2611,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2598
2611
|
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
2599
2612
|
ORDER BY seq ASC, (kind = 'summary')::int ASC
|
|
2600
2613
|
`;
|
|
2601
|
-
const messages = rows.map(rowToMessage);
|
|
2614
|
+
const messages = await projectAiMaps(rows.map(rowToMessage));
|
|
2602
2615
|
const oldestSeq = messages[0]?.seq;
|
|
2603
2616
|
if (oldestSeq === undefined) return { messages, hasMore: false };
|
|
2604
2617
|
const older = await sql<{ exists: boolean }[]>`
|
|
@@ -2639,7 +2652,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2639
2652
|
`;
|
|
2640
2653
|
const page = rows.filter((row) => Number(row.page_rank) <= limit);
|
|
2641
2654
|
return {
|
|
2642
|
-
messages: page.map(rowToMessage),
|
|
2655
|
+
messages: await projectAiMaps(page.map(rowToMessage)),
|
|
2643
2656
|
...(rows.some((row) => Number(row.page_rank) > limit) && page.at(-1) ? { nextCursor: String(page.at(-1)!.seq) } : {}),
|
|
2644
2657
|
};
|
|
2645
2658
|
},
|
|
@@ -2758,7 +2771,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2758
2771
|
AND compacted_at IS NULL
|
|
2759
2772
|
ORDER BY seq ASC
|
|
2760
2773
|
`;
|
|
2761
|
-
return rows.map(rowToMessage);
|
|
2774
|
+
return projectAiMaps(rows.map(rowToMessage));
|
|
2762
2775
|
},
|
|
2763
2776
|
|
|
2764
2777
|
listTurnMessages: async (input) => {
|
|
@@ -2773,7 +2786,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2773
2786
|
AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
|
|
2774
2787
|
ORDER BY seq ASC
|
|
2775
2788
|
`;
|
|
2776
|
-
return rows.map(rowToMessage);
|
|
2789
|
+
return projectAiMaps(rows.map(rowToMessage));
|
|
2777
2790
|
},
|
|
2778
2791
|
|
|
2779
2792
|
setMessageFeedback: async (input) => {
|
|
@@ -3968,7 +3981,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3968
3981
|
).map(rowToMessage)
|
|
3969
3982
|
: await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3970
3983
|
return projectSettledAiToolChecks(
|
|
3971
|
-
rows.map((row) => ({
|
|
3984
|
+
(await projectAiMaps(rows, true)).map((row) => ({
|
|
3972
3985
|
seq: row.seq,
|
|
3973
3986
|
kind: row.kind,
|
|
3974
3987
|
meta: row.meta,
|
package/src/ai/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { z } from "zod";
|
|
|
4
4
|
import type { CapabilityActionCallReview } from "../contracts/capabilities";
|
|
5
5
|
import type { RequestActor } from "../server";
|
|
6
6
|
import { aiToolNeedsApproval } from "./approvals";
|
|
7
|
+
import { resolveAiToolResultMaxChars } from "./tool-result-budget";
|
|
7
8
|
import type {
|
|
8
9
|
AiApprovalTarget,
|
|
9
10
|
AiDataBoundary,
|
|
@@ -64,6 +65,8 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
|
|
|
64
65
|
timeZone?: string;
|
|
65
66
|
/** Localized short status; never include payloads or secrets. */
|
|
66
67
|
reportProgress?: (message: string) => Promise<void>;
|
|
68
|
+
/** A validated result snapshot while a server tool is still running. */
|
|
69
|
+
reportResult?: (result: z.infer<TOutput>) => Promise<void>;
|
|
67
70
|
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
68
71
|
requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
|
|
69
72
|
/** Relays a decision for an effect another platform component consumes and executes. */
|
|
@@ -117,6 +120,8 @@ export type AiToolPreparationContext = {
|
|
|
117
120
|
locale?: string;
|
|
118
121
|
timeZone?: string;
|
|
119
122
|
reportToolProgress?: (callId: string, message: string) => Promise<void>;
|
|
123
|
+
reportToolResult?: (callId: string, result: unknown) => Promise<void>;
|
|
124
|
+
maxToolResultChars?: number;
|
|
120
125
|
/** Uses the approved snapshot immediately before the tool executes its effect. */
|
|
121
126
|
consumeApproval?: (approvalCallId: string, input: unknown) => Promise<AiApprovalUse>;
|
|
122
127
|
/** Links a relayed decision to the record the effect owner will consume. */
|
|
@@ -241,6 +246,15 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
241
246
|
locale: input.locale,
|
|
242
247
|
timeZone: input.timeZone,
|
|
243
248
|
reportProgress: callId && report ? (message) => report(callId, message) : undefined,
|
|
249
|
+
reportResult:
|
|
250
|
+
callId && input.reportToolResult
|
|
251
|
+
? async (result) => {
|
|
252
|
+
const validated = tool.def.outputSchema.parse(result);
|
|
253
|
+
const maximum = input.maxToolResultChars ?? resolveAiToolResultMaxChars({ configuredMaxChars: Number.MAX_SAFE_INTEGER });
|
|
254
|
+
if (JSON.stringify(validated).length > maximum) throw new Error("AI_TOOL_INTERIM_RESULT_TOO_LARGE");
|
|
255
|
+
await input.reportToolResult!(callId, validated);
|
|
256
|
+
}
|
|
257
|
+
: undefined,
|
|
244
258
|
reportWebsiteReceipts:
|
|
245
259
|
callId && input.reportWebsiteReceipts ? (receipts) => input.reportWebsiteReceipts!(callId, receipts) : undefined,
|
|
246
260
|
});
|
package/src/ai/types.ts
CHANGED
|
@@ -313,6 +313,7 @@ export type AiStoredMessage = {
|
|
|
313
313
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
314
314
|
toolReviews?: Record<string, AiStoredActionReview>;
|
|
315
315
|
toolChecks?: Record<string, AiActionCheck>;
|
|
316
|
+
toolInterimResults?: Record<string, unknown>;
|
|
316
317
|
/** How the person decided an approval; `expired` when the turn ended while it still waited. */
|
|
317
318
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
318
319
|
/** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
|
|
@@ -1159,6 +1160,8 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
|
|
|
1159
1160
|
timeZone?: string;
|
|
1160
1161
|
/** Localized short status; never include payloads or secrets. */
|
|
1161
1162
|
reportProgress?: (message: string) => Promise<void>;
|
|
1163
|
+
/** A validated result snapshot while a server tool is still running. */
|
|
1164
|
+
reportResult?: (result: z.infer<TOutput>) => Promise<void>;
|
|
1162
1165
|
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
1163
1166
|
requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
|
|
1164
1167
|
/** Relays a decision for an effect another platform component consumes and executes. */
|
package/src/api/capabilities.ts
CHANGED
|
@@ -3,14 +3,14 @@ import { describeRoute } from "hono-openapi";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { type CatalogSubscription, projectAppEventCatalog, readCatalogSubscriptions } from "../_internal/app-event-catalog";
|
|
5
5
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
6
|
-
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
6
|
+
import { isPersonVisibleCapabilityQuery, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
7
7
|
import {
|
|
8
8
|
capabilityActionReceiptIssues,
|
|
9
9
|
capabilityActionReviewIssues,
|
|
10
10
|
capabilityQueryChecksActionOutcome,
|
|
11
11
|
} from "../_internal/capability-reviews";
|
|
12
12
|
import { capabilityTimeoutMs, createCapabilityDeadline, withCapabilityRequestTimeout } from "../_internal/capability-timeout";
|
|
13
|
-
import { getCapability, listApps } from "../_internal/registry";
|
|
13
|
+
import { getApp, getCapability, listApps } from "../_internal/registry";
|
|
14
14
|
import { resolveCommandLink } from "../capabilities/command-link";
|
|
15
15
|
import {
|
|
16
16
|
type CapabilityClaimScope,
|
|
@@ -59,12 +59,13 @@ import { AppAccessDeniedError, appAccessDeniedBody } from "../server/middleware/
|
|
|
59
59
|
import { oauthScopeByOperation } from "../server/middleware/auth";
|
|
60
60
|
import { noTransform } from "../server/middleware/no-transform";
|
|
61
61
|
import { hasOAuthScope } from "../server/oauth-scope";
|
|
62
|
+
import { ratelimit } from "../server/ratelimit";
|
|
62
63
|
import { appAccessAuthority, canUseApp } from "../services/app-access";
|
|
63
64
|
import { discoverableApps } from "../services/app-discovery";
|
|
64
65
|
import { authorityFromParentInvocation, invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
|
|
65
66
|
import { capabilityInvocationOperation } from "../services/identity/invocation-operations";
|
|
66
67
|
import type { CloudInvocationClaims, InvocationAuthority } from "../services/identity/invocation-token";
|
|
67
|
-
import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
|
|
68
|
+
import { normalizeInvocationRequestId, signAppQueryInvocation } from "../services/identity/invocation-token";
|
|
68
69
|
import { issueInvocationToken as signInvocationToken } from "../services/identity/issue-invocation";
|
|
69
70
|
import { withActiveIdentitySigner } from "../services/identity/key-ring";
|
|
70
71
|
import {
|
|
@@ -73,6 +74,7 @@ import {
|
|
|
73
74
|
nestedInvocationLeases,
|
|
74
75
|
nestedSourceDeadline,
|
|
75
76
|
} from "../services/identity/nested-invocation";
|
|
77
|
+
import type { AuthenticatedWorkload } from "../services/identity/workload-auth";
|
|
76
78
|
import { logger } from "../services/logging";
|
|
77
79
|
import { withMandateIssueAuthority } from "../services/mandates";
|
|
78
80
|
import * as settings from "../services/settings";
|
|
@@ -80,6 +82,9 @@ import { resolveAppPresentation } from "../shared/app-presentation";
|
|
|
80
82
|
import { capabilityMessages } from "../shared/capability-messages";
|
|
81
83
|
import { dispatchCapabilityStream, sealCapabilityStream } from "./capability-streams";
|
|
82
84
|
|
|
85
|
+
// Approved reader-check budget, shared across targets for each calling application.
|
|
86
|
+
export const APP_QUERY_REQUESTS_PER_MINUTE = 1_501;
|
|
87
|
+
const appQueryLimit = ratelimit({ id: "app-queries", limit: APP_QUERY_REQUESTS_PER_MINUTE, windowSecs: 60 });
|
|
83
88
|
const log = logger("capabilities");
|
|
84
89
|
const DEFAULT_CATALOG_LIMIT = 10;
|
|
85
90
|
const MAX_CATALOG_LIMIT = 25;
|
|
@@ -114,6 +119,7 @@ const CapabilityCatalogQuerySchema = z
|
|
|
114
119
|
export type CapabilityRouteDependencies = {
|
|
115
120
|
listApps?: () => Promise<AppRegistryEntry[]>;
|
|
116
121
|
eventSubscriptions?: () => Promise<CatalogSubscription[]>;
|
|
122
|
+
getApp?: (appId: string) => Promise<AppRegistryEntry | null>;
|
|
117
123
|
getCapability?: (appId: string) => Promise<CapabilityRegistryEntry | null>;
|
|
118
124
|
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
119
125
|
authenticate?: MiddlewareHandler<AuthContext>;
|
|
@@ -122,12 +128,15 @@ export type CapabilityRouteDependencies = {
|
|
|
122
128
|
withMandateIssueAuthority?: typeof withMandateIssueAuthority;
|
|
123
129
|
queryTimeoutMs?: number;
|
|
124
130
|
actionTimeoutMs?: number;
|
|
131
|
+
checkAppQueryRate?: (appId: string) => Promise<{ limited: boolean }>;
|
|
125
132
|
reviewTimeoutMs?: number;
|
|
126
133
|
appUrl?: () => Promise<string>;
|
|
127
134
|
};
|
|
128
135
|
|
|
129
136
|
export type CapabilityDispatchDependencies = Pick<
|
|
130
137
|
CapabilityRouteDependencies,
|
|
138
|
+
| "checkAppQueryRate"
|
|
139
|
+
| "getApp"
|
|
131
140
|
| "getCapability"
|
|
132
141
|
| "fetch"
|
|
133
142
|
| "queryTimeoutMs"
|
|
@@ -223,7 +232,7 @@ export const loadCapabilityCatalogPage = async (
|
|
|
223
232
|
appName: presentedEntry.name,
|
|
224
233
|
appIcon: capability.appIcon,
|
|
225
234
|
appDescription: presentedEntry.description,
|
|
226
|
-
manifest,
|
|
235
|
+
manifest: { ...manifest, queries: manifest.queries.filter(isPersonVisibleCapabilityQuery) },
|
|
227
236
|
...(manifest.events ? { events: projectAppEventCatalog(manifest, manifest.events.length ? await loadSubscribers() : []) } : {}),
|
|
228
237
|
};
|
|
229
238
|
const projectedBytes = new TextEncoder().encode(JSON.stringify(projected)).byteLength;
|
|
@@ -276,6 +285,8 @@ export type CapabilityDispatchParams = {
|
|
|
276
285
|
assistantTurn?: { conversationId: string; turnId: string };
|
|
277
286
|
/** Core-only absolute child deadline; cannot be extended by the application. */
|
|
278
287
|
deadline?: number;
|
|
288
|
+
/** Core-only authenticated workload. Never populated from request JSON. */
|
|
289
|
+
workload?: AuthenticatedWorkload;
|
|
279
290
|
mandate?: MandatedCapabilityAuthority;
|
|
280
291
|
callingAppId?: string;
|
|
281
292
|
/** Internal observation only: called immediately before attempting the app request. */
|
|
@@ -342,6 +353,43 @@ const runCapabilityDispatch = async (
|
|
|
342
353
|
);
|
|
343
354
|
return error;
|
|
344
355
|
}
|
|
356
|
+
if ("appOnly" in operation && operation.appOnly !== undefined && !params.workload)
|
|
357
|
+
return errorResponse("FORBIDDEN", "App authority required for this query", 403);
|
|
358
|
+
if (params.workload) {
|
|
359
|
+
if (
|
|
360
|
+
params.authority ||
|
|
361
|
+
params.mandate ||
|
|
362
|
+
params.parentInvocation ||
|
|
363
|
+
params.review ||
|
|
364
|
+
params.kind !== "queries" ||
|
|
365
|
+
!("appOnly" in operation) ||
|
|
366
|
+
operation.appOnly !== true ||
|
|
367
|
+
operation.stream
|
|
368
|
+
)
|
|
369
|
+
return errorResponse("FORBIDDEN", "App caller is not allowed for this query", 403);
|
|
370
|
+
try {
|
|
371
|
+
const caller = await deadline.wait(() => (params.dependencies?.getApp ?? getApp)(params.workload!.appId));
|
|
372
|
+
if (!caller) return errorResponse("APP_UNAVAILABLE", "Calling app registry entry unavailable", 503);
|
|
373
|
+
if (!caller.platformPermissions?.includes("resources:readers"))
|
|
374
|
+
return errorResponse("APP_QUERY_NOT_ADMITTED", "App caller does not declare resources:readers", 403);
|
|
375
|
+
} catch {
|
|
376
|
+
deadline.check();
|
|
377
|
+
return errorResponse("APP_UNAVAILABLE", "App registry unavailable", 503);
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
if (
|
|
381
|
+
(
|
|
382
|
+
await deadline.wait(() =>
|
|
383
|
+
(params.dependencies?.checkAppQueryRate ?? ((appId) => appQueryLimit.check(appId)))(params.workload!.appId),
|
|
384
|
+
)
|
|
385
|
+
).limited
|
|
386
|
+
)
|
|
387
|
+
return errorResponse("RATE_LIMITED", "App query rate limit exceeded; retry after one minute", 429);
|
|
388
|
+
} catch {
|
|
389
|
+
deadline.check();
|
|
390
|
+
return errorResponse("APP_UNAVAILABLE", "App query rate limiter unavailable", 503);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
345
393
|
if (params.review && (params.kind !== "actions" || !("review" in operation) || operation.review !== true)) {
|
|
346
394
|
const error = errorResponse(
|
|
347
395
|
"CAPABILITY_NOT_FOUND",
|
|
@@ -488,6 +536,33 @@ const runCapabilityDispatch = async (
|
|
|
488
536
|
});
|
|
489
537
|
})()
|
|
490
538
|
: await (async () => {
|
|
539
|
+
if (params.workload) {
|
|
540
|
+
const workload = params.workload;
|
|
541
|
+
return deadline.wait(() =>
|
|
542
|
+
(params.dependencies?.withActiveSigner ?? withActiveIdentitySigner)(
|
|
543
|
+
"invocation",
|
|
544
|
+
async (signer) => {
|
|
545
|
+
deadline.check();
|
|
546
|
+
const signed = await signAppQueryInvocation({
|
|
547
|
+
workload,
|
|
548
|
+
targetAppId: params.appId,
|
|
549
|
+
capabilityId: params.capabilityId,
|
|
550
|
+
schemaHash: operation.schemaHash,
|
|
551
|
+
input: input.data,
|
|
552
|
+
requestId,
|
|
553
|
+
signer,
|
|
554
|
+
});
|
|
555
|
+
observed.invocationId = signed.claims.jti;
|
|
556
|
+
return new Headers({
|
|
557
|
+
"content-type": "application/json",
|
|
558
|
+
accept: "application/json",
|
|
559
|
+
authorization: `Bearer ${signed.token}`,
|
|
560
|
+
});
|
|
561
|
+
},
|
|
562
|
+
{ signal, timeoutMs: deadline.remainingMs() },
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
491
566
|
if (!params.authority) throw new Error("Resolved request authority is required for Cloud invocation issuance");
|
|
492
567
|
const authority = params.authority;
|
|
493
568
|
const signed = await deadline.wait(() =>
|
|
@@ -665,12 +740,15 @@ const runCapabilityDispatch = async (
|
|
|
665
740
|
try {
|
|
666
741
|
params.onUpstreamAttempt?.();
|
|
667
742
|
const fetchResponse = () =>
|
|
668
|
-
fetchUpstream(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
743
|
+
fetchUpstream(
|
|
744
|
+
`${entry.endpoint}/${params.workload ? "app-queries" : params.kind}/${encodeURIComponent(params.capabilityId)}${params.review ? "/review" : ""}`,
|
|
745
|
+
{
|
|
746
|
+
method: "POST",
|
|
747
|
+
headers,
|
|
748
|
+
body: requestBody,
|
|
749
|
+
signal,
|
|
750
|
+
},
|
|
751
|
+
);
|
|
674
752
|
// A read may be released at its deadline; Action effects retain their existing uncertainty handling.
|
|
675
753
|
response = params.kind === "queries" || params.review ? await deadline.wait(fetchResponse) : await fetchResponse();
|
|
676
754
|
} catch (error) {
|
|
@@ -895,12 +973,12 @@ export const dispatchCapability = async (params: CapabilityDispatchParams): Prom
|
|
|
895
973
|
origin: params.origin,
|
|
896
974
|
invocationId: observed.invocationId,
|
|
897
975
|
parentInvocationId: params.parentInvocation?.jti,
|
|
898
|
-
viaAppId: params.parentInvocation ? params.callingAppId : undefined,
|
|
976
|
+
viaAppId: params.workload?.appId ?? (params.parentInvocation ? params.callingAppId : undefined),
|
|
899
977
|
appId: params.appId,
|
|
900
978
|
capability: `${params.appId}.${params.capabilityId}`,
|
|
901
979
|
kind: params.kind === "queries" ? "query" : "action",
|
|
902
980
|
destructive: observed.destructive,
|
|
903
|
-
actorKind: principal?.type ?? null,
|
|
981
|
+
actorKind: params.workload ? "app" : (principal?.type ?? null),
|
|
904
982
|
actorId: principal?.id ?? null,
|
|
905
983
|
userId: actor?.kind === "user" ? actor.user.id : (actor?.delegatedUser?.id ?? (principal?.type === "user" ? principal.id : null)),
|
|
906
984
|
accessSubject: subject && subject.id !== principal?.id ? subject : null,
|
package/src/api/mcp.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { Hono, type MiddlewareHandler } from "hono";
|
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { type CatalogSubscription, projectAppEventCatalog, readEventCatalogSubscriptions } from "../_internal/app-event-catalog";
|
|
18
18
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
19
|
-
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
19
|
+
import { isPersonVisibleCapabilityQuery, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
20
20
|
import { capabilitySentenceText } from "../_internal/capability-sentences";
|
|
21
21
|
import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
|
|
22
22
|
import {
|
|
@@ -311,7 +311,9 @@ const orderedCapabilityEntries = function* (app: CapabilityRegistryEntry): Gener
|
|
|
311
311
|
for (const operation of [...app.manifest.actions].sort((left, right) => left.localId.localeCompare(right.localId))) {
|
|
312
312
|
yield { app, kind: "actions", operation };
|
|
313
313
|
}
|
|
314
|
-
for (const operation of
|
|
314
|
+
for (const operation of app.manifest.queries
|
|
315
|
+
.filter(isPersonVisibleCapabilityQuery)
|
|
316
|
+
.sort((left, right) => left.localId.localeCompare(right.localId))) {
|
|
315
317
|
yield { app, kind: "queries", operation };
|
|
316
318
|
}
|
|
317
319
|
};
|
|
@@ -13,6 +13,8 @@ export type BrowserNotificationState = {
|
|
|
13
13
|
permission: NotificationPermission;
|
|
14
14
|
enabled: boolean;
|
|
15
15
|
reason?: string;
|
|
16
|
+
/** On iPhone and iPad outside the Home Screen app: notifications start working once Cloud is added to the Home Screen. */
|
|
17
|
+
homeScreenRequired?: true;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const isIos = (): boolean =>
|
|
@@ -21,7 +23,15 @@ const isIos = (): boolean =>
|
|
|
21
23
|
const isStandalone = (): boolean =>
|
|
22
24
|
window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
|
23
25
|
|
|
24
|
-
const support = (): { supported: boolean; reason?: string } => {
|
|
26
|
+
const support = (): { supported: boolean; reason?: string; homeScreenRequired?: true } => {
|
|
27
|
+
// Safari on iPhone and iPad offers push only to the Home Screen app, so it lacks the APIs checked below.
|
|
28
|
+
if (typeof window !== "undefined" && isIos() && !isStandalone()) {
|
|
29
|
+
return {
|
|
30
|
+
supported: false,
|
|
31
|
+
reason: "On iPhone and iPad, add Cloud to your Home Screen before enabling browser notifications.",
|
|
32
|
+
homeScreenRequired: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
25
35
|
if (
|
|
26
36
|
typeof window === "undefined" ||
|
|
27
37
|
!window.isSecureContext ||
|
|
@@ -31,12 +41,6 @@ const support = (): { supported: boolean; reason?: string } => {
|
|
|
31
41
|
) {
|
|
32
42
|
return { supported: false, reason: "Browser notifications are not supported in this browser." };
|
|
33
43
|
}
|
|
34
|
-
if (isIos() && !isStandalone()) {
|
|
35
|
-
return {
|
|
36
|
-
supported: false,
|
|
37
|
-
reason: "On iPhone and iPad, add Cloud to your Home Screen before enabling browser notifications.",
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
44
|
return { supported: true };
|
|
41
45
|
};
|
|
42
46
|
|
|
@@ -101,7 +105,13 @@ const pushSubscriptionError = (cause: unknown): Error => {
|
|
|
101
105
|
const currentState = async (registration?: ServiceWorkerRegistration): Promise<BrowserNotificationState> => {
|
|
102
106
|
const capability = support();
|
|
103
107
|
if (!capability.supported) {
|
|
104
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
supported: false,
|
|
110
|
+
permission: "denied",
|
|
111
|
+
enabled: false,
|
|
112
|
+
reason: capability.reason,
|
|
113
|
+
...(capability.homeScreenRequired ? { homeScreenRequired: true } : {}),
|
|
114
|
+
};
|
|
105
115
|
}
|
|
106
116
|
return withNotificationTimeout(
|
|
107
117
|
(async () => {
|
|
@@ -131,6 +141,13 @@ export const browserNotificationClient = {
|
|
|
131
141
|
return (await response.json()).state;
|
|
132
142
|
},
|
|
133
143
|
|
|
144
|
+
/** End do not disturb now, for every app. Quiet hours keep their schedule. Returns the quiet state afterwards. */
|
|
145
|
+
endDoNotDisturb: async (): Promise<NotificationQuietState> => {
|
|
146
|
+
const response = await apiClient.me.notifications.quiet.$patch({ json: { doNotDisturbUntil: null } });
|
|
147
|
+
if (!response.ok) throw await responseError(response, "Failed to end do not disturb.");
|
|
148
|
+
return (await response.json()).state;
|
|
149
|
+
},
|
|
150
|
+
|
|
134
151
|
/** Close this application's group on this device without opting into notifications. */
|
|
135
152
|
closeGroup: async (appId: string, group: string): Promise<void> => {
|
|
136
153
|
const tag = notificationGroupTag(appId, group);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { discussionsIssues } from "../contracts/discussions";
|
|
2
|
+
import { type CapabilityCaller, listCapabilityCatalog } from "./server-invoke";
|
|
3
|
+
import type { CapabilityCatalogApp } from "./types";
|
|
4
|
+
|
|
5
|
+
// Same interactive discovery budget as the calendar reader. Cursor pages are sequential (one in flight).
|
|
6
|
+
const MAX_CALLS = 60;
|
|
7
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
export type DiscussionProvider = {
|
|
9
|
+
app: CapabilityCatalogApp;
|
|
10
|
+
itemType: string;
|
|
11
|
+
containerType: string;
|
|
12
|
+
containers: string;
|
|
13
|
+
activity?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Request-local discovery. Failure, truncation, and cancellation throw; no partial success or retained authority. */
|
|
17
|
+
export const discussionProviders = async (
|
|
18
|
+
caller: CapabilityCaller,
|
|
19
|
+
options: { deadline: number; signal?: AbortSignal },
|
|
20
|
+
): Promise<DiscussionProvider[]> => {
|
|
21
|
+
if (!Number.isFinite(options.deadline)) throw new RangeError("Discussion deadline must be a finite epoch time in milliseconds");
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const signal = AbortSignal.any([
|
|
24
|
+
controller.signal,
|
|
25
|
+
...[caller.signal, options.signal].filter((signal): signal is AbortSignal => !!signal),
|
|
26
|
+
]);
|
|
27
|
+
const timer = setTimeout(() => controller.abort(new Error("DISCUSSION_DEADLINE")), Math.max(0, options.deadline - Date.now()));
|
|
28
|
+
let bytes = 0;
|
|
29
|
+
const addBytes = (size: number) => {
|
|
30
|
+
bytes += size;
|
|
31
|
+
if (bytes > MAX_BYTES) {
|
|
32
|
+
controller.abort(new Error("DISCUSSION_BYTE_BUDGET"));
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const credentials: CapabilityCaller = {
|
|
37
|
+
...caller,
|
|
38
|
+
signal,
|
|
39
|
+
...(!caller.authority
|
|
40
|
+
? {
|
|
41
|
+
transport: async (request: Request) => {
|
|
42
|
+
const response = await (caller.transport ?? ((request: Request) => fetch(request, { redirect: "error" })))(request);
|
|
43
|
+
if (!response.body) return response;
|
|
44
|
+
const body = response.body.pipeThrough(
|
|
45
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
46
|
+
transform(chunk, stream) {
|
|
47
|
+
addBytes(chunk.byteLength);
|
|
48
|
+
stream.enqueue(chunk);
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
{ signal },
|
|
52
|
+
);
|
|
53
|
+
return new Response(body, { status: response.status, headers: response.headers });
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
: {}),
|
|
57
|
+
};
|
|
58
|
+
const providers: DiscussionProvider[] = [];
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const appIds = new Set<string>();
|
|
61
|
+
let cursor: string | undefined;
|
|
62
|
+
let onAbort: (() => void) | undefined;
|
|
63
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
64
|
+
onAbort = () => reject(signal.reason);
|
|
65
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
for (let calls = 0; calls < MAX_CALLS; calls++) {
|
|
69
|
+
if (options.deadline <= Date.now()) controller.abort(new Error("DISCUSSION_DEADLINE"));
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
const page = await Promise.race([listCapabilityCatalog({ caller: credentials, cursor, limit: 25 }), cancelled]);
|
|
72
|
+
signal.throwIfAborted();
|
|
73
|
+
if (!page.ok) throw new Error(`Discussion discovery unavailable: ${page.error.code}`);
|
|
74
|
+
if (caller.authority) addBytes(new TextEncoder().encode(JSON.stringify(page.data)).byteLength);
|
|
75
|
+
for (const app of page.data.apps) {
|
|
76
|
+
const role = app.manifest.discussions;
|
|
77
|
+
const model = app.manifest.resourceTypes?.find((model) => model.type === role?.item);
|
|
78
|
+
if (!role || !model?.container || discussionsIssues(app.manifest).length || appIds.has(app.appId)) continue;
|
|
79
|
+
appIds.add(app.appId);
|
|
80
|
+
providers.push({
|
|
81
|
+
app,
|
|
82
|
+
itemType: `${app.appId}.${role.item}`,
|
|
83
|
+
containerType: `${app.appId}.${model.container}`,
|
|
84
|
+
containers: role.containers,
|
|
85
|
+
...(role.activity ? { activity: role.activity } : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (!page.data.page.hasMore) return providers;
|
|
89
|
+
cursor = page.data.page.nextCursor;
|
|
90
|
+
if (seen.has(cursor)) throw new Error("DISCUSSION_CURSOR_LOOP");
|
|
91
|
+
seen.add(cursor);
|
|
92
|
+
}
|
|
93
|
+
throw new Error("DISCUSSION_CALL_BUDGET");
|
|
94
|
+
} finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
97
|
+
controller.abort();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
@@ -63,7 +63,7 @@ export type CapabilityExecutionInput = {
|
|
|
63
63
|
capability: string;
|
|
64
64
|
kind: "query" | "action";
|
|
65
65
|
destructive: boolean;
|
|
66
|
-
actorKind?: "user" | "service_account" | null;
|
|
66
|
+
actorKind?: "user" | "service_account" | "app" | null;
|
|
67
67
|
actorId?: string | null;
|
|
68
68
|
userId?: string | null;
|
|
69
69
|
/** Recorded only when the access subject differs from the acting principal. */
|
|
@@ -90,7 +90,7 @@ export type CapabilityExecution = {
|
|
|
90
90
|
capability: string;
|
|
91
91
|
kind: "query" | "action";
|
|
92
92
|
destructive: boolean;
|
|
93
|
-
actorKind: "user" | "service_account" | null;
|
|
93
|
+
actorKind: "user" | "service_account" | "app" | null;
|
|
94
94
|
actorId: string | null;
|
|
95
95
|
userId: string | null;
|
|
96
96
|
accessSubjectType: "user" | "service_account" | null;
|
|
@@ -35,7 +35,7 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
|
|
|
35
35
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
36
36
|
CONSTRAINT capabilities_executions_origin_check CHECK (origin IN ('assistant', 'mcp', 'http', 'app')),
|
|
37
37
|
CONSTRAINT capabilities_executions_kind_check CHECK (kind IN ('query', 'action')),
|
|
38
|
-
CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account')),
|
|
38
|
+
CONSTRAINT capabilities_executions_actor_kind_check CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app')),
|
|
39
39
|
CONSTRAINT capabilities_executions_subject_check CHECK (access_subject_type IS NULL OR access_subject_type IN ('user', 'service_account')),
|
|
40
40
|
CONSTRAINT capabilities_executions_status_check CHECK (status IN ('succeeded', 'failed', 'denied', 'invalid_input', 'timed_out', 'rejected'))
|
|
41
41
|
)
|
|
@@ -59,6 +59,15 @@ export const migrateCloudCapabilities = async (): Promise<void> => {
|
|
|
59
59
|
await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS parent_invocation_id UUID`.simple();
|
|
60
60
|
await sql`ALTER TABLE capabilities.executions ADD COLUMN IF NOT EXISTS via_app_id TEXT`.simple();
|
|
61
61
|
|
|
62
|
+
await sql`DO $$ BEGIN
|
|
63
|
+
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'capabilities.executions'::regclass
|
|
64
|
+
AND conname = 'capabilities_executions_actor_kind_check' AND position('''app''' in pg_get_constraintdef(oid)) = 0) THEN
|
|
65
|
+
ALTER TABLE capabilities.executions DROP CONSTRAINT capabilities_executions_actor_kind_check;
|
|
66
|
+
ALTER TABLE capabilities.executions ADD CONSTRAINT capabilities_executions_actor_kind_check
|
|
67
|
+
CHECK (actor_kind IS NULL OR actor_kind IN ('user', 'service_account', 'app'));
|
|
68
|
+
END IF;
|
|
69
|
+
END $$`.simple();
|
|
70
|
+
|
|
62
71
|
// Platform-owned idempotency claims. One row per
|
|
63
72
|
// (app, capability, principal, key); the dispatcher claims before it
|
|
64
73
|
// forwards and resolves the row once the outcome is known. Response bodies
|