@k2b/cloud 0.6.0-rc.0 → 0.7.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 +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/skills.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { aiProjects } from "./projects";
|
|
2
|
+
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { accessRevision } from "../server/services/access-revision";
|
|
1
5
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
2
6
|
import type { AccessSubject } from "../server";
|
|
3
7
|
import {
|
|
@@ -9,6 +13,7 @@ import {
|
|
|
9
13
|
type Principal,
|
|
10
14
|
} from "../server/services/access";
|
|
11
15
|
import { toPgUuidArray } from "../services/postgres";
|
|
16
|
+
import { aiSkillSearchSql, withAiSkillSearch } from "./skill-search";
|
|
12
17
|
import { mountAiSkillFilePath } from "./file-mount";
|
|
13
18
|
import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
|
|
14
19
|
import {
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
validateAiSkillName,
|
|
22
27
|
validateAiSkillReferences,
|
|
23
28
|
} from "./skill-format";
|
|
29
|
+
import { getBuiltinAiSkillTemplates } from "./skill-seeds";
|
|
24
30
|
import type { AiSkillFileToolContent, AiSkillFileToolStat } from "./types";
|
|
25
31
|
|
|
26
32
|
export type AiSkillPermission = Exclude<PermissionLevel, "none">;
|
|
@@ -55,7 +61,22 @@ export type AiSkillAccess = {
|
|
|
55
61
|
createdAt: string;
|
|
56
62
|
};
|
|
57
63
|
|
|
64
|
+
export type AiSkillTemplate = {
|
|
65
|
+
key: string;
|
|
66
|
+
version: number;
|
|
67
|
+
name: string;
|
|
68
|
+
description: string;
|
|
69
|
+
instructions: string;
|
|
70
|
+
extraFrontmatter?: AiSkillExtraFrontmatter;
|
|
71
|
+
references?: readonly AiSkillReferenceInput[];
|
|
72
|
+
};
|
|
73
|
+
|
|
58
74
|
export type AiSkillAdminListItem = {
|
|
75
|
+
revision: number;
|
|
76
|
+
templateId: string | null;
|
|
77
|
+
templateVersion: number | null;
|
|
78
|
+
currentTemplateVersion: number | null;
|
|
79
|
+
templateStatus: "current" | "modified" | "update_available" | null;
|
|
59
80
|
id: string;
|
|
60
81
|
shortId: string;
|
|
61
82
|
name: string;
|
|
@@ -86,6 +107,9 @@ type SkillRow = {
|
|
|
86
107
|
id: string;
|
|
87
108
|
short_id: string;
|
|
88
109
|
managed_key: string | null;
|
|
110
|
+
template_id: string | null;
|
|
111
|
+
template_version: number | null;
|
|
112
|
+
template_hash: string | null;
|
|
89
113
|
name: string;
|
|
90
114
|
description: string;
|
|
91
115
|
instructions: string;
|
|
@@ -106,13 +130,14 @@ type SkillAccessRow = {
|
|
|
106
130
|
display_name: string | null;
|
|
107
131
|
};
|
|
108
132
|
|
|
109
|
-
type SkillSummaryRow = SkillRow & {
|
|
133
|
+
type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
|
|
110
134
|
permission: AiSkillPermission;
|
|
111
135
|
reference_count: number;
|
|
112
136
|
enabled: boolean;
|
|
113
137
|
};
|
|
114
138
|
|
|
115
139
|
type AdminSkillRow = SkillRow & {
|
|
140
|
+
content_references: AiSkillReference[] | string;
|
|
116
141
|
reference_count: number;
|
|
117
142
|
access_count: number;
|
|
118
143
|
admin_count: number;
|
|
@@ -162,7 +187,7 @@ const principalForSubject = (subject: AccessSubject): Principal =>
|
|
|
162
187
|
: { type: "service_account", serviceAccountId: subject.serviceAccountId };
|
|
163
188
|
|
|
164
189
|
const accessMatch = (subject: AccessSubject | null): SQLQuery =>
|
|
165
|
-
buildAccessPrincipalCondition({
|
|
190
|
+
subject === null ? sql`FALSE` : buildAccessPrincipalCondition({
|
|
166
191
|
subject,
|
|
167
192
|
columns: {
|
|
168
193
|
userId: sql`access.user_id`,
|
|
@@ -181,17 +206,22 @@ const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow
|
|
|
181
206
|
const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | null> =>
|
|
182
207
|
(await db<SkillRow[]>`SELECT * FROM ai.skills WHERE name = ${name}`)[0] ?? null;
|
|
183
208
|
|
|
209
|
+
// Project links grant read only. Their creator's later permissions are irrelevant.
|
|
210
|
+
const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null) => sql`
|
|
211
|
+
SELECT permission FROM (
|
|
212
|
+
SELECT access.permission FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
|
|
213
|
+
WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
|
|
214
|
+
UNION ALL
|
|
215
|
+
SELECT 'read'::auth.permission_level FROM ai.project_skills link
|
|
216
|
+
JOIN ai.project_access project_access ON project_access.project_id=link.project_id
|
|
217
|
+
JOIN auth.access access ON access.id=project_access.access_id
|
|
218
|
+
WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
|
|
219
|
+
) grants ORDER BY CASE permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC LIMIT 1
|
|
220
|
+
`;
|
|
221
|
+
|
|
184
222
|
const permissionFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<AiSkillPermission | "none"> => {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
SELECT access.permission
|
|
188
|
-
FROM ai.skill_access skill_access
|
|
189
|
-
JOIN auth.access access ON access.id = skill_access.access_id
|
|
190
|
-
WHERE skill_access.skill_id = ${skillId}::uuid AND ${match}
|
|
191
|
-
ORDER BY CASE access.permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC
|
|
192
|
-
LIMIT 1
|
|
193
|
-
`;
|
|
194
|
-
return rows[0]?.permission ?? "none";
|
|
223
|
+
const [row] = await db<{ permission: AiSkillPermission | null }[]>`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject)}) AS permission`;
|
|
224
|
+
return row?.permission ?? "none";
|
|
195
225
|
};
|
|
196
226
|
|
|
197
227
|
const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
|
|
@@ -280,6 +310,7 @@ const createSkillAccess = async (
|
|
|
280
310
|
input: { principal: Principal; permission: AiSkillPermission },
|
|
281
311
|
db: SQL,
|
|
282
312
|
): Promise<AiSkillAccess> => {
|
|
313
|
+
AuthenticatedPrincipalSchema.parse(input.principal);
|
|
283
314
|
const created = await createAccess(input, db);
|
|
284
315
|
if (!created.ok) throw new Error(created.error.message);
|
|
285
316
|
let shortId = "";
|
|
@@ -300,6 +331,11 @@ const createSkillAccess = async (
|
|
|
300
331
|
return entry;
|
|
301
332
|
};
|
|
302
333
|
|
|
334
|
+
const checkAccessRevision = async (skillId: string, expected: string | undefined, db: SQL) => {
|
|
335
|
+
if (expected !== undefined && accessRevision(await listSkillAccess(skillId, db)) !== expected)
|
|
336
|
+
throw new AiSkillRevisionConflictError();
|
|
337
|
+
};
|
|
338
|
+
|
|
303
339
|
const updateSkillAccess = async (skillId: string, accessId: string, permission: AiSkillPermission, db: SQL): Promise<boolean> => {
|
|
304
340
|
const [bound] = await db<{ id: string; permission: AiSkillPermission }[]>`
|
|
305
341
|
SELECT access.id, access.permission
|
|
@@ -361,26 +397,92 @@ const validatedFields = (input: {
|
|
|
361
397
|
}
|
|
362
398
|
};
|
|
363
399
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
400
|
+
// JSON object key order and reference insertion order are not content changes.
|
|
401
|
+
const canonicalJson = (value: unknown): string => {
|
|
402
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
403
|
+
if (value !== null && typeof value === "object") {
|
|
404
|
+
return `{${Object.entries(value)
|
|
405
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
406
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
|
|
407
|
+
.join(",")}}`;
|
|
408
|
+
}
|
|
409
|
+
return JSON.stringify(value);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const contentHash = (fields: ReturnType<typeof validatedFields>): string =>
|
|
413
|
+
createHash("sha256")
|
|
414
|
+
.update(
|
|
415
|
+
canonicalJson({
|
|
416
|
+
...fields,
|
|
417
|
+
references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
|
418
|
+
}),
|
|
419
|
+
)
|
|
420
|
+
.digest("hex");
|
|
421
|
+
|
|
422
|
+
const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
|
|
423
|
+
contentHash({
|
|
424
|
+
name: row.name,
|
|
425
|
+
description: row.description,
|
|
426
|
+
instructions: row.instructions,
|
|
427
|
+
extraFrontmatter: jsonObject(row.extra_frontmatter),
|
|
428
|
+
references: await listReferences(row.id, db),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const validateTemplate = (input: AiSkillTemplate) => {
|
|
432
|
+
if (!input.key.trim() || !Number.isSafeInteger(input.version) || input.version < 1 || input.version > 2147483647)
|
|
433
|
+
throw new AiSkillInputError("A template needs a stable ID and a positive integer version.");
|
|
434
|
+
const fields = validatedFields(input);
|
|
435
|
+
return { fields, hash: contentHash(fields) };
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const replaceTemplate = async (row: SkillRow, template: AiSkillTemplate, db: SQL): Promise<void> => {
|
|
439
|
+
const { fields, hash } = validateTemplate(template);
|
|
440
|
+
await db`UPDATE ai.skills SET name = ${fields.name}, description = ${fields.description},
|
|
441
|
+
instructions = ${fields.instructions}, extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
|
|
442
|
+
template_id = ${template.key}, template_version = ${template.version}, template_hash = ${hash},
|
|
443
|
+
revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
|
|
444
|
+
await db`DELETE FROM ai.skill_references WHERE skill_id = ${row.id}::uuid`;
|
|
445
|
+
for (const ref of fields.references)
|
|
446
|
+
await db`INSERT INTO ai.skill_references (skill_id, path, content)
|
|
447
|
+
VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
|
|
448
|
+
};
|
|
367
449
|
|
|
368
450
|
const skillSearchPattern = (search?: string): string | null => {
|
|
369
451
|
const value = search?.trim().toLowerCase();
|
|
370
452
|
return value ? `%${value.replace(/[\\%_]/g, (char) => `\\${char}`)}%` : null;
|
|
371
453
|
};
|
|
372
454
|
|
|
373
|
-
const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem =>
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
455
|
+
const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => {
|
|
456
|
+
const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === row.template_id);
|
|
457
|
+
return {
|
|
458
|
+
revision: Number(row.revision),
|
|
459
|
+
templateId: row.template_id,
|
|
460
|
+
templateVersion: row.template_version,
|
|
461
|
+
currentTemplateVersion: template?.version ?? null,
|
|
462
|
+
templateStatus: !row.template_id
|
|
463
|
+
? null
|
|
464
|
+
: template && template.version > Number(row.template_version)
|
|
465
|
+
? "update_available"
|
|
466
|
+
: contentHash({
|
|
467
|
+
name: row.name,
|
|
468
|
+
description: row.description,
|
|
469
|
+
instructions: row.instructions,
|
|
470
|
+
extraFrontmatter: jsonObject(row.extra_frontmatter),
|
|
471
|
+
references: snapshotFiles(row.content_references),
|
|
472
|
+
}) === row.template_hash
|
|
473
|
+
? "current"
|
|
474
|
+
: "modified",
|
|
475
|
+
id: row.id,
|
|
476
|
+
shortId: row.short_id,
|
|
477
|
+
name: row.name,
|
|
478
|
+
description: row.description,
|
|
479
|
+
referenceCount: Number(row.reference_count),
|
|
480
|
+
accessCount: Number(row.access_count),
|
|
481
|
+
adminCount: Number(row.admin_count),
|
|
482
|
+
createdAt: iso(row.created_at),
|
|
483
|
+
updatedAt: iso(row.updated_at),
|
|
484
|
+
};
|
|
485
|
+
};
|
|
384
486
|
|
|
385
487
|
const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[] => [
|
|
386
488
|
{
|
|
@@ -390,90 +492,104 @@ const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[]
|
|
|
390
492
|
...skill.references.map((reference) => ({ path: `${skill.name}/${reference.path}`, content: reference.content })),
|
|
391
493
|
];
|
|
392
494
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
495
|
+
type SkillListOptions = { query?: string; enabledOnly?: boolean; limit?: number };
|
|
496
|
+
async function listSkillSummaries(subject: AccessSubject | null, options: SkillListOptions & {
|
|
497
|
+
project?: { id: string; linked: boolean }; manageOnly?: boolean; offset?: number;
|
|
498
|
+
} = {}): Promise<AiSkillSummary[]> {
|
|
499
|
+
const userId = subject?.type === "user" ? subject.userId : null;
|
|
500
|
+
const query = (options.query ?? "").trim().slice(0, 200);
|
|
501
|
+
const limit = Math.max(1, Math.min(201, options.limit ?? 200));
|
|
502
|
+
const rows = await withAiSkillSearch(query, async (bm25) => {
|
|
503
|
+
const search = aiSkillSearchSql(query, bm25);
|
|
504
|
+
return sql<SkillSummaryRow[]>`
|
|
505
|
+
SELECT skill.id, skill.short_id, skill.name, skill.description, skill.revision, skill.created_at, skill.updated_at,
|
|
506
|
+
access.permission,
|
|
507
|
+
count(DISTINCT reference.path)::int AS reference_count,
|
|
508
|
+
(disabled.user_id IS NULL) AS enabled
|
|
509
|
+
FROM ai.skills skill
|
|
510
|
+
JOIN LATERAL (${effectivePermission(sql`skill.id`, subject)}) access ON true
|
|
511
|
+
LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
|
|
512
|
+
LEFT JOIN ai.skill_user_disabled disabled
|
|
513
|
+
ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
|
|
514
|
+
WHERE access.permission <> 'none'
|
|
515
|
+
AND (${!options.manageOnly} OR access.permission='admin')
|
|
516
|
+
AND ${options.project ? sql`EXISTS(SELECT 1 FROM ai.project_skills link WHERE link.skill_id=skill.id AND link.project_id=${options.project.id}::uuid) = ${options.project.linked}` : sql`true`}
|
|
517
|
+
AND (${!options.enabledOnly} OR disabled.user_id IS NULL) AND ${search.matches}
|
|
518
|
+
GROUP BY skill.id, disabled.user_id, access.permission
|
|
519
|
+
ORDER BY ${search.rank} DESC, ${search.textRank} DESC, skill.name, skill.id
|
|
520
|
+
LIMIT ${limit} OFFSET ${options.offset ?? 0}
|
|
521
|
+
`;
|
|
522
|
+
});
|
|
523
|
+
return rows.map((row) => ({
|
|
524
|
+
id: row.id,
|
|
525
|
+
shortId: row.short_id,
|
|
526
|
+
name: row.name,
|
|
527
|
+
description: row.description,
|
|
528
|
+
permission: row.permission,
|
|
529
|
+
revision: Number(row.revision),
|
|
530
|
+
referenceCount: Number(row.reference_count),
|
|
531
|
+
enabled: row.enabled,
|
|
532
|
+
createdAt: iso(row.created_at),
|
|
533
|
+
updatedAt: iso(row.updated_at),
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
412
536
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
VALUES (
|
|
423
|
-
${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
|
|
424
|
-
(${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb
|
|
425
|
-
)
|
|
426
|
-
RETURNING *
|
|
427
|
-
`,
|
|
428
|
-
);
|
|
429
|
-
row = rows[0]!;
|
|
430
|
-
} else {
|
|
431
|
-
const references = await listReferences(row.id, tx);
|
|
432
|
-
const changed =
|
|
433
|
-
row.name !== fields.name ||
|
|
434
|
-
row.description !== fields.description ||
|
|
435
|
-
row.instructions !== fields.instructions ||
|
|
436
|
-
JSON.stringify(jsonObject(row.extra_frontmatter)) !== JSON.stringify(fields.extraFrontmatter) ||
|
|
437
|
-
!sameReferences(references, fields.references);
|
|
438
|
-
if (changed) {
|
|
439
|
-
[row] = await tx<SkillRow[]>`
|
|
440
|
-
UPDATE ai.skills
|
|
441
|
-
SET name = ${fields.name}, description = ${fields.description}, instructions = ${fields.instructions},
|
|
442
|
-
extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
|
|
443
|
-
managed_key = NULL, revision = revision + 1, updated_at = now()
|
|
444
|
-
WHERE id = ${row.id}::uuid
|
|
445
|
-
RETURNING *
|
|
446
|
-
`;
|
|
447
|
-
} else {
|
|
448
|
-
[row] = await tx<SkillRow[]>`
|
|
449
|
-
UPDATE ai.skills SET managed_key = NULL WHERE id = ${row.id}::uuid RETURNING *
|
|
450
|
-
`;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
537
|
+
async function linkedSkillProjects(skillId: string, subject: AccessSubject | null) {
|
|
538
|
+
const links = await sql<{projectId:string}[]>`SELECT project_id AS "projectId" FROM ai.project_skills WHERE skill_id=${skillId}::uuid ORDER BY project_id`;
|
|
539
|
+
const result: Array<{ projectId: string; shortId: string | null; name: string | null }> = [];
|
|
540
|
+
for (const link of links) {
|
|
541
|
+
const project = await aiProjects.get(link.projectId, subject);
|
|
542
|
+
result.push({ ...link, shortId: project?.shortId ?? null, name: project?.name ?? null });
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
453
546
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
547
|
+
export const aiSkills = {
|
|
548
|
+
async seedOnce(template: AiSkillTemplate): Promise<void> {
|
|
549
|
+
const { fields, hash } = validateTemplate(template);
|
|
550
|
+
await sql.begin(async (tx) => {
|
|
551
|
+
const [claimed] = await tx`INSERT INTO ai.skill_seeds (key, catalog_version)
|
|
552
|
+
VALUES (${template.key}, ${template.version}) ON CONFLICT (key) DO NOTHING RETURNING key`;
|
|
553
|
+
// Seed lock always precedes the Skill lock, including administrator adoption/reset.
|
|
554
|
+
const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
|
|
555
|
+
SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
|
|
556
|
+
if (!seed || seed.catalog_version > template.version) return;
|
|
557
|
+
await tx`UPDATE ai.skill_seeds SET catalog_version = ${template.version} WHERE key = ${template.key}`;
|
|
558
|
+
if (seed.skill_id) {
|
|
559
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${seed.skill_id}::uuid FOR UPDATE`;
|
|
560
|
+
if (!row || row.template_id !== template.key || Number(row.template_version) >= template.version) return;
|
|
561
|
+
if ((await rowContentHash(row, tx)) !== row.template_hash) return;
|
|
562
|
+
// A user may have claimed the new name. Keep the existing Skill rather than fail startup.
|
|
563
|
+
const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name} AND id <> ${row.id}::uuid`;
|
|
564
|
+
if (!collision) {
|
|
565
|
+
try {
|
|
566
|
+
await tx.savepoint((attempt) => replaceTemplate(row, template, attempt));
|
|
567
|
+
} catch (error) {
|
|
568
|
+
// A concurrent normal create may claim the name after our read.
|
|
569
|
+
if (!(typeof error === "object" && error !== null && "constraint" in error && error.constraint === "idx_ai_skills_name"))
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
462
572
|
}
|
|
573
|
+
return;
|
|
463
574
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
575
|
+
// Old seed markers and name collisions never authorize adoption or replacement.
|
|
576
|
+
if (!claimed) return;
|
|
577
|
+
const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
|
|
578
|
+
if (collision) return;
|
|
579
|
+
const [row] = await withAiShortIdForDb(
|
|
580
|
+
tx,
|
|
581
|
+
"idx_ai_skills_short_id",
|
|
582
|
+
(attempt, shortId) => attempt<SkillRow[]>`
|
|
583
|
+
INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter, template_id, template_version, template_hash)
|
|
584
|
+
VALUES (${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
|
|
585
|
+
(${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb, ${template.key}, ${template.version}, ${hash}) ON CONFLICT (name) DO NOTHING RETURNING *`,
|
|
586
|
+
);
|
|
587
|
+
if (!row) return;
|
|
588
|
+
for (const ref of fields.references)
|
|
589
|
+
await tx`INSERT INTO ai.skill_references (skill_id, path, content)
|
|
590
|
+
VALUES (${row!.id}::uuid, ${ref.path}, ${ref.content})`;
|
|
591
|
+
await tx`UPDATE ai.skill_seeds SET skill_id = ${row!.id}::uuid WHERE key = ${template.key}`;
|
|
592
|
+
await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
|
|
477
593
|
});
|
|
478
594
|
},
|
|
479
595
|
|
|
@@ -510,39 +626,38 @@ export const aiSkills = {
|
|
|
510
626
|
});
|
|
511
627
|
},
|
|
512
628
|
|
|
513
|
-
async list(subject: AccessSubject | null): Promise<AiSkillSummary[]> {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}));
|
|
629
|
+
async list(subject: AccessSubject | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
|
|
630
|
+
return listSkillSummaries(subject, options);
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
async projectSkills(projectId: string, subject: AccessSubject | null, options: { query?: string; page?: number; available?: boolean } = {}) {
|
|
634
|
+
if (!(await aiProjects.get(projectId, subject, options.available ? "admin" : "read"))) return null;
|
|
635
|
+
const page = Math.max(1, Math.min(100000, Math.trunc(options.page ?? 1)));
|
|
636
|
+
const skills = await listSkillSummaries(subject, { query: options.query, limit: 31, offset: (page-1)*30,
|
|
637
|
+
project: { id: projectId, linked: !options.available }, manageOnly: options.available });
|
|
638
|
+
return { items: skills.slice(0,30), hasNext: skills.length>30, page };
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
async linkProject(skillId: string, projectId: string, linked: boolean, subject: AccessSubject | null): Promise<boolean> {
|
|
642
|
+
if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
|
|
643
|
+
return sql.begin(async tx => {
|
|
644
|
+
const [row] = await tx`SELECT id FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
|
|
645
|
+
if (!row || !hasPermission(await permissionFor(skillId, subject, tx), "admin")) return false;
|
|
646
|
+
if (linked) await tx`INSERT INTO ai.project_skills(project_id,skill_id) VALUES(${projectId}::uuid,${skillId}::uuid) ON CONFLICT DO NOTHING`;
|
|
647
|
+
else await tx`DELETE FROM ai.project_skills WHERE project_id=${projectId}::uuid AND skill_id=${skillId}::uuid`;
|
|
648
|
+
return true;
|
|
649
|
+
});
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
async linkedProjects(skillId: string, subject: AccessSubject | null) {
|
|
653
|
+
if (!(await this.get(skillId, subject, "admin"))) return null;
|
|
654
|
+
return linkedSkillProjects(skillId, subject);
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
async search(subject: AccessSubject | null, query: string, limit = 30): Promise<{ skills: AiSkillSummary[]; more: boolean }> {
|
|
658
|
+
const size = Math.max(1, Math.min(200, limit));
|
|
659
|
+
const skills = await aiSkills.list(subject, { query, enabledOnly: true, limit: size + 1 });
|
|
660
|
+
return { skills: skills.slice(0, size), more: skills.length > size };
|
|
546
661
|
},
|
|
547
662
|
|
|
548
663
|
async get(skillId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
|
|
@@ -607,39 +722,89 @@ export const aiSkills = {
|
|
|
607
722
|
},
|
|
608
723
|
|
|
609
724
|
async listAccess(skillId: string, subject: AccessSubject | null): Promise<AiSkillAccess[] | null> {
|
|
610
|
-
|
|
611
|
-
|
|
725
|
+
return sql.begin(async tx => {
|
|
726
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
|
|
727
|
+
if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
|
|
728
|
+
return listSkillAccess(skillId, tx);
|
|
729
|
+
});
|
|
612
730
|
},
|
|
613
731
|
|
|
614
732
|
async grantAccess(
|
|
615
733
|
skillId: string,
|
|
616
734
|
subject: AccessSubject | null,
|
|
617
735
|
input: { principal: Principal; permission: AiSkillPermission },
|
|
736
|
+
expectedAccessRevision?: string,
|
|
618
737
|
): Promise<AiSkillAccess | null> {
|
|
619
738
|
return sql.begin(async (tx) => {
|
|
620
739
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
621
740
|
if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
|
|
741
|
+
await checkAccessRevision(skillId, expectedAccessRevision, tx);
|
|
622
742
|
return createSkillAccess(skillId, input, tx);
|
|
623
743
|
});
|
|
624
744
|
},
|
|
625
745
|
|
|
626
|
-
async updateAccess(skillId: string, accessId: string, subject: AccessSubject | null, permission: AiSkillPermission): Promise<boolean> {
|
|
746
|
+
async updateAccess(skillId: string, accessId: string, subject: AccessSubject | null, permission: AiSkillPermission, expectedAccessRevision?: string): Promise<boolean> {
|
|
627
747
|
return sql.begin(async (tx) => {
|
|
628
748
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
749
|
+
if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
|
|
750
|
+
await checkAccessRevision(skillId, expectedAccessRevision, tx);
|
|
751
|
+
return updateSkillAccess(skillId, accessId, permission, tx);
|
|
632
752
|
});
|
|
633
753
|
},
|
|
634
754
|
|
|
635
|
-
async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null): Promise<boolean> {
|
|
755
|
+
async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null, expectedAccessRevision?: string): Promise<boolean> {
|
|
636
756
|
return sql.begin(async (tx) => {
|
|
637
757
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
638
|
-
|
|
758
|
+
if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
|
|
759
|
+
await checkAccessRevision(skillId, expectedAccessRevision, tx);
|
|
760
|
+
return revokeSkillAccess(skillId, accessId, tx);
|
|
639
761
|
});
|
|
640
762
|
},
|
|
641
763
|
|
|
642
764
|
admin: {
|
|
765
|
+
linkedProjects: linkedSkillProjects,
|
|
766
|
+
// Platform-owned recovery API: callers must enforce platform-admin authorization.
|
|
767
|
+
async applyTemplate(
|
|
768
|
+
skillId: string,
|
|
769
|
+
input: {
|
|
770
|
+
templateId: string;
|
|
771
|
+
templateVersion: number;
|
|
772
|
+
expectedRevision: number;
|
|
773
|
+
mode: "associate" | "reset";
|
|
774
|
+
},
|
|
775
|
+
): Promise<boolean> {
|
|
776
|
+
const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === input.templateId);
|
|
777
|
+
if (!template) throw new AiSkillInputError("Unknown Skill template.");
|
|
778
|
+
if (template.version !== input.templateVersion) throw new AiSkillRevisionConflictError();
|
|
779
|
+
const { hash } = validateTemplate(template);
|
|
780
|
+
return sql.begin(async (tx) => {
|
|
781
|
+
await tx`INSERT INTO ai.skill_seeds (key, catalog_version) VALUES (${template.key}, ${template.version})
|
|
782
|
+
ON CONFLICT (key) DO NOTHING`;
|
|
783
|
+
const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
|
|
784
|
+
SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
|
|
785
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
786
|
+
if (!row) return false;
|
|
787
|
+
if (
|
|
788
|
+
Number(row.revision) !== input.expectedRevision ||
|
|
789
|
+
seed!.catalog_version > template.version ||
|
|
790
|
+
Number(row.template_version) > template.version
|
|
791
|
+
)
|
|
792
|
+
throw new AiSkillRevisionConflictError();
|
|
793
|
+
if (seed!.skill_id && seed!.skill_id !== row.id)
|
|
794
|
+
throw new AiSkillInputError("This template is already linked, possibly to a deleted Skill.");
|
|
795
|
+
if (input.mode === "associate") {
|
|
796
|
+
if (row.template_id) throw new AiSkillInputError("This Skill already has a template.");
|
|
797
|
+
await tx`UPDATE ai.skills SET template_id = ${template.key}, template_version = ${template.version},
|
|
798
|
+
template_hash = ${hash}, managed_key = NULL, revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
|
|
799
|
+
} else {
|
|
800
|
+
if (row.template_id !== template.key) throw new AiSkillInputError("Associate this Skill with its template first.");
|
|
801
|
+
await replaceTemplate(row, template, tx);
|
|
802
|
+
}
|
|
803
|
+
await tx`UPDATE ai.skill_seeds SET skill_id = ${row.id}::uuid, catalog_version = ${template.version} WHERE key = ${template.key}`;
|
|
804
|
+
return true;
|
|
805
|
+
});
|
|
806
|
+
},
|
|
807
|
+
|
|
643
808
|
async list(params: {
|
|
644
809
|
search?: string;
|
|
645
810
|
page?: number;
|
|
@@ -659,6 +824,8 @@ export const aiSkills = {
|
|
|
659
824
|
`;
|
|
660
825
|
const rows = await sql<AdminSkillRow[]>`
|
|
661
826
|
SELECT skill.*,
|
|
827
|
+
(SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
|
|
828
|
+
FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
|
|
662
829
|
count(DISTINCT reference.path)::int AS reference_count,
|
|
663
830
|
count(DISTINCT skill_access.access_id)::int AS access_count,
|
|
664
831
|
count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
|
|
@@ -704,6 +871,8 @@ export const aiSkills = {
|
|
|
704
871
|
async getByShortId(shortId: string): Promise<AiSkillAdminListItem | null> {
|
|
705
872
|
const rows = await sql<AdminSkillRow[]>`
|
|
706
873
|
SELECT skill.*,
|
|
874
|
+
(SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
|
|
875
|
+
FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
|
|
707
876
|
count(DISTINCT reference.path)::int AS reference_count,
|
|
708
877
|
count(DISTINCT skill_access.access_id)::int AS access_count,
|
|
709
878
|
count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
|
|
@@ -885,17 +1054,15 @@ export const aiSkills = {
|
|
|
885
1054
|
},
|
|
886
1055
|
|
|
887
1056
|
async listTurnFiles(turnId: string, subject: AccessSubject | null): Promise<AiSkillFileToolStat[]> {
|
|
888
|
-
const match = accessMatch(subject);
|
|
889
1057
|
const userId = subject?.type === "user" ? subject.userId : null;
|
|
890
1058
|
const rows = await sql<SnapshotRow[]>`
|
|
891
1059
|
SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
|
|
892
1060
|
snapshot.files, snapshot.loaded_at
|
|
893
1061
|
FROM ai.turn_skill_snapshots snapshot
|
|
894
|
-
JOIN
|
|
895
|
-
JOIN auth.access access ON access.id = skill_access.access_id
|
|
1062
|
+
JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
|
|
896
1063
|
LEFT JOIN ai.skill_user_disabled disabled
|
|
897
1064
|
ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
|
|
898
|
-
WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
|
|
1065
|
+
WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
|
|
899
1066
|
AND disabled.user_id IS NULL
|
|
900
1067
|
ORDER BY snapshot.skill_name
|
|
901
1068
|
`;
|
|
@@ -914,21 +1081,23 @@ export const aiSkills = {
|
|
|
914
1081
|
const stat = files.find((file) => file.path === path);
|
|
915
1082
|
if (!stat) return null;
|
|
916
1083
|
const [skillName] = path.split("/", 1);
|
|
917
|
-
const match = accessMatch(subject);
|
|
918
1084
|
const userId = subject?.type === "user" ? subject.userId : null;
|
|
919
1085
|
const [row] = await sql<SnapshotRow[]>`
|
|
920
1086
|
SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
|
|
921
1087
|
snapshot.files, snapshot.loaded_at
|
|
922
1088
|
FROM ai.turn_skill_snapshots snapshot
|
|
923
|
-
JOIN
|
|
924
|
-
JOIN auth.access access ON access.id = skill_access.access_id
|
|
1089
|
+
JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
|
|
925
1090
|
LEFT JOIN ai.skill_user_disabled disabled
|
|
926
1091
|
ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
|
|
927
1092
|
WHERE snapshot.turn_id = ${turnId}::uuid AND snapshot.skill_name = ${skillName}
|
|
928
|
-
AND access.permission <> 'none' AND
|
|
1093
|
+
AND access.permission <> 'none' AND disabled.user_id IS NULL
|
|
929
1094
|
LIMIT 1
|
|
930
1095
|
`;
|
|
931
1096
|
const file = row ? snapshotFiles(row.files).find((entry) => entry.path === path) : null;
|
|
932
1097
|
return file ? { ...stat, bytes: new TextEncoder().encode(file.content) } : null;
|
|
933
1098
|
},
|
|
934
1099
|
};
|
|
1100
|
+
|
|
1101
|
+
export const seedCloudAiSkills = async (): Promise<void> => {
|
|
1102
|
+
for (const seed of getBuiltinAiSkillTemplates()) await aiSkills.seedOnce(seed);
|
|
1103
|
+
};
|