@k2b/cloud 0.29.0 → 0.30.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 -3
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +111 -3
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +10 -1
- package/src/_internal/registry-validation.ts +5 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +203 -185
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +12 -26
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +4 -4
- package/src/ai/chat/turn-view.tsx +141 -69
- package/src/ai/code-runtime-tools.ts +59 -44
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +19 -21
- package/src/ai/index.ts +9 -1
- package/src/ai/migrate.ts +59 -12
- package/src/ai/protocol.ts +9 -4
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +20 -13
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/types.ts +10 -2
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +259 -100
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/widgets.ts +28 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +20 -0
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/services/accounts/users.ts +15 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/shared/network-address.ts +1 -0
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +142 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
package/src/ai/skills.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
3
2
|
import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
|
|
4
3
|
import type { AccessSubject } from "../server";
|
|
@@ -11,10 +10,12 @@ import {
|
|
|
11
10
|
type Principal,
|
|
12
11
|
} from "../server/services/access";
|
|
13
12
|
import { accessRevision } from "../server/services/access-revision";
|
|
14
|
-
import {
|
|
13
|
+
import { getRoles } from "../services/accounts/users";
|
|
14
|
+
import { toPgTextArray, toPgUuidArray } from "../services/postgres";
|
|
15
15
|
import { mountAiSkillFilePath } from "./file-mount";
|
|
16
16
|
import { aiProjects } from "./projects";
|
|
17
17
|
import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
|
|
18
|
+
import { type AiSkillContent, appSkillStatus, contentHash } from "./skill-content";
|
|
18
19
|
import {
|
|
19
20
|
type AiSkillExtraFrontmatter,
|
|
20
21
|
type AiSkillReferenceInput,
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
validateAiSkillReferences,
|
|
27
28
|
} from "./skill-format";
|
|
28
29
|
import { aiSkillSearchSql, withAiSkillSearch } from "./skill-search";
|
|
29
|
-
import { getBuiltinAiSkillTemplates } from "./skill-seeds";
|
|
30
30
|
import type { AiSkillFileToolContent, AiSkillFileToolStat } from "./types";
|
|
31
31
|
|
|
32
32
|
export type AiSkillPermission = Exclude<PermissionLevel, "none">;
|
|
@@ -34,6 +34,7 @@ export type AiSkillPermission = Exclude<PermissionLevel, "none">;
|
|
|
34
34
|
export type AiSkillReference = AiSkillReferenceInput;
|
|
35
35
|
|
|
36
36
|
export type AiSkillSummary = {
|
|
37
|
+
source: { appId: string; appName: string } | null;
|
|
37
38
|
id: string;
|
|
38
39
|
shortId: string;
|
|
39
40
|
name: string;
|
|
@@ -64,22 +65,16 @@ export type AiSkillAccess = {
|
|
|
64
65
|
createdAt: string;
|
|
65
66
|
};
|
|
66
67
|
|
|
67
|
-
export type AiSkillTemplate = {
|
|
68
|
-
key: string;
|
|
69
|
-
version: number;
|
|
70
|
-
name: string;
|
|
71
|
-
description: string;
|
|
72
|
-
instructions: string;
|
|
73
|
-
extraFrontmatter?: AiSkillExtraFrontmatter;
|
|
74
|
-
references?: readonly AiSkillReferenceInput[];
|
|
75
|
-
};
|
|
76
|
-
|
|
77
68
|
export type AiSkillAdminListItem = {
|
|
78
69
|
revision: number;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
source: {
|
|
71
|
+
appId: string;
|
|
72
|
+
appName: string;
|
|
73
|
+
status: "current" | "modified" | "update_available";
|
|
74
|
+
available: boolean;
|
|
75
|
+
/** Content hash of the app's latest version; reset requires it. Null until the app publishes after the upgrade. */
|
|
76
|
+
appVersion: string | null;
|
|
77
|
+
} | null;
|
|
83
78
|
id: string;
|
|
84
79
|
shortId: string;
|
|
85
80
|
name: string;
|
|
@@ -106,13 +101,10 @@ export type AiLoadedSkillSnapshot = {
|
|
|
106
101
|
loadedAt: string;
|
|
107
102
|
};
|
|
108
103
|
|
|
109
|
-
type SkillRow = {
|
|
104
|
+
export type SkillRow = {
|
|
110
105
|
id: string;
|
|
111
106
|
short_id: string;
|
|
112
107
|
managed_key: string | null;
|
|
113
|
-
template_id: string | null;
|
|
114
|
-
template_version: number | null;
|
|
115
|
-
template_hash: string | null;
|
|
116
108
|
name: string;
|
|
117
109
|
description: string;
|
|
118
110
|
instructions: string;
|
|
@@ -136,12 +128,19 @@ type SkillAccessRow = {
|
|
|
136
128
|
};
|
|
137
129
|
|
|
138
130
|
type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
|
|
131
|
+
app_id: string | null;
|
|
132
|
+
app_name: string | null;
|
|
139
133
|
permission: AiSkillPermission;
|
|
140
134
|
reference_count: number;
|
|
141
135
|
enabled: boolean;
|
|
142
136
|
};
|
|
143
137
|
|
|
144
138
|
type AdminSkillRow = SkillRow & {
|
|
139
|
+
app_id: string | null;
|
|
140
|
+
app_name: string | null;
|
|
141
|
+
source_hash: string | null;
|
|
142
|
+
applied_hash: string | null;
|
|
143
|
+
available: boolean;
|
|
145
144
|
content_references: AiSkillReference[] | string;
|
|
146
145
|
reference_count: number;
|
|
147
146
|
access_count: number;
|
|
@@ -157,6 +156,13 @@ type SnapshotRow = {
|
|
|
157
156
|
loaded_at: Date | string;
|
|
158
157
|
};
|
|
159
158
|
|
|
159
|
+
export class AiSkillAppForbiddenError extends Error {
|
|
160
|
+
constructor(appName: string) {
|
|
161
|
+
super(`This Skill comes from the app ${appName}. A Cloud administrator can override it under Administration > AI Skills.`);
|
|
162
|
+
this.name = "AiSkillAppForbiddenError";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
160
166
|
export class AiSkillLastAdminError extends Error {
|
|
161
167
|
constructor() {
|
|
162
168
|
super("A skill must keep at least one admin access entry.");
|
|
@@ -165,8 +171,8 @@ export class AiSkillLastAdminError extends Error {
|
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
export class AiSkillRevisionConflictError extends Error {
|
|
168
|
-
constructor() {
|
|
169
|
-
super(
|
|
174
|
+
constructor(message = "This skill changed after it was opened. Reload it before saving.") {
|
|
175
|
+
super(message);
|
|
170
176
|
this.name = "AiSkillRevisionConflictError";
|
|
171
177
|
}
|
|
172
178
|
}
|
|
@@ -204,7 +210,7 @@ const accessMatch = (subject: AccessSubject | null): SQLQuery =>
|
|
|
204
210
|
},
|
|
205
211
|
});
|
|
206
212
|
|
|
207
|
-
const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
|
|
213
|
+
export const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
|
|
208
214
|
(await db<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid`)[0] ?? null;
|
|
209
215
|
|
|
210
216
|
const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow | null> =>
|
|
@@ -213,26 +219,58 @@ const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow
|
|
|
213
219
|
const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | null> =>
|
|
214
220
|
(await db<SkillRow[]>`SELECT * FROM ai.skills WHERE name = ${name}`)[0] ?? null;
|
|
215
221
|
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Resolve before opening a transaction: role lookup uses its own pool connection, and a transaction that waits for a
|
|
224
|
+
* second connection can exhaust the pool under concurrent requests.
|
|
225
|
+
*/
|
|
226
|
+
const subjectRoles = async (subject: AccessSubject | null): Promise<string[]> =>
|
|
227
|
+
subject?.type === "user" ? await getRoles(subject.userId) : [];
|
|
228
|
+
|
|
229
|
+
// App availability applies to every grant. Only explicit skill grants bypass its audience.
|
|
230
|
+
const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null, roles: readonly string[]) => sql`
|
|
218
231
|
SELECT permission FROM (
|
|
219
|
-
SELECT access.permission
|
|
232
|
+
SELECT access.permission, (access.user_id IS NOT NULL OR access.group_id IS NOT NULL OR access.service_account_id IS NOT NULL) AS direct
|
|
233
|
+
FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
|
|
220
234
|
WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
|
|
221
235
|
UNION ALL
|
|
222
|
-
SELECT 'read'::auth.permission_level FROM ai.project_skills link
|
|
236
|
+
SELECT 'read'::auth.permission_level, false AS direct FROM ai.project_skills link
|
|
223
237
|
JOIN ai.project_access project_access ON project_access.project_id=link.project_id
|
|
224
238
|
JOIN auth.access access ON access.id=project_access.access_id
|
|
225
239
|
WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
|
|
226
|
-
) grants
|
|
240
|
+
) grants WHERE NOT EXISTS (
|
|
241
|
+
SELECT 1 FROM ai.app_skills app WHERE app.skill_id=${skillId}
|
|
242
|
+
AND (NOT app.available OR (NOT grants.direct AND app.required_roles IS NOT NULL AND NOT(app.required_roles && ${toPgTextArray([...roles])}::text[])))
|
|
243
|
+
) ORDER BY CASE permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC LIMIT 1
|
|
227
244
|
`;
|
|
228
245
|
|
|
229
|
-
const permissionFor = async (
|
|
246
|
+
const permissionFor = async (
|
|
247
|
+
skillId: string,
|
|
248
|
+
subject: AccessSubject | null,
|
|
249
|
+
db: SQL,
|
|
250
|
+
roles: readonly string[],
|
|
251
|
+
): Promise<AiSkillPermission | "none"> => {
|
|
230
252
|
const [row] = await db<
|
|
231
253
|
{ permission: AiSkillPermission | null }[]
|
|
232
|
-
>`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject)}) AS permission`;
|
|
254
|
+
>`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject, roles)}) AS permission`;
|
|
233
255
|
return row?.permission ?? "none";
|
|
234
256
|
};
|
|
235
257
|
|
|
258
|
+
const canMutate = async (
|
|
259
|
+
skillId: string,
|
|
260
|
+
subject: AccessSubject | null,
|
|
261
|
+
required: AiSkillPermission,
|
|
262
|
+
db: SQL,
|
|
263
|
+
roles: readonly string[],
|
|
264
|
+
): Promise<boolean> => {
|
|
265
|
+
const permission = await permissionFor(skillId, subject, db, roles);
|
|
266
|
+
if (hasPermission(permission, required)) return true;
|
|
267
|
+
if (hasPermission(permission, "read")) {
|
|
268
|
+
const [app] = await db<{ app_name: string }[]>`SELECT app_name FROM ai.app_skills WHERE skill_id=${skillId}::uuid`;
|
|
269
|
+
if (app) throw new AiSkillAppForbiddenError(app.app_name);
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
};
|
|
273
|
+
|
|
236
274
|
const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
|
|
237
275
|
if (subject?.type !== "user") return true;
|
|
238
276
|
const [disabled] = await db<{ disabled: boolean }[]>`
|
|
@@ -243,18 +281,22 @@ const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQ
|
|
|
243
281
|
return !disabled;
|
|
244
282
|
};
|
|
245
283
|
|
|
246
|
-
const listReferences = async (skillId: string, db: SQL = sql): Promise<AiSkillReference[]> =>
|
|
284
|
+
export const listReferences = async (skillId: string, db: SQL = sql): Promise<AiSkillReference[]> =>
|
|
247
285
|
db<AiSkillReference[]>`
|
|
248
286
|
SELECT path, content FROM ai.skill_references
|
|
249
287
|
WHERE skill_id = ${skillId}::uuid
|
|
250
288
|
ORDER BY path
|
|
251
289
|
`;
|
|
252
290
|
|
|
253
|
-
const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL
|
|
254
|
-
const permission = await permissionFor(row.id, subject, db);
|
|
291
|
+
const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL, roles: readonly string[]): Promise<AiSkill | null> => {
|
|
292
|
+
const permission = await permissionFor(row.id, subject, db, roles);
|
|
255
293
|
if (permission === "none") return null;
|
|
256
294
|
const references = await listReferences(row.id, db);
|
|
295
|
+
const [source] = await db<
|
|
296
|
+
{ appId: string; appName: string }[]
|
|
297
|
+
>`SELECT app_id AS "appId", app_name AS "appName" FROM ai.app_skills WHERE skill_id=${row.id}::uuid`;
|
|
257
298
|
return {
|
|
299
|
+
source: source ?? null,
|
|
258
300
|
id: row.id,
|
|
259
301
|
shortId: row.short_id,
|
|
260
302
|
name: row.name,
|
|
@@ -274,11 +316,14 @@ const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL = s
|
|
|
274
316
|
const requireSkill = async (
|
|
275
317
|
row: SkillRow | null,
|
|
276
318
|
subject: AccessSubject | null,
|
|
277
|
-
required: AiSkillPermission
|
|
278
|
-
db: SQL
|
|
319
|
+
required: AiSkillPermission,
|
|
320
|
+
db: SQL,
|
|
321
|
+
roles: readonly string[],
|
|
279
322
|
): Promise<AiSkill | null> => {
|
|
280
323
|
if (!row) return null;
|
|
281
|
-
const skill = await toSkill(row, subject, db);
|
|
324
|
+
const skill = await toSkill(row, subject, db, roles);
|
|
325
|
+
if (skill && !hasPermission(skill.permission, required) && skill.source && required !== "read")
|
|
326
|
+
throw new AiSkillAppForbiddenError(skill.source.appName);
|
|
282
327
|
return skill && hasPermission(skill.permission, required) ? skill : null;
|
|
283
328
|
};
|
|
284
329
|
|
|
@@ -317,7 +362,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
317
362
|
}));
|
|
318
363
|
};
|
|
319
364
|
|
|
320
|
-
const createSkillAccess = async (
|
|
365
|
+
export const createSkillAccess = async (
|
|
321
366
|
skillId: string,
|
|
322
367
|
input: { principal: Principal; permission: AiSkillPermission },
|
|
323
368
|
db: SQL,
|
|
@@ -408,29 +453,7 @@ const validatedFields = (input: {
|
|
|
408
453
|
}
|
|
409
454
|
};
|
|
410
455
|
|
|
411
|
-
|
|
412
|
-
const canonicalJson = (value: unknown): string => {
|
|
413
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
414
|
-
if (value !== null && typeof value === "object") {
|
|
415
|
-
return `{${Object.entries(value)
|
|
416
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
417
|
-
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
|
|
418
|
-
.join(",")}}`;
|
|
419
|
-
}
|
|
420
|
-
return JSON.stringify(value);
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const contentHash = (fields: ReturnType<typeof validatedFields>): string =>
|
|
424
|
-
createHash("sha256")
|
|
425
|
-
.update(
|
|
426
|
-
canonicalJson({
|
|
427
|
-
...fields,
|
|
428
|
-
references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
|
|
429
|
-
}),
|
|
430
|
-
)
|
|
431
|
-
.digest("hex");
|
|
432
|
-
|
|
433
|
-
const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
|
|
456
|
+
export const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
|
|
434
457
|
contentHash({
|
|
435
458
|
name: row.name,
|
|
436
459
|
description: row.description,
|
|
@@ -439,23 +462,13 @@ const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
|
|
|
439
462
|
references: await listReferences(row.id, db),
|
|
440
463
|
});
|
|
441
464
|
|
|
442
|
-
const
|
|
443
|
-
if (!input.key.trim() || !Number.isSafeInteger(input.version) || input.version < 1 || input.version > 2147483647)
|
|
444
|
-
throw new AiSkillInputError("A template needs a stable ID and a positive integer version.");
|
|
445
|
-
const fields = validatedFields(input);
|
|
446
|
-
return { fields, hash: contentHash(fields) };
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
const replaceTemplate = async (row: SkillRow, template: AiSkillTemplate, db: SQL): Promise<void> => {
|
|
450
|
-
const { fields, hash } = validateTemplate(template);
|
|
465
|
+
export const replaceSkillContent = async (row: SkillRow, fields: AiSkillContent, db: SQL): Promise<void> => {
|
|
451
466
|
await db`UPDATE ai.skills SET name = ${fields.name}, description = ${fields.description},
|
|
452
467
|
instructions = ${fields.instructions}, extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
|
|
453
|
-
template_id = ${template.key}, template_version = ${template.version}, template_hash = ${hash},
|
|
454
468
|
revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
|
|
455
469
|
await db`DELETE FROM ai.skill_references WHERE skill_id = ${row.id}::uuid`;
|
|
456
470
|
for (const ref of fields.references)
|
|
457
|
-
await db`INSERT INTO ai.skill_references (skill_id, path, content)
|
|
458
|
-
VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
|
|
471
|
+
await db`INSERT INTO ai.skill_references (skill_id, path, content) VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
|
|
459
472
|
};
|
|
460
473
|
|
|
461
474
|
const skillSearchPattern = (search?: string): string | null => {
|
|
@@ -464,25 +477,25 @@ const skillSearchPattern = (search?: string): string | null => {
|
|
|
464
477
|
};
|
|
465
478
|
|
|
466
479
|
const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => {
|
|
467
|
-
const
|
|
480
|
+
const hash = contentHash({
|
|
481
|
+
name: row.name,
|
|
482
|
+
description: row.description,
|
|
483
|
+
instructions: row.instructions,
|
|
484
|
+
extraFrontmatter: jsonObject(row.extra_frontmatter),
|
|
485
|
+
references: snapshotFiles(row.content_references),
|
|
486
|
+
});
|
|
468
487
|
return {
|
|
469
488
|
revision: Number(row.revision),
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
instructions: row.instructions,
|
|
481
|
-
extraFrontmatter: jsonObject(row.extra_frontmatter),
|
|
482
|
-
references: snapshotFiles(row.content_references),
|
|
483
|
-
}) === row.template_hash
|
|
484
|
-
? "current"
|
|
485
|
-
: "modified",
|
|
489
|
+
source:
|
|
490
|
+
row.app_id && row.app_name
|
|
491
|
+
? {
|
|
492
|
+
appId: row.app_id,
|
|
493
|
+
appName: row.app_name,
|
|
494
|
+
status: appSkillStatus(hash, row.source_hash, row.applied_hash),
|
|
495
|
+
appVersion: row.source_hash,
|
|
496
|
+
available: row.available,
|
|
497
|
+
}
|
|
498
|
+
: null,
|
|
486
499
|
id: row.id,
|
|
487
500
|
shortId: row.short_id,
|
|
488
501
|
name: row.name,
|
|
@@ -512,6 +525,7 @@ async function listSkillSummaries(
|
|
|
512
525
|
offset?: number;
|
|
513
526
|
} = {},
|
|
514
527
|
): Promise<AiSkillSummary[]> {
|
|
528
|
+
const roles = await subjectRoles(subject);
|
|
515
529
|
const userId = subject?.type === "user" ? subject.userId : null;
|
|
516
530
|
const query = (options.query ?? "").trim().slice(0, 200);
|
|
517
531
|
const limit = Math.max(1, Math.min(201, options.limit ?? 200));
|
|
@@ -519,11 +533,12 @@ async function listSkillSummaries(
|
|
|
519
533
|
const search = aiSkillSearchSql(query, bm25);
|
|
520
534
|
return sql<SkillSummaryRow[]>`
|
|
521
535
|
SELECT skill.id, skill.short_id, skill.name, skill.description, skill.revision, skill.created_at, skill.updated_at,
|
|
522
|
-
access.permission,
|
|
536
|
+
app_skill.app_id, app_skill.app_name, access.permission,
|
|
523
537
|
count(DISTINCT reference.path)::int AS reference_count,
|
|
524
538
|
(disabled.user_id IS NULL) AS enabled
|
|
525
539
|
FROM ai.skills skill
|
|
526
|
-
JOIN
|
|
540
|
+
LEFT JOIN ai.app_skills app_skill ON app_skill.skill_id=skill.id
|
|
541
|
+
JOIN LATERAL (${effectivePermission(sql`skill.id`, subject, roles)}) access ON true
|
|
527
542
|
LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
|
|
528
543
|
LEFT JOIN ai.skill_user_disabled disabled
|
|
529
544
|
ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
|
|
@@ -531,12 +546,13 @@ async function listSkillSummaries(
|
|
|
531
546
|
AND (${!options.manageOnly} OR access.permission='admin')
|
|
532
547
|
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`}
|
|
533
548
|
AND (${!options.enabledOnly} OR disabled.user_id IS NULL) AND ${search.matches}
|
|
534
|
-
GROUP BY skill.id, disabled.user_id, access.permission
|
|
549
|
+
GROUP BY skill.id, app_skill.key, disabled.user_id, access.permission
|
|
535
550
|
ORDER BY ${search.rank} DESC, ${search.textRank} DESC, skill.name, skill.id
|
|
536
551
|
LIMIT ${limit} OFFSET ${options.offset ?? 0}
|
|
537
552
|
`;
|
|
538
553
|
});
|
|
539
554
|
return rows.map((row) => ({
|
|
555
|
+
source: row.app_id && row.app_name ? { appId: row.app_id, appName: row.app_name } : null,
|
|
540
556
|
id: row.id,
|
|
541
557
|
shortId: row.short_id,
|
|
542
558
|
name: row.name,
|
|
@@ -564,6 +580,33 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
|
|
|
564
580
|
|
|
565
581
|
export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
|
|
566
582
|
|
|
583
|
+
const listSkillTurnFiles = async (
|
|
584
|
+
turnId: string,
|
|
585
|
+
subject: AccessSubject | null,
|
|
586
|
+
roles: readonly string[],
|
|
587
|
+
): Promise<AiSkillFileToolStat[]> => {
|
|
588
|
+
const userId = subject?.type === "user" ? subject.userId : null;
|
|
589
|
+
const rows = await sql<SnapshotRow[]>`
|
|
590
|
+
SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
|
|
591
|
+
snapshot.files, snapshot.loaded_at
|
|
592
|
+
FROM ai.turn_skill_snapshots snapshot
|
|
593
|
+
JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject, roles)}) access ON true
|
|
594
|
+
LEFT JOIN ai.skill_user_disabled disabled
|
|
595
|
+
ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
|
|
596
|
+
WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
|
|
597
|
+
AND disabled.user_id IS NULL
|
|
598
|
+
ORDER BY snapshot.skill_name
|
|
599
|
+
`;
|
|
600
|
+
return rows.flatMap((row) =>
|
|
601
|
+
snapshotFiles(row.files).map((file) => ({
|
|
602
|
+
path: file.path,
|
|
603
|
+
mediaType: "text/markdown",
|
|
604
|
+
size: new TextEncoder().encode(file.content).byteLength,
|
|
605
|
+
updatedAt: iso(row.loaded_at),
|
|
606
|
+
})),
|
|
607
|
+
);
|
|
608
|
+
};
|
|
609
|
+
|
|
567
610
|
export const aiSkills = {
|
|
568
611
|
/**
|
|
569
612
|
* Skills a chat loaded, from the revision snapshots its turns pinned, most recently loaded first. The caller resolves
|
|
@@ -589,54 +632,6 @@ export const aiSkills = {
|
|
|
589
632
|
}));
|
|
590
633
|
},
|
|
591
634
|
|
|
592
|
-
async seedOnce(template: AiSkillTemplate): Promise<void> {
|
|
593
|
-
const { fields, hash } = validateTemplate(template);
|
|
594
|
-
await sql.begin(async (tx) => {
|
|
595
|
-
const [claimed] = await tx`INSERT INTO ai.skill_seeds (key, catalog_version)
|
|
596
|
-
VALUES (${template.key}, ${template.version}) ON CONFLICT (key) DO NOTHING RETURNING key`;
|
|
597
|
-
// Seed lock always precedes the Skill lock, including administrator adoption/reset.
|
|
598
|
-
const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
|
|
599
|
-
SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
|
|
600
|
-
if (!seed || seed.catalog_version > template.version) return;
|
|
601
|
-
await tx`UPDATE ai.skill_seeds SET catalog_version = ${template.version} WHERE key = ${template.key}`;
|
|
602
|
-
if (seed.skill_id) {
|
|
603
|
-
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${seed.skill_id}::uuid FOR UPDATE`;
|
|
604
|
-
if (!row || row.template_id !== template.key || Number(row.template_version) >= template.version) return;
|
|
605
|
-
if ((await rowContentHash(row, tx)) !== row.template_hash) return;
|
|
606
|
-
// A user may have claimed the new name. Keep the existing Skill rather than fail startup.
|
|
607
|
-
const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name} AND id <> ${row.id}::uuid`;
|
|
608
|
-
if (!collision) {
|
|
609
|
-
try {
|
|
610
|
-
await tx.savepoint((attempt) => replaceTemplate(row, template, attempt));
|
|
611
|
-
} catch (error) {
|
|
612
|
-
// A concurrent normal create may claim the name after our read.
|
|
613
|
-
if (!(typeof error === "object" && error !== null && "constraint" in error && error.constraint === "idx_ai_skills_name"))
|
|
614
|
-
throw error;
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
// Old seed markers and name collisions never authorize adoption or replacement.
|
|
620
|
-
if (!claimed) return;
|
|
621
|
-
const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
|
|
622
|
-
if (collision) return;
|
|
623
|
-
const [row] = await withAiShortIdForDb(
|
|
624
|
-
tx,
|
|
625
|
-
"idx_ai_skills_short_id",
|
|
626
|
-
(attempt, shortId) => attempt<SkillRow[]>`
|
|
627
|
-
INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter, template_id, template_version, template_hash)
|
|
628
|
-
VALUES (${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
|
|
629
|
-
(${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb, ${template.key}, ${template.version}, ${hash}) ON CONFLICT (name) DO NOTHING RETURNING *`,
|
|
630
|
-
);
|
|
631
|
-
if (!row) return;
|
|
632
|
-
for (const ref of fields.references)
|
|
633
|
-
await tx`INSERT INTO ai.skill_references (skill_id, path, content)
|
|
634
|
-
VALUES (${row!.id}::uuid, ${ref.path}, ${ref.content})`;
|
|
635
|
-
await tx`UPDATE ai.skill_seeds SET skill_id = ${row!.id}::uuid WHERE key = ${template.key}`;
|
|
636
|
-
await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
|
|
637
|
-
});
|
|
638
|
-
},
|
|
639
|
-
|
|
640
635
|
async create(input: {
|
|
641
636
|
subject: AccessSubject;
|
|
642
637
|
name: string;
|
|
@@ -646,6 +641,7 @@ export const aiSkills = {
|
|
|
646
641
|
references?: readonly AiSkillReferenceInput[];
|
|
647
642
|
}): Promise<AiSkill> {
|
|
648
643
|
const fields = validatedFields(input);
|
|
644
|
+
const roles = await subjectRoles(input.subject);
|
|
649
645
|
return sql.begin(async (tx) => {
|
|
650
646
|
const rows = await withAiShortIdForDb(
|
|
651
647
|
tx,
|
|
@@ -666,7 +662,7 @@ export const aiSkills = {
|
|
|
666
662
|
`;
|
|
667
663
|
}
|
|
668
664
|
await createSkillAccess(rows[0]!.id, { principal: principalForSubject(input.subject), permission: "admin" }, tx);
|
|
669
|
-
return (await toSkill(rows[0]!, input.subject, tx))!;
|
|
665
|
+
return (await toSkill(rows[0]!, input.subject, tx, roles))!;
|
|
670
666
|
});
|
|
671
667
|
},
|
|
672
668
|
|
|
@@ -693,9 +689,10 @@ export const aiSkills = {
|
|
|
693
689
|
|
|
694
690
|
async linkProject(skillId: string, projectId: string, linked: boolean, subject: AccessSubject | null): Promise<boolean> {
|
|
695
691
|
if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
|
|
692
|
+
const roles = await subjectRoles(subject);
|
|
696
693
|
return sql.begin(async (tx) => {
|
|
697
694
|
const [row] = await tx`SELECT id FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
|
|
698
|
-
if (!row || !
|
|
695
|
+
if (!row || !(await canMutate(skillId, subject, "admin", tx, roles))) return false;
|
|
699
696
|
if (linked)
|
|
700
697
|
await tx`INSERT INTO ai.project_skills(project_id,skill_id) VALUES(${projectId}::uuid,${skillId}::uuid) ON CONFLICT DO NOTHING`;
|
|
701
698
|
else await tx`DELETE FROM ai.project_skills WHERE project_id=${projectId}::uuid AND skill_id=${skillId}::uuid`;
|
|
@@ -715,15 +712,15 @@ export const aiSkills = {
|
|
|
715
712
|
},
|
|
716
713
|
|
|
717
714
|
async get(skillId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
|
|
718
|
-
return requireSkill(await getRow(skillId), subject, required);
|
|
715
|
+
return requireSkill(await getRow(skillId), subject, required, sql, await subjectRoles(subject));
|
|
719
716
|
},
|
|
720
717
|
|
|
721
718
|
async getByShortId(shortId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
|
|
722
|
-
return requireSkill(await getRowByShortId(shortId), subject, required);
|
|
719
|
+
return requireSkill(await getRowByShortId(shortId), subject, required, sql, await subjectRoles(subject));
|
|
723
720
|
},
|
|
724
721
|
|
|
725
722
|
async getByName(name: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
|
|
726
|
-
return requireSkill(await getRowByName(name), subject, required);
|
|
723
|
+
return requireSkill(await getRowByName(name), subject, required, sql, await subjectRoles(subject));
|
|
727
724
|
},
|
|
728
725
|
|
|
729
726
|
async update(
|
|
@@ -739,9 +736,10 @@ export const aiSkills = {
|
|
|
739
736
|
},
|
|
740
737
|
): Promise<AiSkill | null> {
|
|
741
738
|
const fields = validatedFields(input);
|
|
739
|
+
const roles = await subjectRoles(subject);
|
|
742
740
|
return sql.begin(async (tx) => {
|
|
743
741
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
744
|
-
if (!row || !
|
|
742
|
+
if (!row || !(await canMutate(row.id, subject, "write", tx, roles))) return null;
|
|
745
743
|
if (Number(row.revision) !== input.expectedRevision) throw new AiSkillRevisionConflictError();
|
|
746
744
|
const [updated] = await tx<SkillRow[]>`
|
|
747
745
|
UPDATE ai.skills
|
|
@@ -758,14 +756,15 @@ export const aiSkills = {
|
|
|
758
756
|
VALUES (${skillId}::uuid, ${reference.path}, ${reference.content})
|
|
759
757
|
`;
|
|
760
758
|
}
|
|
761
|
-
return toSkill(updated!, subject, tx);
|
|
759
|
+
return toSkill(updated!, subject, tx, roles);
|
|
762
760
|
});
|
|
763
761
|
},
|
|
764
762
|
|
|
765
763
|
async delete(skillId: string, subject: AccessSubject | null): Promise<boolean> {
|
|
764
|
+
const roles = await subjectRoles(subject);
|
|
766
765
|
return sql.begin(async (tx) => {
|
|
767
766
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
768
|
-
if (!row || !
|
|
767
|
+
if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return false;
|
|
769
768
|
const accessIds = (await tx<{ access_id: string }[]>`SELECT access_id FROM ai.skill_access WHERE skill_id = ${skillId}::uuid`).map(
|
|
770
769
|
(entry) => entry.access_id,
|
|
771
770
|
);
|
|
@@ -776,9 +775,10 @@ export const aiSkills = {
|
|
|
776
775
|
},
|
|
777
776
|
|
|
778
777
|
async listAccess(skillId: string, subject: AccessSubject | null): Promise<AiSkillAccess[] | null> {
|
|
778
|
+
const roles = await subjectRoles(subject);
|
|
779
779
|
return sql.begin(async (tx) => {
|
|
780
780
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
|
|
781
|
-
if (!row || !
|
|
781
|
+
if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return null;
|
|
782
782
|
return listSkillAccess(skillId, tx);
|
|
783
783
|
});
|
|
784
784
|
},
|
|
@@ -789,9 +789,10 @@ export const aiSkills = {
|
|
|
789
789
|
input: { principal: Principal; permission: AiSkillPermission },
|
|
790
790
|
expectedAccessRevision?: string,
|
|
791
791
|
): Promise<AiSkillAccess | null> {
|
|
792
|
+
const roles = await subjectRoles(subject);
|
|
792
793
|
return sql.begin(async (tx) => {
|
|
793
794
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
794
|
-
if (!row || !
|
|
795
|
+
if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return null;
|
|
795
796
|
await checkAccessRevision(skillId, expectedAccessRevision, tx);
|
|
796
797
|
return createSkillAccess(skillId, input, tx);
|
|
797
798
|
});
|
|
@@ -804,18 +805,20 @@ export const aiSkills = {
|
|
|
804
805
|
permission: AiSkillPermission,
|
|
805
806
|
expectedAccessRevision?: string,
|
|
806
807
|
): Promise<boolean> {
|
|
808
|
+
const roles = await subjectRoles(subject);
|
|
807
809
|
return sql.begin(async (tx) => {
|
|
808
810
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
809
|
-
if (!row || !
|
|
811
|
+
if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return false;
|
|
810
812
|
await checkAccessRevision(skillId, expectedAccessRevision, tx);
|
|
811
813
|
return updateSkillAccess(skillId, accessId, permission, tx);
|
|
812
814
|
});
|
|
813
815
|
},
|
|
814
816
|
|
|
815
817
|
async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null, expectedAccessRevision?: string): Promise<boolean> {
|
|
818
|
+
const roles = await subjectRoles(subject);
|
|
816
819
|
return sql.begin(async (tx) => {
|
|
817
820
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
818
|
-
if (!row || !
|
|
821
|
+
if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return false;
|
|
819
822
|
await checkAccessRevision(skillId, expectedAccessRevision, tx);
|
|
820
823
|
return revokeSkillAccess(skillId, accessId, tx);
|
|
821
824
|
});
|
|
@@ -824,47 +827,6 @@ export const aiSkills = {
|
|
|
824
827
|
admin: {
|
|
825
828
|
linkedProjects: linkedSkillProjects,
|
|
826
829
|
// Platform-owned recovery API: callers must enforce platform-admin authorization.
|
|
827
|
-
async applyTemplate(
|
|
828
|
-
skillId: string,
|
|
829
|
-
input: {
|
|
830
|
-
templateId: string;
|
|
831
|
-
templateVersion: number;
|
|
832
|
-
expectedRevision: number;
|
|
833
|
-
mode: "associate" | "reset";
|
|
834
|
-
},
|
|
835
|
-
): Promise<boolean> {
|
|
836
|
-
const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === input.templateId);
|
|
837
|
-
if (!template) throw new AiSkillInputError("Unknown Skill template.");
|
|
838
|
-
if (template.version !== input.templateVersion) throw new AiSkillRevisionConflictError();
|
|
839
|
-
const { hash } = validateTemplate(template);
|
|
840
|
-
return sql.begin(async (tx) => {
|
|
841
|
-
await tx`INSERT INTO ai.skill_seeds (key, catalog_version) VALUES (${template.key}, ${template.version})
|
|
842
|
-
ON CONFLICT (key) DO NOTHING`;
|
|
843
|
-
const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
|
|
844
|
-
SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
|
|
845
|
-
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
846
|
-
if (!row) return false;
|
|
847
|
-
if (
|
|
848
|
-
Number(row.revision) !== input.expectedRevision ||
|
|
849
|
-
seed!.catalog_version > template.version ||
|
|
850
|
-
Number(row.template_version) > template.version
|
|
851
|
-
)
|
|
852
|
-
throw new AiSkillRevisionConflictError();
|
|
853
|
-
if (seed!.skill_id && seed!.skill_id !== row.id)
|
|
854
|
-
throw new AiSkillInputError("This template is already linked, possibly to a deleted Skill.");
|
|
855
|
-
if (input.mode === "associate") {
|
|
856
|
-
if (row.template_id) throw new AiSkillInputError("This Skill already has a template.");
|
|
857
|
-
await tx`UPDATE ai.skills SET template_id = ${template.key}, template_version = ${template.version},
|
|
858
|
-
template_hash = ${hash}, managed_key = NULL, revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
|
|
859
|
-
} else {
|
|
860
|
-
if (row.template_id !== template.key) throw new AiSkillInputError("Associate this Skill with its template first.");
|
|
861
|
-
await replaceTemplate(row, template, tx);
|
|
862
|
-
}
|
|
863
|
-
await tx`UPDATE ai.skill_seeds SET skill_id = ${row.id}::uuid, catalog_version = ${template.version} WHERE key = ${template.key}`;
|
|
864
|
-
return true;
|
|
865
|
-
});
|
|
866
|
-
},
|
|
867
|
-
|
|
868
830
|
async list(params: {
|
|
869
831
|
search?: string;
|
|
870
832
|
page?: number;
|
|
@@ -883,13 +845,14 @@ export const aiSkills = {
|
|
|
883
845
|
OR lower(skill.short_id) LIKE ${pattern} ESCAPE '\\'
|
|
884
846
|
`;
|
|
885
847
|
const rows = await sql<AdminSkillRow[]>`
|
|
886
|
-
SELECT skill.*,
|
|
848
|
+
SELECT skill.*, app_skill.app_id, app_skill.app_name, app_skill.source_hash, app_skill.applied_hash, app_skill.available,
|
|
887
849
|
(SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
|
|
888
850
|
FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
|
|
889
851
|
count(DISTINCT reference.path)::int AS reference_count,
|
|
890
852
|
count(DISTINCT skill_access.access_id)::int AS access_count,
|
|
891
853
|
count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
|
|
892
854
|
FROM ai.skills skill
|
|
855
|
+
LEFT JOIN ai.app_skills app_skill ON app_skill.skill_id=skill.id
|
|
893
856
|
LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
|
|
894
857
|
LEFT JOIN ai.skill_access skill_access ON skill_access.skill_id = skill.id
|
|
895
858
|
LEFT JOIN auth.access access ON access.id = skill_access.access_id
|
|
@@ -897,7 +860,7 @@ export const aiSkills = {
|
|
|
897
860
|
OR lower(skill.name) LIKE ${pattern} ESCAPE '\\'
|
|
898
861
|
OR lower(skill.description) LIKE ${pattern} ESCAPE '\\'
|
|
899
862
|
OR lower(skill.short_id) LIKE ${pattern} ESCAPE '\\'
|
|
900
|
-
GROUP BY skill.id
|
|
863
|
+
GROUP BY skill.id, app_skill.key
|
|
901
864
|
ORDER BY skill.updated_at DESC, skill.id
|
|
902
865
|
LIMIT ${perPage} OFFSET ${offset}
|
|
903
866
|
`;
|
|
@@ -921,7 +884,7 @@ export const aiSkills = {
|
|
|
921
884
|
GROUP BY skill.id
|
|
922
885
|
)
|
|
923
886
|
SELECT count(*)::int AS total,
|
|
924
|
-
count(*) FILTER (WHERE admin_count = 0)::int AS unmanaged,
|
|
887
|
+
count(*) FILTER (WHERE admin_count = 0 AND NOT EXISTS (SELECT 1 FROM ai.app_skills WHERE skill_id=filtered.id))::int AS unmanaged,
|
|
925
888
|
coalesce(sum(access_count), 0)::int AS total_access
|
|
926
889
|
FROM filtered
|
|
927
890
|
`;
|
|
@@ -930,18 +893,19 @@ export const aiSkills = {
|
|
|
930
893
|
|
|
931
894
|
async getByShortId(shortId: string): Promise<AiSkillAdminListItem | null> {
|
|
932
895
|
const rows = await sql<AdminSkillRow[]>`
|
|
933
|
-
SELECT skill.*,
|
|
896
|
+
SELECT skill.*, app_skill.app_id, app_skill.app_name, app_skill.source_hash, app_skill.applied_hash, app_skill.available,
|
|
934
897
|
(SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
|
|
935
898
|
FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
|
|
936
899
|
count(DISTINCT reference.path)::int AS reference_count,
|
|
937
900
|
count(DISTINCT skill_access.access_id)::int AS access_count,
|
|
938
901
|
count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
|
|
939
902
|
FROM ai.skills skill
|
|
903
|
+
LEFT JOIN ai.app_skills app_skill ON app_skill.skill_id=skill.id
|
|
940
904
|
LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
|
|
941
905
|
LEFT JOIN ai.skill_access skill_access ON skill_access.skill_id = skill.id
|
|
942
906
|
LEFT JOIN auth.access access ON access.id = skill_access.access_id
|
|
943
907
|
WHERE skill.short_id = ${shortId}
|
|
944
|
-
GROUP BY skill.id
|
|
908
|
+
GROUP BY skill.id, app_skill.key
|
|
945
909
|
`;
|
|
946
910
|
return rows[0] ? toAdminSkill(rows[0]) : null;
|
|
947
911
|
},
|
|
@@ -988,8 +952,9 @@ export const aiSkills = {
|
|
|
988
952
|
|
|
989
953
|
async setEnabled(skillId: string, subject: AccessSubject | null, enabled: boolean): Promise<boolean | null> {
|
|
990
954
|
if (subject?.type !== "user") return null;
|
|
955
|
+
const roles = await subjectRoles(subject);
|
|
991
956
|
return sql.begin(async (tx) => {
|
|
992
|
-
if (!(await requireSkill(await getRow(skillId, tx), subject, "read", tx))) return null;
|
|
957
|
+
if (!(await requireSkill(await getRow(skillId, tx), subject, "read", tx, roles))) return null;
|
|
993
958
|
if (enabled) {
|
|
994
959
|
await tx`
|
|
995
960
|
DELETE FROM ai.skill_user_disabled
|
|
@@ -1024,9 +989,10 @@ export const aiSkills = {
|
|
|
1024
989
|
): Promise<AiSkill | null> {
|
|
1025
990
|
if (input.references.length === 0) throw new AiSkillInputError("Set at least one Skill reference.");
|
|
1026
991
|
const references = validateAiSkillReferences(input.references);
|
|
992
|
+
const roles = await subjectRoles(subject);
|
|
1027
993
|
return sql.begin(async (tx) => {
|
|
1028
994
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
1029
|
-
if (!row || !
|
|
995
|
+
if (!row || !(await canMutate(row.id, subject, "write", tx, roles))) return null;
|
|
1030
996
|
if (Number(row.revision) !== input.expectedRevision) throw new AiSkillRevisionConflictError();
|
|
1031
997
|
const existing = await tx<AiSkillReferenceInput[]>`
|
|
1032
998
|
SELECT path, content FROM ai.skill_references WHERE skill_id = ${skillId}::uuid
|
|
@@ -1045,7 +1011,7 @@ export const aiSkills = {
|
|
|
1045
1011
|
UPDATE ai.skills SET revision = revision + 1, updated_at = now()
|
|
1046
1012
|
WHERE id = ${skillId}::uuid RETURNING *
|
|
1047
1013
|
`;
|
|
1048
|
-
return toSkill(updated!, subject, tx);
|
|
1014
|
+
return toSkill(updated!, subject, tx, roles);
|
|
1049
1015
|
});
|
|
1050
1016
|
},
|
|
1051
1017
|
|
|
@@ -1055,9 +1021,10 @@ export const aiSkills = {
|
|
|
1055
1021
|
input: { expectedRevision: number; path: string },
|
|
1056
1022
|
): Promise<AiSkill | null> {
|
|
1057
1023
|
const [reference] = validateAiSkillReferences([{ path: input.path, content: "" }]);
|
|
1024
|
+
const roles = await subjectRoles(subject);
|
|
1058
1025
|
return sql.begin(async (tx) => {
|
|
1059
1026
|
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
|
|
1060
|
-
if (!row || !
|
|
1027
|
+
if (!row || !(await canMutate(row.id, subject, "write", tx, roles))) return null;
|
|
1061
1028
|
if (Number(row.revision) !== input.expectedRevision) throw new AiSkillRevisionConflictError();
|
|
1062
1029
|
const deleted = await tx`
|
|
1063
1030
|
DELETE FROM ai.skill_references
|
|
@@ -1068,7 +1035,7 @@ export const aiSkills = {
|
|
|
1068
1035
|
UPDATE ai.skills SET revision = revision + 1, updated_at = now()
|
|
1069
1036
|
WHERE id = ${skillId}::uuid RETURNING *
|
|
1070
1037
|
`;
|
|
1071
|
-
return toSkill(updated!, subject, tx);
|
|
1038
|
+
return toSkill(updated!, subject, tx, roles);
|
|
1072
1039
|
});
|
|
1073
1040
|
},
|
|
1074
1041
|
|
|
@@ -1080,11 +1047,12 @@ export const aiSkills = {
|
|
|
1080
1047
|
const name = typeof selector === "string" ? validateAiSkillName(selector) : null;
|
|
1081
1048
|
const shortId = typeof selector === "string" ? null : selector.id;
|
|
1082
1049
|
if (shortId !== null && !AI_SHORT_ID_PATTERN.test(shortId)) throw new AiSkillInputError("Invalid Skill ID.");
|
|
1050
|
+
const roles = await subjectRoles(subject);
|
|
1083
1051
|
return sql.begin(async (tx) => {
|
|
1084
1052
|
const [row] = await tx<SkillRow[]>`
|
|
1085
1053
|
SELECT * FROM ai.skills WHERE name = ${name} OR short_id = ${shortId} FOR SHARE
|
|
1086
1054
|
`;
|
|
1087
|
-
const skill = await requireSkill(row ?? null, subject, "read", tx);
|
|
1055
|
+
const skill = await requireSkill(row ?? null, subject, "read", tx, roles);
|
|
1088
1056
|
if (!skill?.enabled) return null;
|
|
1089
1057
|
const files = skillSnapshotFiles(skill);
|
|
1090
1058
|
await tx`
|
|
@@ -1114,30 +1082,12 @@ export const aiSkills = {
|
|
|
1114
1082
|
},
|
|
1115
1083
|
|
|
1116
1084
|
async listTurnFiles(turnId: string, subject: AccessSubject | null): Promise<AiSkillFileToolStat[]> {
|
|
1117
|
-
|
|
1118
|
-
const rows = await sql<SnapshotRow[]>`
|
|
1119
|
-
SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
|
|
1120
|
-
snapshot.files, snapshot.loaded_at
|
|
1121
|
-
FROM ai.turn_skill_snapshots snapshot
|
|
1122
|
-
JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
|
|
1123
|
-
LEFT JOIN ai.skill_user_disabled disabled
|
|
1124
|
-
ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
|
|
1125
|
-
WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
|
|
1126
|
-
AND disabled.user_id IS NULL
|
|
1127
|
-
ORDER BY snapshot.skill_name
|
|
1128
|
-
`;
|
|
1129
|
-
return rows.flatMap((row) =>
|
|
1130
|
-
snapshotFiles(row.files).map((file) => ({
|
|
1131
|
-
path: file.path,
|
|
1132
|
-
mediaType: "text/markdown",
|
|
1133
|
-
size: new TextEncoder().encode(file.content).byteLength,
|
|
1134
|
-
updatedAt: iso(row.loaded_at),
|
|
1135
|
-
})),
|
|
1136
|
-
);
|
|
1085
|
+
return listSkillTurnFiles(turnId, subject, await subjectRoles(subject));
|
|
1137
1086
|
},
|
|
1138
1087
|
|
|
1139
1088
|
async readTurnFile(turnId: string, path: string, subject: AccessSubject | null): Promise<AiSkillFileToolContent | null> {
|
|
1140
|
-
const
|
|
1089
|
+
const roles = await subjectRoles(subject);
|
|
1090
|
+
const files = await listSkillTurnFiles(turnId, subject, roles);
|
|
1141
1091
|
const stat = files.find((file) => file.path === path);
|
|
1142
1092
|
if (!stat) return null;
|
|
1143
1093
|
const [skillName] = path.split("/", 1);
|
|
@@ -1146,7 +1096,7 @@ export const aiSkills = {
|
|
|
1146
1096
|
SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
|
|
1147
1097
|
snapshot.files, snapshot.loaded_at
|
|
1148
1098
|
FROM ai.turn_skill_snapshots snapshot
|
|
1149
|
-
JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
|
|
1099
|
+
JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject, roles)}) access ON true
|
|
1150
1100
|
LEFT JOIN ai.skill_user_disabled disabled
|
|
1151
1101
|
ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
|
|
1152
1102
|
WHERE snapshot.turn_id = ${turnId}::uuid AND snapshot.skill_name = ${skillName}
|
|
@@ -1157,7 +1107,3 @@ export const aiSkills = {
|
|
|
1157
1107
|
return file ? { ...stat, bytes: new TextEncoder().encode(file.content) } : null;
|
|
1158
1108
|
},
|
|
1159
1109
|
};
|
|
1160
|
-
|
|
1161
|
-
export const seedCloudAiSkills = async (): Promise<void> => {
|
|
1162
|
-
for (const seed of getBuiltinAiSkillTemplates()) await aiSkills.seedOnce(seed);
|
|
1163
|
-
};
|