@k2b/cloud 0.39.0 → 0.40.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/README.md +8 -0
- package/package.json +8 -4
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +2 -0
- package/src/ai/migrate.ts +11 -2
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +145 -1
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +2 -0
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +6 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +120 -13
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/seen-hints.ts +35 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +11 -0
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +10 -2
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +2 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +17 -22
package/src/ai/store.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
|
|
2
2
|
import type { Usage } from "@k2b/nessi/ai";
|
|
3
3
|
import { type SQL, sql } from "bun";
|
|
4
|
-
import { type
|
|
4
|
+
import { type CapabilityActionReviewData, CapabilityActionReviewDataSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck, type AiStoredActionReview } from "./action-review";
|
|
8
|
+
|
|
9
|
+
export { AI_TOOL_CHECK_STALE_MS };
|
|
10
|
+
|
|
7
11
|
import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
|
|
12
|
+
import { projectSettledAiToolChecks } from "./capability-result";
|
|
8
13
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
9
14
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
10
15
|
import type { AiTurnBlock } from "./protocol";
|
|
16
|
+
import { collectRecentSentResourceRefs, RECENT_SENT_CONVERSATION_SCAN_LIMIT, RECENT_SENT_MESSAGE_SCAN_LIMIT } from "./recent-sent-refs";
|
|
11
17
|
import { AiRunTimeout } from "./run-timeout";
|
|
12
18
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
13
19
|
import { parseAiTodoPlan } from "./todo-contracts";
|
|
@@ -65,6 +71,45 @@ export const resolveAiTurnShortIds = async (input: {
|
|
|
65
71
|
return new Map(rows.map((row) => [row.id, row.short_id]));
|
|
66
72
|
};
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Marker texts of the owner's latest sent messages, newest first. The candidates carry no message bodies; only the
|
|
76
|
+
* newest few are read for their resource markers.
|
|
77
|
+
*/
|
|
78
|
+
export const recentSentResourceMarkersQuery = (ownerUserId: string) => sql<
|
|
79
|
+
{ conversation_id: string; short_id: string; created_at: Date | string; markers: string[] }[]
|
|
80
|
+
>`
|
|
81
|
+
WITH conversations AS MATERIALIZED (
|
|
82
|
+
SELECT id FROM ai.conversations
|
|
83
|
+
WHERE created_by_user_id = ${ownerUserId}::uuid AND archived_at IS NULL
|
|
84
|
+
ORDER BY updated_at DESC
|
|
85
|
+
LIMIT ${RECENT_SENT_CONVERSATION_SCAN_LIMIT}
|
|
86
|
+
), recent_messages AS MATERIALIZED (
|
|
87
|
+
SELECT candidate.id, candidate.conversation_id, candidate.created_at FROM conversations
|
|
88
|
+
CROSS JOIN LATERAL (
|
|
89
|
+
SELECT message.id, message.conversation_id, message.created_at
|
|
90
|
+
FROM ai.messages message
|
|
91
|
+
WHERE message.conversation_id = conversations.id
|
|
92
|
+
AND message.role = 'user' AND message.kind = 'message'
|
|
93
|
+
AND NOT (COALESCE(message.meta, '{}'::jsonb) ? 'scheduledTask')
|
|
94
|
+
AND NOT (COALESCE(message.meta, '{}'::jsonb) ? 'agentMessage')
|
|
95
|
+
ORDER BY message.seq DESC
|
|
96
|
+
LIMIT ${RECENT_SENT_MESSAGE_SCAN_LIMIT}
|
|
97
|
+
) candidate
|
|
98
|
+
ORDER BY candidate.created_at DESC, candidate.id DESC
|
|
99
|
+
LIMIT ${RECENT_SENT_MESSAGE_SCAN_LIMIT}
|
|
100
|
+
)
|
|
101
|
+
SELECT recent_messages.conversation_id, sent.short_id, recent_messages.created_at,
|
|
102
|
+
array_agg(part->>'text' ORDER BY ordinal) AS markers
|
|
103
|
+
FROM recent_messages
|
|
104
|
+
JOIN ai.messages sent ON sent.id = recent_messages.id
|
|
105
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
106
|
+
CASE WHEN jsonb_typeof(sent.message->'content') = 'array' THEN sent.message->'content' ELSE '[]'::jsonb END
|
|
107
|
+
) WITH ORDINALITY AS content(part, ordinal)
|
|
108
|
+
WHERE part->>'type' = 'text' AND ltrim(part->>'text') LIKE '[Attached Cloud resource %'
|
|
109
|
+
GROUP BY recent_messages.id, recent_messages.conversation_id, sent.short_id, recent_messages.created_at
|
|
110
|
+
ORDER BY recent_messages.created_at DESC, recent_messages.id DESC
|
|
111
|
+
`;
|
|
112
|
+
|
|
68
113
|
type ConversationSearchBackend = "native" | "bm25";
|
|
69
114
|
let conversationSearchBackendPromise: Promise<ConversationSearchBackend> | null = null;
|
|
70
115
|
|
|
@@ -485,9 +530,9 @@ const parseJsonValue = <T>(value: unknown): T => {
|
|
|
485
530
|
return value as T;
|
|
486
531
|
};
|
|
487
532
|
|
|
488
|
-
const parseCapabilityActionReview = (value: unknown):
|
|
533
|
+
const parseCapabilityActionReview = (value: unknown): CapabilityActionReviewData | undefined => {
|
|
489
534
|
if (value === null || value === undefined) return undefined;
|
|
490
|
-
const parsed =
|
|
535
|
+
const parsed = CapabilityActionReviewDataSchema.safeParse(parseJsonValue(value));
|
|
491
536
|
return parsed.success ? parsed.data : undefined;
|
|
492
537
|
};
|
|
493
538
|
|
|
@@ -530,6 +575,13 @@ const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserP
|
|
|
530
575
|
const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
|
|
531
576
|
const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
|
|
532
577
|
|
|
578
|
+
/** What a copied message keeps of its meta: its todo plan, and its calls' reviews and checks, so a fork reads the same. */
|
|
579
|
+
const copiedMessageMetaJson = () => sql`NULLIF(jsonb_strip_nulls(jsonb_build_object(
|
|
580
|
+
'todoPlan', ${todoMetaJson()}->'todoPlan',
|
|
581
|
+
'toolReviews', ${todoMetaJson()}->'toolReviews',
|
|
582
|
+
'toolChecks', ${todoMetaJson()}->'toolChecks'
|
|
583
|
+
)), '{}'::jsonb)`;
|
|
584
|
+
|
|
533
585
|
// A scheduled result arriving during an interactive turn becomes context on the next turn.
|
|
534
586
|
const visibleToTurn = (turnId?: string) => sql`(
|
|
535
587
|
${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
|
|
@@ -929,6 +981,114 @@ export const recordAiWebsiteReceipts = async (input: {
|
|
|
929
981
|
`;
|
|
930
982
|
};
|
|
931
983
|
|
|
984
|
+
/** Reviews and outcome checks belong to the assistant message containing the call, including scheduled turns. */
|
|
985
|
+
const recordAiToolMeta = async (
|
|
986
|
+
input: { conversationId: string; turnId: string; callId: string },
|
|
987
|
+
key: "toolReviews" | "toolChecks",
|
|
988
|
+
value: AiStoredActionReview | AiActionCheck,
|
|
989
|
+
db = sql,
|
|
990
|
+
): Promise<void> => {
|
|
991
|
+
let written = false;
|
|
992
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
993
|
+
const rows = await db<{ id: string }[]>`
|
|
994
|
+
UPDATE ${db(table)}
|
|
995
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
|
|
996
|
+
${key}::text, COALESCE(meta->${key}::text, '{}'::jsonb) ||
|
|
997
|
+
jsonb_build_object(${input.callId}::text, ${JSON.stringify(value)}::text::jsonb)
|
|
998
|
+
)
|
|
999
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
|
|
1000
|
+
AND role = 'assistant'
|
|
1001
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
1002
|
+
RETURNING id`;
|
|
1003
|
+
written ||= rows.length > 0;
|
|
1004
|
+
}
|
|
1005
|
+
if (!written) throw new Error("AI tool call message is unavailable.");
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
export const recordAiToolReview = (input: { conversationId: string; turnId: string; callId: string; review: AiStoredActionReview }) =>
|
|
1009
|
+
recordAiToolMeta(input, "toolReviews", input.review);
|
|
1010
|
+
|
|
1011
|
+
export const recordAiToolCheck = (input: { conversationId: string; turnId: string; callId: string; check: AiActionCheck }, db = sql) =>
|
|
1012
|
+
recordAiToolMeta(input, "toolChecks", input.check, db);
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Writes one call's check only where `allowed` holds for its current state, in one statement per table, so two Core
|
|
1016
|
+
* replicas or the executor and a person's re-check can never both hold a check, and a settled result is never replaced.
|
|
1017
|
+
*/
|
|
1018
|
+
const writeAiToolCheckWhere = async (
|
|
1019
|
+
input: { conversationId: string; turnId: string; callId: string },
|
|
1020
|
+
check: AiActionCheck,
|
|
1021
|
+
allowed: (current: SQL.Query<unknown>) => SQL.Query<unknown>,
|
|
1022
|
+
): Promise<boolean> => {
|
|
1023
|
+
let written = false;
|
|
1024
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
1025
|
+
const current = sql`meta->'toolChecks'->${input.callId}::text`;
|
|
1026
|
+
const rows = await sql<{ id: string }[]>`
|
|
1027
|
+
UPDATE ${sql(table)}
|
|
1028
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
|
|
1029
|
+
'toolChecks', COALESCE(meta->'toolChecks', '{}'::jsonb) ||
|
|
1030
|
+
jsonb_build_object(${input.callId}::text, ${JSON.stringify(check)}::text::jsonb)
|
|
1031
|
+
)
|
|
1032
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
|
|
1033
|
+
AND role = 'assistant'
|
|
1034
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
1035
|
+
AND (${allowed(current)})
|
|
1036
|
+
RETURNING id`;
|
|
1037
|
+
written ||= rows.length > 0;
|
|
1038
|
+
}
|
|
1039
|
+
return written;
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Claims the check of one call: stores `checking` at `at`, which is the claim's token, when no check holds and none
|
|
1044
|
+
* settled. A call without any check yet may be claimed only with `first`, which the executor uses after an unknown
|
|
1045
|
+
* outcome. A `checking` older than `staleBefore` belonged to a worker or request that stopped and may be taken over.
|
|
1046
|
+
*/
|
|
1047
|
+
export const claimAiToolCheck = (input: {
|
|
1048
|
+
conversationId: string;
|
|
1049
|
+
turnId: string;
|
|
1050
|
+
callId: string;
|
|
1051
|
+
at: string;
|
|
1052
|
+
staleBefore: string;
|
|
1053
|
+
first?: boolean;
|
|
1054
|
+
}): Promise<boolean> =>
|
|
1055
|
+
writeAiToolCheckWhere(
|
|
1056
|
+
input,
|
|
1057
|
+
{ state: "checking", at: input.at },
|
|
1058
|
+
(current) => sql`
|
|
1059
|
+
${input.first ? sql`${current} IS NULL OR` : sql``}
|
|
1060
|
+
${current}->>'state' IN ('pending', 'failed')
|
|
1061
|
+
OR (${current}->>'state' = 'checking' AND COALESCE(${current}->>'at', '') < ${input.staleBefore}::text)`,
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
/** Stores the result of the claim made at `claimedAt`, only while that claim still holds. */
|
|
1065
|
+
export const settleAiToolCheck = (input: {
|
|
1066
|
+
conversationId: string;
|
|
1067
|
+
turnId: string;
|
|
1068
|
+
callId: string;
|
|
1069
|
+
claimedAt: string;
|
|
1070
|
+
check: AiActionCheck;
|
|
1071
|
+
}): Promise<boolean> =>
|
|
1072
|
+
writeAiToolCheckWhere(
|
|
1073
|
+
input,
|
|
1074
|
+
input.check,
|
|
1075
|
+
(current) => sql`${current}->>'state' = 'checking' AND ${current}->>'at' = ${input.claimedAt}::text`,
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
/** The stored check of one call, or null. */
|
|
1079
|
+
export const readAiToolCheck = async (input: { conversationId: string; turnId: string; callId: string }): Promise<AiActionCheck | null> => {
|
|
1080
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
1081
|
+
const [row] = await sql<{ check: AiActionCheck | null }[]>`
|
|
1082
|
+
SELECT meta->'toolChecks'->${input.callId}::text AS check FROM ${sql(table)}
|
|
1083
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND loop_id = ${input.turnId}::text
|
|
1084
|
+
AND role = 'assistant'
|
|
1085
|
+
AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
|
|
1086
|
+
LIMIT 1`;
|
|
1087
|
+
if (row?.check) return row.check;
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
932
1092
|
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
933
1093
|
const insertMessageLocked = async (
|
|
934
1094
|
input: {
|
|
@@ -1219,15 +1379,17 @@ export const aiConversations: AiConversationService = {
|
|
|
1219
1379
|
);
|
|
1220
1380
|
const target = rows[0];
|
|
1221
1381
|
if (!target) throw new Error("Failed to fork conversation.");
|
|
1382
|
+
// Copies keep their original send time because Recent orders by it.
|
|
1222
1383
|
await tx`
|
|
1223
1384
|
INSERT INTO ai.messages (
|
|
1224
1385
|
short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
|
|
1225
|
-
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
|
|
1386
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta, created_at
|
|
1226
1387
|
)
|
|
1227
1388
|
SELECT
|
|
1228
1389
|
short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
|
|
1229
1390
|
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
|
|
1230
|
-
|
|
1391
|
+
${copiedMessageMetaJson()},
|
|
1392
|
+
created_at
|
|
1231
1393
|
FROM ai.messages
|
|
1232
1394
|
WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
1233
1395
|
AND compacted_at IS NULL
|
|
@@ -1563,6 +1725,18 @@ export const aiConversations: AiConversationService = {
|
|
|
1563
1725
|
};
|
|
1564
1726
|
},
|
|
1565
1727
|
|
|
1728
|
+
listRecentSentResourceRefs: async ({ ownerUserId }) => {
|
|
1729
|
+
const rows = await recentSentResourceMarkersQuery(ownerUserId);
|
|
1730
|
+
return collectRecentSentResourceRefs(
|
|
1731
|
+
rows.map((row) => ({
|
|
1732
|
+
conversationId: row.conversation_id,
|
|
1733
|
+
messageId: row.short_id,
|
|
1734
|
+
lastSentAt: iso(row.created_at),
|
|
1735
|
+
markers: row.markers,
|
|
1736
|
+
})),
|
|
1737
|
+
);
|
|
1738
|
+
},
|
|
1739
|
+
|
|
1566
1740
|
listUserConversationResources: async (input) => {
|
|
1567
1741
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1568
1742
|
const pattern = searchPattern(input.search);
|
|
@@ -2507,6 +2681,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2507
2681
|
|
|
2508
2682
|
await sql.begin(async (tx) => {
|
|
2509
2683
|
await tx`SELECT id FROM ai.conversations WHERE id = ${input.targetConversationId} FOR UPDATE`;
|
|
2684
|
+
// Copies keep their original send time because Recent orders by it.
|
|
2510
2685
|
await tx`
|
|
2511
2686
|
INSERT INTO ai.messages (
|
|
2512
2687
|
short_id,
|
|
@@ -2523,7 +2698,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2523
2698
|
stop_reason,
|
|
2524
2699
|
loop_aggregate,
|
|
2525
2700
|
loop_done_reason,
|
|
2526
|
-
meta
|
|
2701
|
+
meta,
|
|
2702
|
+
created_at
|
|
2527
2703
|
)
|
|
2528
2704
|
SELECT
|
|
2529
2705
|
short_id,
|
|
@@ -2540,7 +2716,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2540
2716
|
stop_reason,
|
|
2541
2717
|
loop_aggregate,
|
|
2542
2718
|
loop_done_reason,
|
|
2543
|
-
|
|
2719
|
+
${copiedMessageMetaJson()},
|
|
2720
|
+
created_at
|
|
2544
2721
|
FROM ai.messages
|
|
2545
2722
|
WHERE conversation_id = ${input.sourceConversationId}
|
|
2546
2723
|
AND compacted_at IS NULL
|
|
@@ -2862,10 +3039,33 @@ export const aiConversations: AiConversationService = {
|
|
|
2862
3039
|
`;
|
|
2863
3040
|
if (!rows[0]) return null;
|
|
2864
3041
|
// The waits let a reconnecting client show work time without time spent waiting for the user.
|
|
2865
|
-
const waits = await
|
|
3042
|
+
const [waits, metadata] = await Promise.all([
|
|
3043
|
+
activeTurnWaits(rows[0].id),
|
|
3044
|
+
sql<{ tool_reviews: Record<string, AiStoredActionReview> | null; tool_checks: Record<string, AiActionCheck> | null }[]>`
|
|
3045
|
+
SELECT meta->'toolReviews' AS tool_reviews, meta->'toolChecks' AS tool_checks
|
|
3046
|
+
FROM (
|
|
3047
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
|
|
3048
|
+
UNION ALL
|
|
3049
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
|
|
3050
|
+
) messages
|
|
3051
|
+
WHERE conversation_id = ${input.conversationId}
|
|
3052
|
+
AND loop_id = ${rows[0].id}
|
|
3053
|
+
AND role = 'assistant'
|
|
3054
|
+
AND (meta ? 'toolReviews' OR meta ? 'toolChecks')
|
|
3055
|
+
ORDER BY seq ASC
|
|
3056
|
+
`,
|
|
3057
|
+
]);
|
|
3058
|
+
// A person may re-check while the model continues. Durable checks outrank the worker's earlier live snapshot.
|
|
3059
|
+
const reviews = new Map(metadata.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
|
|
3060
|
+
const checks = new Map(metadata.flatMap((row) => Object.entries(row.tool_checks ?? {})));
|
|
2866
3061
|
return {
|
|
2867
3062
|
turn: rowToTurn(rows[0]),
|
|
2868
|
-
liveBlocks: rowToLiveBlocks(rows[0])
|
|
3063
|
+
liveBlocks: rowToLiveBlocks(rows[0]).map((block) => {
|
|
3064
|
+
if (block.kind !== "tool") return block;
|
|
3065
|
+
const review = reviews.get(block.callId);
|
|
3066
|
+
const check = checks.get(block.callId);
|
|
3067
|
+
return { ...block, ...(review ? { review } : {}), ...(check ? { check } : {}) };
|
|
3068
|
+
}),
|
|
2869
3069
|
liveSeq: Number(rows[0].live_seq ?? 0),
|
|
2870
3070
|
actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
|
|
2871
3071
|
waitingSince: waits.waitingSince,
|
|
@@ -3578,7 +3778,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3578
3778
|
if (input.background && input.turnId) {
|
|
3579
3779
|
const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
|
|
3580
3780
|
const context = input.background.context;
|
|
3581
|
-
return [...context, ...own.map((row) => ({ seq: context.length + row.seq
|
|
3781
|
+
return [...context, ...projectSettledAiToolChecks(own.map((row) => ({ ...row, seq: context.length + row.seq })))];
|
|
3582
3782
|
}
|
|
3583
3783
|
// Results delivered after this turn started belong to the next turn's context.
|
|
3584
3784
|
// Compare durable message sequences rather than wall-clock timestamps.
|
|
@@ -3592,20 +3792,23 @@ export const aiConversations: AiConversationService = {
|
|
|
3592
3792
|
`
|
|
3593
3793
|
).map(rowToMessage)
|
|
3594
3794
|
: await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3595
|
-
return
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3795
|
+
return projectSettledAiToolChecks(
|
|
3796
|
+
rows.map((row) => ({
|
|
3797
|
+
seq: row.seq,
|
|
3798
|
+
kind: row.kind,
|
|
3799
|
+
meta: row.meta,
|
|
3800
|
+
message:
|
|
3801
|
+
input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
|
|
3802
|
+
? {
|
|
3803
|
+
role: "user" as const,
|
|
3804
|
+
content:
|
|
3805
|
+
typeof input.turnInput === "string"
|
|
3806
|
+
? [{ type: "text" as const, text: input.turnInput }]
|
|
3807
|
+
: input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
|
|
3808
|
+
}
|
|
3809
|
+
: row.message,
|
|
3810
|
+
})),
|
|
3811
|
+
);
|
|
3609
3812
|
},
|
|
3610
3813
|
append: async (message, opts) => {
|
|
3611
3814
|
// Initial input and durable steering are already persisted transactionally before Nessi appends them.
|
package/src/ai/tool-audit.ts
CHANGED
|
@@ -30,6 +30,14 @@ export const aiToolAudit = {
|
|
|
30
30
|
LIMIT 1`;
|
|
31
31
|
return row?.conversation_id ?? null;
|
|
32
32
|
},
|
|
33
|
+
hasCapabilityDispatch: async (input: { turnId: string; callId: string }): Promise<boolean> => {
|
|
34
|
+
const rows = await sql`
|
|
35
|
+
SELECT 1 FROM ai.tool_calls
|
|
36
|
+
WHERE turn_id = ${input.turnId} AND call_id = ${input.callId} AND request_id IS NOT NULL
|
|
37
|
+
LIMIT 1
|
|
38
|
+
`;
|
|
39
|
+
return rows.length > 0;
|
|
40
|
+
},
|
|
33
41
|
noteCapabilityDispatch: async (input: {
|
|
34
42
|
conversationId: string;
|
|
35
43
|
turnId: string;
|
package/src/ai/tool-wording.ts
CHANGED
|
@@ -2,14 +2,22 @@ import {
|
|
|
2
2
|
type CapabilityActionWording,
|
|
3
3
|
capabilityActionOutcome,
|
|
4
4
|
capabilityActionSubject,
|
|
5
|
+
capabilityReviewSentence,
|
|
6
|
+
capabilitySentenceText,
|
|
5
7
|
resolveCapabilityActionSentences,
|
|
6
8
|
} from "../_internal/capability-sentences";
|
|
9
|
+
import { CapabilityActionReceiptSchema } from "../contracts/capabilities";
|
|
10
|
+
import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "./action-review";
|
|
7
11
|
import { displayToolName, isRecord } from "./chat/message-utils";
|
|
8
12
|
import type { AiTurnBlock } from "./protocol";
|
|
9
13
|
import { CLOUD_AI_TOOL_PRESENTATION } from "./tool-sentences";
|
|
10
14
|
|
|
11
15
|
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
12
16
|
|
|
17
|
+
/** The review Cloud stored for the call, else the one its open approval carries. */
|
|
18
|
+
const reviewOf = (block: ToolBlock): AiStoredActionReview | null =>
|
|
19
|
+
block.review ?? (block.approval?.review ? storedActionReview(block.approval.review) : null);
|
|
20
|
+
|
|
13
21
|
/**
|
|
14
22
|
* How the chat words one call that acts on something: an app Action through its saved presentation, a
|
|
15
23
|
* Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
|
|
@@ -28,17 +36,31 @@ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionW
|
|
|
28
36
|
return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
|
|
29
37
|
};
|
|
30
38
|
|
|
31
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* What the call does or would do, as one sentence; `null` for calls that only read. A stored review's action sentence
|
|
41
|
+
* wins, so it reads the same after a reload and after the app changed. An old review's message is the question of the
|
|
42
|
+
* card, not the call's short subject: steps and receipts keep the app's catalog sentence.
|
|
43
|
+
*/
|
|
32
44
|
export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
|
|
45
|
+
const review = reviewOf(block);
|
|
46
|
+
if (review && !isLegacyActionReview(review))
|
|
47
|
+
return capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone });
|
|
33
48
|
const wording = toolWording(block, locale);
|
|
34
49
|
return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
|
|
35
50
|
};
|
|
36
51
|
|
|
37
52
|
/** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
|
|
38
53
|
export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
|
|
54
|
+
const result = isRecord(block.result) ? block.result : undefined;
|
|
55
|
+
const review = reviewOf(block);
|
|
56
|
+
if (review && !isLegacyActionReview(review)) {
|
|
57
|
+
const receipt = key === "done" ? CapabilityActionReceiptSchema.safeParse(result?.receipt) : null;
|
|
58
|
+
if (receipt?.success) return capabilitySentenceText(receipt.data.text, receipt.data.slots ?? {}, { locale: review.locale, timeZone });
|
|
59
|
+
return capabilityReviewSentence(review, key, { timeZone });
|
|
60
|
+
}
|
|
61
|
+
if (review && key === "done" && review.legacy.summary) return review.legacy.summary;
|
|
39
62
|
const wording = toolWording(block, locale);
|
|
40
63
|
if (!wording) return null;
|
|
41
|
-
const result = isRecord(block.result) ? block.result : undefined;
|
|
42
64
|
// App Actions return their data inside the result envelope; Cloud tools return the data itself.
|
|
43
65
|
const data = block.presentation?.kind === "capability" ? result?.data : block.result;
|
|
44
66
|
return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
|
package/src/ai/types.ts
CHANGED
|
@@ -13,11 +13,32 @@ import type {
|
|
|
13
13
|
import type { Usage } from "@k2b/nessi/ai";
|
|
14
14
|
import type { z } from "zod";
|
|
15
15
|
import type { CapabilitySentenceField } from "../_internal/capability-sentences";
|
|
16
|
-
import type {
|
|
16
|
+
import type { CapabilityActionReviewData, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
|
|
17
17
|
import type { RequestActor } from "../server";
|
|
18
|
+
import type { AiActionCheck, AiStoredActionReview } from "./action-review";
|
|
18
19
|
import type { AiLocalClient, AiLocalContext, AiLocalToolName } from "./local-tool-contracts";
|
|
19
20
|
import type { AiTurnBlock } from "./protocol";
|
|
20
21
|
|
|
22
|
+
/** Current reader-confirmed metadata for a recently sent resource. */
|
|
23
|
+
export type AiRecentResource = {
|
|
24
|
+
ref: CloudResourceRef;
|
|
25
|
+
title: string;
|
|
26
|
+
icon?: string;
|
|
27
|
+
href?: string;
|
|
28
|
+
kind: string;
|
|
29
|
+
preview?: string;
|
|
30
|
+
appName: string;
|
|
31
|
+
count: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AiRecentSentResourceRef = {
|
|
35
|
+
ref: CloudResourceRef;
|
|
36
|
+
count: number;
|
|
37
|
+
lastSentAt: string;
|
|
38
|
+
/** Internal conversation UUID, only for authorized Studio reads. */
|
|
39
|
+
conversationId: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
21
42
|
export const AI_MODEL_CAPABILITIES = ["streaming", "tools", "vision", "transcription"] as const;
|
|
22
43
|
export type AiModelCapability = (typeof AI_MODEL_CAPABILITIES)[number];
|
|
23
44
|
|
|
@@ -308,6 +329,8 @@ export type AiStoredMessage = {
|
|
|
308
329
|
trigger: "scheduled" | "manual";
|
|
309
330
|
};
|
|
310
331
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
332
|
+
toolReviews?: Record<string, AiStoredActionReview>;
|
|
333
|
+
toolChecks?: Record<string, AiActionCheck>;
|
|
311
334
|
/** How the person decided an approval; `expired` when the turn ended while it still waited. */
|
|
312
335
|
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
313
336
|
/** Requests a website approval let through, by tool call and then by `AiWebsiteReceipt.index`. */
|
|
@@ -428,7 +451,7 @@ export type AiPendingTurnAction =
|
|
|
428
451
|
name: string;
|
|
429
452
|
args: unknown;
|
|
430
453
|
message?: string;
|
|
431
|
-
review?:
|
|
454
|
+
review?: CapabilityActionReviewData;
|
|
432
455
|
allowAlways: boolean;
|
|
433
456
|
/** Whether the approval can be remembered for this chat. */
|
|
434
457
|
allowChat: boolean;
|
|
@@ -464,7 +487,7 @@ export type AiPendingTurnActionRecord = {
|
|
|
464
487
|
name: string;
|
|
465
488
|
args: unknown;
|
|
466
489
|
message?: string;
|
|
467
|
-
review?:
|
|
490
|
+
review?: CapabilityActionReviewData;
|
|
468
491
|
approvalScope: string;
|
|
469
492
|
allowAlways: boolean;
|
|
470
493
|
/** Whether the approval can be remembered for this chat; defaults to `allowAlways`. */
|
|
@@ -621,6 +644,7 @@ export type AiBackgroundRunContext = {
|
|
|
621
644
|
trigger?: "scheduled" | "manual";
|
|
622
645
|
timezone?: string;
|
|
623
646
|
stopDeliveredAt?: string;
|
|
647
|
+
needsApproval?: { action: string };
|
|
624
648
|
taskId: string;
|
|
625
649
|
occurrenceId: string;
|
|
626
650
|
context: Array<{ seq: number; kind: "message" | "summary"; message: Message }>;
|
|
@@ -765,6 +789,7 @@ export type AiConversationService = {
|
|
|
765
789
|
before?: string;
|
|
766
790
|
limit?: number;
|
|
767
791
|
}): Promise<{ resources: AiConversationResourceRef[]; nextCursor?: string }>;
|
|
792
|
+
listRecentSentResourceRefs(input: { ownerUserId: string }): Promise<AiRecentSentResourceRef[]>;
|
|
768
793
|
listUserConversationResources(input: {
|
|
769
794
|
ownerUserId: string;
|
|
770
795
|
search?: string;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
UserProviderSchema,
|
|
15
15
|
} from "../contracts";
|
|
16
16
|
import { type AuthContext, auth, expectUserBackedActor, jsonResponse, requiresAuth, respond, v } from "../server";
|
|
17
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
17
18
|
import { accountsAppService as accountsService } from "../services";
|
|
18
19
|
|
|
19
20
|
const EntitiesListResponseSchema = z.object({
|
|
@@ -91,7 +92,7 @@ type AccountsEntitiesRouteDependencies = {
|
|
|
91
92
|
};
|
|
92
93
|
|
|
93
94
|
export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRouteDependencies = {}) =>
|
|
94
|
-
new Hono<AuthContext>().get(
|
|
95
|
+
new Hono<AuthContext>().use("/entities/*", noTransform()).get(
|
|
95
96
|
"/entities",
|
|
96
97
|
dependencies.authenticate ?? auth.requireRole("authenticated"),
|
|
97
98
|
dependencies.requireUser ?? auth.requireUser(),
|
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { AiProjectLastAdminError, aiProjects } from "../ai/projects";
|
|
4
4
|
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
5
5
|
import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
|
|
6
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
6
7
|
|
|
7
8
|
const ListProjectsSchema = z
|
|
8
9
|
.object({
|
|
@@ -20,6 +21,7 @@ const lastAdminConflict = (c: Parameters<typeof respond>[0], error: AiProjectLas
|
|
|
20
21
|
|
|
21
22
|
export const createAdminAiProjectsRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
|
|
22
23
|
new Hono<AuthContext>()
|
|
24
|
+
.use("*", noTransform())
|
|
23
25
|
.use("*", authenticate)
|
|
24
26
|
.get("/", v("query", ListProjectsSchema), async (c) => {
|
|
25
27
|
const query = c.req.valid("query");
|
|
@@ -4,6 +4,7 @@ import { aiAppSkills } from "../ai/app-skill-store";
|
|
|
4
4
|
import { AiSkillInputError, AiSkillLastAdminError, AiSkillRevisionConflictError, aiSkills } from "../ai/skills";
|
|
5
5
|
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
6
6
|
import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
|
|
7
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
7
8
|
|
|
8
9
|
const ListSkillsSchema = z
|
|
9
10
|
.object({
|
|
@@ -32,6 +33,7 @@ const installAppSkill = async (c: Parameters<typeof respond>[0], install: () =>
|
|
|
32
33
|
|
|
33
34
|
export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
|
|
34
35
|
new Hono<AuthContext>()
|
|
36
|
+
.use("*", noTransform())
|
|
35
37
|
.use("*", authenticate)
|
|
36
38
|
.get("/", v("query", ListSkillsSchema), async (c) => {
|
|
37
39
|
const query = c.req.valid("query");
|
|
@@ -3,6 +3,7 @@ import { describeRoute } from "hono-openapi";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { aiUsage } from "../ai/usage";
|
|
5
5
|
import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
|
|
6
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
6
7
|
import { AiUsageQuerySchema } from "../shared/ai-usage";
|
|
7
8
|
|
|
8
9
|
export const createAdminAiUsageRoutes = (
|
|
@@ -10,6 +11,7 @@ export const createAdminAiUsageRoutes = (
|
|
|
10
11
|
service: typeof aiUsage = aiUsage,
|
|
11
12
|
) =>
|
|
12
13
|
new Hono<AuthContext>()
|
|
14
|
+
.use("*", noTransform())
|
|
13
15
|
.use("*", authenticate)
|
|
14
16
|
.get(
|
|
15
17
|
"/report",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { describeRoute } from "hono-openapi";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { GEOIP_MAX_DATABASE_BYTES, GEOIP_MAX_REQUEST_BYTES } from "../contracts/geoip";
|
|
5
|
+
import { type AuthContext, auth, v } from "../server";
|
|
6
|
+
import { REQUEST_IDLE_TIMEOUT_MAX_SECONDS, REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
|
|
7
|
+
import { GeoIpError, geoip } from "../services/geoip";
|
|
8
|
+
|
|
9
|
+
export const createAdminGeoIpRoutes = (
|
|
10
|
+
authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin"),
|
|
11
|
+
service: typeof geoip = geoip,
|
|
12
|
+
) =>
|
|
13
|
+
new Hono<AuthContext>()
|
|
14
|
+
.use(authenticate)
|
|
15
|
+
.onError((error, c) => {
|
|
16
|
+
if (error instanceof GeoIpError)
|
|
17
|
+
return c.json(
|
|
18
|
+
{
|
|
19
|
+
code: error.code,
|
|
20
|
+
...(error.code === "geoip_too_large" ? { maxDatabaseBytes: GEOIP_MAX_DATABASE_BYTES } : {}),
|
|
21
|
+
...(error.code === "geoip_request_too_large" ? { maxRequestBytes: GEOIP_MAX_REQUEST_BYTES } : {}),
|
|
22
|
+
},
|
|
23
|
+
error.status,
|
|
24
|
+
);
|
|
25
|
+
if (c.req.raw.signal.aborted) return new Response(null, { status: 499 });
|
|
26
|
+
throw error;
|
|
27
|
+
})
|
|
28
|
+
.get(
|
|
29
|
+
"/",
|
|
30
|
+
describeRoute({ tags: ["Sign-in locations"], summary: "Show active sign-in location databases and upload limits" }),
|
|
31
|
+
auth.requireOAuthScope("read", "admin"),
|
|
32
|
+
async (c) => c.json(await service.status(c.get("user"))),
|
|
33
|
+
)
|
|
34
|
+
.put(
|
|
35
|
+
"/",
|
|
36
|
+
describeRoute({
|
|
37
|
+
tags: ["Sign-in locations"],
|
|
38
|
+
summary: "Replace a City or ASN database with a raw or gzip MMDB upload",
|
|
39
|
+
requestBody: {
|
|
40
|
+
required: true,
|
|
41
|
+
content: {
|
|
42
|
+
"application/octet-stream": { schema: { type: "string", format: "binary" } },
|
|
43
|
+
"application/gzip": { schema: { type: "string", format: "binary" } },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
auth.requireOAuthScope("write", "admin"),
|
|
48
|
+
async (c) => {
|
|
49
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_MAX_SECONDS);
|
|
50
|
+
try {
|
|
51
|
+
const length = c.req.header("Content-Length");
|
|
52
|
+
return c.json(
|
|
53
|
+
await service.upload(
|
|
54
|
+
c.get("user"),
|
|
55
|
+
c.req.raw.body ??
|
|
56
|
+
new ReadableStream({
|
|
57
|
+
start(controller) {
|
|
58
|
+
controller.close();
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
{
|
|
62
|
+
contentLength: length === undefined ? undefined : Number(length),
|
|
63
|
+
signal: c.req.raw.signal,
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
} finally {
|
|
68
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
.delete(
|
|
73
|
+
"/:kind",
|
|
74
|
+
describeRoute({ tags: ["Sign-in locations"], summary: "Reset one sign-in database to the bundled snapshot" }),
|
|
75
|
+
auth.requireOAuthScope("write", "admin"),
|
|
76
|
+
v("param", z.object({ kind: z.enum(["city", "asn"]) })),
|
|
77
|
+
async (c) => c.json(await service.reset(c.get("user"), c.req.valid("param").kind)),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
export default createAdminGeoIpRoutes();
|