@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, validateAppSkillCatalog } from "../ai/app-skills";
|
|
1
2
|
import { startLiveOutbox } from "../events/live";
|
|
2
3
|
import { registerHelp } from "../services/help";
|
|
3
4
|
import { preloadLayoutHelp } from "../ssr/help";
|
|
@@ -167,6 +168,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
167
168
|
settings?: S;
|
|
168
169
|
/** Notification kinds owned by this app, conventionally imported from `src/notifications.ts`. */
|
|
169
170
|
notifications?: N;
|
|
171
|
+
/** Assistant skills owned by this app, conventionally imported from `src/skills.ts`. */
|
|
172
|
+
skills?: readonly AppSkillDefinition[];
|
|
170
173
|
/**
|
|
171
174
|
* Legal/info links contributed by this app — aggregated app-wide via
|
|
172
175
|
* `listLegalLinks()` and rendered in login footer, app Footer, rail more
|
|
@@ -375,6 +378,8 @@ export const defineApp = <
|
|
|
375
378
|
>(
|
|
376
379
|
opts: AppOptions<S, N, AppId>,
|
|
377
380
|
): AppDefinition<S, N, AppId> => {
|
|
381
|
+
const appSkills = opts.skills ? validateAppSkillCatalog(opts.skills) : undefined;
|
|
382
|
+
const skillsManifestHash = appSkills ? appSkillManifestHash(appSkills) : undefined;
|
|
378
383
|
configurePostgresApplicationName(opts.id);
|
|
379
384
|
const isDevelopment = env.IS_DEVELOPMENT;
|
|
380
385
|
const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
|
|
@@ -603,6 +608,7 @@ export const defineApp = <
|
|
|
603
608
|
}
|
|
604
609
|
: undefined,
|
|
605
610
|
help: compiledHelp?.summary,
|
|
611
|
+
skills: skillsManifestHash ? { manifestHash: skillsManifestHash } : undefined,
|
|
606
612
|
legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
|
|
607
613
|
searchLinks: meta.searchLinks,
|
|
608
614
|
pwa: meta.pwa
|
|
@@ -618,7 +624,10 @@ export const defineApp = <
|
|
|
618
624
|
// Heartbeat
|
|
619
625
|
const heartbeat = createHeartbeat(meta.id, entry, {
|
|
620
626
|
registry: appRegistry(),
|
|
621
|
-
beforeWrite:
|
|
627
|
+
beforeWrite: async () => {
|
|
628
|
+
if (compiledHelp) await registerHelp(compiledHelp.corpus);
|
|
629
|
+
if (appSkills && skillsManifestHash) await registerAppSkills(meta.id, appSkills, skillsManifestHash);
|
|
630
|
+
},
|
|
622
631
|
onError: (error) =>
|
|
623
632
|
log.error("Registry heartbeat failed", {
|
|
624
633
|
appId: meta.id,
|
|
@@ -166,6 +166,11 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
166
166
|
return invalid("capabilities", "a valid capability summary");
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
if (
|
|
170
|
+
value.skills !== undefined &&
|
|
171
|
+
(!isRecord(value.skills) || !isString(value.skills.manifestHash) || !/^[a-f0-9]{64}$/.test(value.skills.manifestHash))
|
|
172
|
+
)
|
|
173
|
+
return invalid("skills", "a valid app skill manifest hash");
|
|
169
174
|
if (
|
|
170
175
|
value.help !== undefined &&
|
|
171
176
|
(!isRecord(value.help) ||
|
|
@@ -20,8 +20,11 @@ import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
|
20
20
|
export type GrantableLevel = Exclude<PermissionLevel, "none">;
|
|
21
21
|
|
|
22
22
|
/** Either a bare level (uses the default View / Edit / Manage label and
|
|
23
|
-
* icon) or an object with per-context overrides.
|
|
24
|
-
|
|
23
|
+
* icon) or an object with per-context overrides. A `scope` offers the level
|
|
24
|
+
* on a narrower part of the resource that the application defines, such as
|
|
25
|
+
* Mail's assigned conversations; it reaches the callbacks and matches the
|
|
26
|
+
* entry's `scope`. `description` explains an option in the level menu. */
|
|
27
|
+
export type AllowedLevel = GrantableLevel | { level: GrantableLevel; scope?: string; label?: string; icon?: string; description?: string };
|
|
25
28
|
|
|
26
29
|
type PermissionEditorProps = {
|
|
27
30
|
/** Initial access entries — caller stays the source of truth for
|
|
@@ -41,10 +44,11 @@ type PermissionEditorProps = {
|
|
|
41
44
|
principal: Principal,
|
|
42
45
|
permission: GrantableLevel,
|
|
43
46
|
display?: { displayName: string; serviceAccountKind?: ServiceAccountKind },
|
|
47
|
+
scope?: string,
|
|
44
48
|
) => Promise<AccessEntry>;
|
|
45
49
|
|
|
46
|
-
/** Update an existing entry's permission level. */
|
|
47
|
-
updateAccess: (accessId: string, permission: GrantableLevel) => Promise<void>;
|
|
50
|
+
/** Update an existing entry's permission level and, for a scoped level, its scope. */
|
|
51
|
+
updateAccess: (accessId: string, permission: GrantableLevel, scope?: string) => Promise<void>;
|
|
48
52
|
|
|
49
53
|
/** Revoke an existing entry. Any entry can be revoked except the last
|
|
50
54
|
* manager: once exactly one entry manages the resource (`admin` for a
|
|
@@ -95,10 +99,16 @@ const defaultLabels = (t: ReturnType<typeof accessMessages.resolve>["t"]): Recor
|
|
|
95
99
|
|
|
96
100
|
type ResolvedLevel = {
|
|
97
101
|
level: GrantableLevel;
|
|
102
|
+
scope: string | undefined;
|
|
103
|
+
/** Unique per option: the level, prefixed by its scope. */
|
|
104
|
+
key: string;
|
|
98
105
|
label: string;
|
|
99
106
|
icon: string;
|
|
107
|
+
description: string | undefined;
|
|
100
108
|
};
|
|
101
109
|
|
|
110
|
+
const levelKey = (level: PermissionLevel, scope: string | undefined): string => (scope ? `${scope}:${level}` : level);
|
|
111
|
+
|
|
102
112
|
/** Resolve the AllowedLevel union into a flat shape the renderer can
|
|
103
113
|
* loop over. Falls back to the default View / Edit / Manage list when
|
|
104
114
|
* no override is given. */
|
|
@@ -111,8 +121,11 @@ const resolveAllowedLevels = (allowed: AllowedLevel[] | undefined, t: ReturnType
|
|
|
111
121
|
const def = defaults[level];
|
|
112
122
|
return {
|
|
113
123
|
level,
|
|
124
|
+
scope: override?.scope,
|
|
125
|
+
key: levelKey(level, override?.scope),
|
|
114
126
|
label: override?.label ?? def.label,
|
|
115
127
|
icon: override?.icon ?? def.icon,
|
|
128
|
+
description: override?.description,
|
|
116
129
|
};
|
|
117
130
|
});
|
|
118
131
|
};
|
|
@@ -122,10 +135,11 @@ const resolveAllowedLevels = (allowed: AllowedLevel[] | undefined, t: ReturnType
|
|
|
122
135
|
* the platform defaults. Tolerates "none" / unknown legacy values. */
|
|
123
136
|
const resolveEntryDisplay = (
|
|
124
137
|
permission: PermissionLevel,
|
|
138
|
+
scope: string | undefined,
|
|
125
139
|
allowed: ResolvedLevel[],
|
|
126
140
|
t: ReturnType<typeof accessMessages.resolve>["t"],
|
|
127
141
|
): { label: string; icon: string } => {
|
|
128
|
-
const fromAllowed = allowed.find((a) => a.
|
|
142
|
+
const fromAllowed = allowed.find((a) => a.key === levelKey(permission, scope));
|
|
129
143
|
if (fromAllowed) return fromAllowed;
|
|
130
144
|
const defaults = defaultLabels(t);
|
|
131
145
|
return defaults[permission] ?? defaults.none;
|
|
@@ -186,22 +200,24 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
186
200
|
mutation: async (data: {
|
|
187
201
|
principal: Principal;
|
|
188
202
|
permission: GrantableLevel;
|
|
203
|
+
scope: string | undefined;
|
|
189
204
|
display: { displayName: string; serviceAccountKind?: ServiceAccountKind };
|
|
190
|
-
}) => props.grantAccess(data.principal, data.permission, data.display),
|
|
205
|
+
}) => props.grantAccess(data.principal, data.permission, data.display, data.scope),
|
|
191
206
|
onSuccess: (newEntry) => {
|
|
192
207
|
setEntries(entries.length, { ...newEntry });
|
|
193
208
|
},
|
|
194
209
|
onError: (err) => prompts.error(err.message),
|
|
195
210
|
});
|
|
196
211
|
|
|
197
|
-
|
|
212
|
+
type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
|
|
213
|
+
const updateMut = mutation.create<LevelChange, LevelChange>({
|
|
198
214
|
mutation: async (data) => {
|
|
199
|
-
await props.updateAccess(data.accessId, data.permission);
|
|
215
|
+
await props.updateAccess(data.accessId, data.permission, data.scope);
|
|
200
216
|
return data;
|
|
201
217
|
},
|
|
202
218
|
onSuccess: (result) => {
|
|
203
219
|
if (result) {
|
|
204
|
-
setEntries((entry) => entry.id === result.accessId,
|
|
220
|
+
setEntries((entry) => entry.id === result.accessId, { permission: result.permission, scope: result.scope });
|
|
205
221
|
}
|
|
206
222
|
},
|
|
207
223
|
onError: (err) => prompts.error(err.message),
|
|
@@ -226,7 +242,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
226
242
|
const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
|
|
227
243
|
// The service refuses to remove the last manager; the row says so up front.
|
|
228
244
|
const lastManagerId = createMemo(() => {
|
|
229
|
-
|
|
245
|
+
// A grant on part of the resource never manages it.
|
|
246
|
+
const managers = (props.effectiveEntries?.(entries) ?? entries).filter((entry) => !entry.scope && isManagerEntry(entry));
|
|
230
247
|
return managers.length === 1 ? managers[0]!.id : null;
|
|
231
248
|
});
|
|
232
249
|
|
|
@@ -247,8 +264,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
247
264
|
lastManager={entry.id === lastManagerId()}
|
|
248
265
|
allowed={allowed(entry.principal)}
|
|
249
266
|
singlePicker={allowed(entry.principal).length === 1}
|
|
250
|
-
onUpdatePermission={(
|
|
251
|
-
if (!busy()) void updateMut.mutate({ accessId: entry.id, permission });
|
|
267
|
+
onUpdatePermission={(option) => {
|
|
268
|
+
if (!busy()) void updateMut.mutate({ accessId: entry.id, permission: option.level, scope: option.scope });
|
|
252
269
|
}}
|
|
253
270
|
onRevoke={() => {
|
|
254
271
|
if (!busy()) void revokeMut.mutate(entry);
|
|
@@ -273,8 +290,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
273
290
|
allowServiceAccounts={props.allowServiceAccounts}
|
|
274
291
|
disabled={busy()}
|
|
275
292
|
onSelect={(principal, display) => {
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
293
|
+
const first = allowed(principal)[0];
|
|
294
|
+
if (first && !busy()) grantMut.mutate({ principal, permission: first.level, scope: first.scope, display });
|
|
278
295
|
}}
|
|
279
296
|
/>
|
|
280
297
|
</Show>
|
|
@@ -298,17 +315,18 @@ function AccessEntryRow(props: {
|
|
|
298
315
|
singlePicker: boolean;
|
|
299
316
|
/** Present for group grants: who currently receives access through the group. */
|
|
300
317
|
coverage?: GroupCoverage;
|
|
301
|
-
onUpdatePermission: (
|
|
318
|
+
onUpdatePermission: (option: ResolvedLevel) => void;
|
|
302
319
|
onRevoke: () => void;
|
|
303
320
|
}) {
|
|
304
321
|
const locale = useLocale();
|
|
305
322
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
306
323
|
const displayName = () => getEntryDisplayName(props.entry, t(), locale());
|
|
307
|
-
const
|
|
308
|
-
const
|
|
324
|
+
const entryKey = () => levelKey(props.entry.permission, props.entry.scope);
|
|
325
|
+
const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
|
|
326
|
+
const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
|
|
309
327
|
const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
|
|
310
328
|
const isInteractive = () =>
|
|
311
|
-
props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.
|
|
329
|
+
props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
|
|
312
330
|
|
|
313
331
|
const badgeClass =
|
|
314
332
|
"flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
|
|
@@ -371,21 +389,22 @@ function AccessEntryRow(props: {
|
|
|
371
389
|
<Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
|
|
372
390
|
<SelectChip
|
|
373
391
|
aria-label={t().permissionFor({ name: displayName() })}
|
|
374
|
-
value={
|
|
392
|
+
value={entryKey}
|
|
375
393
|
options={props.allowed.map((option) => {
|
|
376
|
-
const locked = props.lastManager && option.level !== "admin";
|
|
394
|
+
const locked = props.lastManager && (option.level !== "admin" || option.scope !== undefined);
|
|
377
395
|
return {
|
|
378
|
-
value: option.
|
|
396
|
+
value: option.key,
|
|
379
397
|
label: option.label,
|
|
380
398
|
icon: `ti ${option.icon}`,
|
|
381
399
|
disabled: locked,
|
|
382
|
-
description: locked ? t().lastManagerOption({ level: manageLabel() }) :
|
|
400
|
+
description: locked ? t().lastManagerOption({ level: manageLabel() }) : option.description,
|
|
383
401
|
};
|
|
384
402
|
})}
|
|
385
403
|
icon={`ti ${display().icon}`}
|
|
386
404
|
position="bottom-left"
|
|
387
|
-
onValueChange={(
|
|
388
|
-
|
|
405
|
+
onValueChange={(key) => {
|
|
406
|
+
const option = props.allowed.find((candidate) => candidate.key === key);
|
|
407
|
+
if (option && key !== entryKey()) props.onUpdatePermission(option);
|
|
389
408
|
}}
|
|
390
409
|
/>
|
|
391
410
|
</Show>
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { type SQL, sql, type TransactionSQL } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { appAudienceRoles } from "../_internal/app-roles";
|
|
4
|
+
import { APP_REGISTRY_TTL_MS } from "../_internal/registry";
|
|
5
|
+
import type { AppRegistryEntry } from "../contracts/registry";
|
|
6
|
+
import { logger } from "../services/logging";
|
|
7
|
+
import { toPgTextArray } from "../services/postgres";
|
|
8
|
+
import { type AppSkillDefinition, parseStoredAppSkill } from "./app-skills";
|
|
9
|
+
import { withAiShortIdForDb } from "./short-id";
|
|
10
|
+
import { AI_SKILL_CATALOG_MAX_CHARS, aiSkillCatalogChars } from "./skill-catalog";
|
|
11
|
+
import { appSkillStatus, contentHash } from "./skill-content";
|
|
12
|
+
import { serializeAiSkillMarkdown } from "./skill-format";
|
|
13
|
+
import {
|
|
14
|
+
AiSkillInputError,
|
|
15
|
+
AiSkillRevisionConflictError,
|
|
16
|
+
createSkillAccess,
|
|
17
|
+
getRow,
|
|
18
|
+
listReferences,
|
|
19
|
+
replaceSkillContent,
|
|
20
|
+
rowContentHash,
|
|
21
|
+
type SkillRow,
|
|
22
|
+
} from "./skills";
|
|
23
|
+
|
|
24
|
+
const log = logger("ai:app-skills");
|
|
25
|
+
type AppSkillRow = {
|
|
26
|
+
key: string;
|
|
27
|
+
app_id: string;
|
|
28
|
+
app_name: string;
|
|
29
|
+
name: string;
|
|
30
|
+
skill_id: string | null;
|
|
31
|
+
source: unknown;
|
|
32
|
+
source_hash: string | null;
|
|
33
|
+
applied_hash: string | null;
|
|
34
|
+
manifest_hash: string | null;
|
|
35
|
+
available: boolean;
|
|
36
|
+
required_roles: string[] | null;
|
|
37
|
+
conflict: "invalid" | "name_taken" | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const lock = (db: SQL) => db`SELECT pg_advisory_xact_lock(hashtext('cloud:ai:app-skills'))`;
|
|
41
|
+
const nameTaken = (error: unknown): boolean =>
|
|
42
|
+
error !== null && typeof error === "object" && "constraint" in error && error.constraint === "idx_ai_skills_name";
|
|
43
|
+
|
|
44
|
+
const install = async (source: AppSkillDefinition, db: SQL): Promise<SkillRow> => {
|
|
45
|
+
const [row] = await withAiShortIdForDb(
|
|
46
|
+
db,
|
|
47
|
+
"idx_ai_skills_short_id",
|
|
48
|
+
(attempt, shortId) => attempt<SkillRow[]>`
|
|
49
|
+
INSERT INTO ai.skills(short_id, name, description, instructions, extra_frontmatter)
|
|
50
|
+
VALUES (${shortId}, ${source.name}, ${source.description}, ${source.instructions},
|
|
51
|
+
(${JSON.stringify(source.extraFrontmatter)}::text)::jsonb) RETURNING *`,
|
|
52
|
+
);
|
|
53
|
+
if (!row) throw new Error("Failed to install app skill.");
|
|
54
|
+
for (const ref of source.references)
|
|
55
|
+
await db`INSERT INTO ai.skill_references(skill_id, path, content) VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
|
|
56
|
+
await createSkillAccess(row.id, { principal: { type: "authenticated" }, permission: "read" }, db);
|
|
57
|
+
return row;
|
|
58
|
+
};
|
|
59
|
+
const sourceJson = (source: AppSkillDefinition): string =>
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
name: source.name,
|
|
62
|
+
description: source.description,
|
|
63
|
+
instructions: source.instructions,
|
|
64
|
+
extraFrontmatter: source.extraFrontmatter,
|
|
65
|
+
references: source.references,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Brings one live app's Skills in line with its published catalog. Returns false while the advertised catalog is not
|
|
70
|
+
* stored yet, so the caller retries on a later registry change.
|
|
71
|
+
*/
|
|
72
|
+
const reconcileApp = async (app: AppRegistryEntry, tx: TransactionSQL): Promise<boolean> => {
|
|
73
|
+
if (!app.skills) {
|
|
74
|
+
await tx`UPDATE ai.app_skills SET available=false, updated_at=now() WHERE app_id=${app.id} AND available`;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const manifest = app.skills.manifestHash;
|
|
78
|
+
const [catalog] = await tx<
|
|
79
|
+
{ skills: unknown }[]
|
|
80
|
+
>`SELECT skills FROM ai.app_skill_catalogs WHERE app_id=${app.id} AND manifest_hash=${manifest}`;
|
|
81
|
+
if (!catalog) return false; // Publication may still be in flight: keep the last known state.
|
|
82
|
+
const roles = appAudienceRoles(app.nav);
|
|
83
|
+
const roleArray = roles === undefined ? null : toPgTextArray([...roles]);
|
|
84
|
+
const [state] = await tx<{ current: boolean }[]>`SELECT
|
|
85
|
+
EXISTS(SELECT 1 FROM ai.app_skills WHERE app_id=${app.id}) AND
|
|
86
|
+
NOT EXISTS(SELECT 1 FROM ai.app_skills WHERE app_id=${app.id} AND (
|
|
87
|
+
manifest_hash IS DISTINCT FROM ${manifest} OR NOT available OR app_name IS DISTINCT FROM ${app.name}
|
|
88
|
+
OR required_roles IS DISTINCT FROM ${roleArray}::text[]
|
|
89
|
+
)) AS current`;
|
|
90
|
+
if (state?.current) return true;
|
|
91
|
+
const entries = z
|
|
92
|
+
.array(z.unknown())
|
|
93
|
+
.max(Math.floor((AI_SKILL_CATALOG_MAX_CHARS + 1) / "- a: b\n".length))
|
|
94
|
+
.parse(catalog.skills);
|
|
95
|
+
const names = new Set<string>();
|
|
96
|
+
let catalogChars = 0;
|
|
97
|
+
for (const [index, input] of entries.entries()) {
|
|
98
|
+
const name = input && typeof input === "object" && "name" in input && typeof input.name === "string" ? input.name : `invalid-${index}`;
|
|
99
|
+
const key = `app:${app.id}/${name}`;
|
|
100
|
+
let source: AppSkillDefinition;
|
|
101
|
+
try {
|
|
102
|
+
source = parseStoredAppSkill(input);
|
|
103
|
+
if (names.has(name)) throw new Error(`Skill "${name}" is duplicated.`);
|
|
104
|
+
catalogChars += aiSkillCatalogChars([source]) + (names.size ? 1 : 0);
|
|
105
|
+
if (catalogChars > AI_SKILL_CATALOG_MAX_CHARS) throw new Error("App skill catalog exceeds the Assistant catalog budget.");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
names.add(name);
|
|
108
|
+
await tx`INSERT INTO ai.app_skills(key, app_id, app_name, name, manifest_hash, available, required_roles, conflict)
|
|
109
|
+
VALUES (${key}, ${app.id}, ${app.name}, ${name}, ${manifest}, true, ${roleArray}::text[], 'invalid')
|
|
110
|
+
ON CONFLICT(key) DO UPDATE SET app_name=EXCLUDED.app_name, manifest_hash=EXCLUDED.manifest_hash,
|
|
111
|
+
available=true, required_roles=EXCLUDED.required_roles, conflict='invalid', updated_at=now()`;
|
|
112
|
+
log.warn("Invalid app skill skipped", { appId: app.id, name, error: error instanceof Error ? error.message : String(error) });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
names.add(name);
|
|
116
|
+
const [managed] = await tx<AppSkillRow[]>`INSERT INTO ai.app_skills
|
|
117
|
+
(key, app_id, app_name, name, source, source_hash, manifest_hash, available, required_roles)
|
|
118
|
+
VALUES (${key}, ${app.id}, ${app.name}, ${name}, (${sourceJson(source)}::text)::jsonb,
|
|
119
|
+
${source.hash}, ${manifest}, true, ${roleArray}::text[])
|
|
120
|
+
ON CONFLICT(key) DO UPDATE SET app_name=EXCLUDED.app_name, source=EXCLUDED.source, source_hash=EXCLUDED.source_hash,
|
|
121
|
+
manifest_hash=EXCLUDED.manifest_hash, available=true, required_roles=EXCLUDED.required_roles,
|
|
122
|
+
conflict=CASE WHEN ai.app_skills.skill_id IS NULL THEN ai.app_skills.conflict ELSE NULL END, updated_at=now()
|
|
123
|
+
RETURNING *`;
|
|
124
|
+
if (!managed) throw new Error("Failed to read app skill association.");
|
|
125
|
+
if (managed.skill_id) {
|
|
126
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${managed.skill_id}::uuid FOR UPDATE`;
|
|
127
|
+
if (!row) continue; // A durable admin deletion, even when the app's manifest changes.
|
|
128
|
+
const hash = await rowContentHash(row, tx);
|
|
129
|
+
if (hash === source.hash) {
|
|
130
|
+
if (managed.applied_hash !== source.hash) await tx`UPDATE ai.app_skills SET applied_hash=${source.hash} WHERE key=${key}`;
|
|
131
|
+
} else if (hash === managed.applied_hash) {
|
|
132
|
+
try {
|
|
133
|
+
await tx.savepoint(async (attempt) => {
|
|
134
|
+
await replaceSkillContent(row, source, attempt);
|
|
135
|
+
await attempt`UPDATE ai.app_skills SET applied_hash=${source.hash} WHERE key=${key}`;
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!nameTaken(error)) throw error;
|
|
139
|
+
await tx`UPDATE ai.app_skills SET conflict='name_taken' WHERE key=${key}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await tx.savepoint(async (attempt) => {
|
|
146
|
+
const row = await install(source, attempt);
|
|
147
|
+
await attempt`UPDATE ai.app_skills SET skill_id=${row.id}::uuid, applied_hash=${source.hash}, conflict=NULL WHERE key=${key}`;
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (!nameTaken(error)) throw error;
|
|
151
|
+
await tx`UPDATE ai.app_skills SET conflict='name_taken' WHERE key=${key}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await tx`UPDATE ai.app_skills SET available=false, updated_at=now()
|
|
155
|
+
WHERE app_id=${app.id} AND available AND NOT(name=ANY(${toPgTextArray([...names])}::text[]))`;
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Everything app Skills derive from the registry. While it is unchanged, reconciling again changes nothing. */
|
|
160
|
+
export const appSkillsRegistryState = (apps: readonly AppRegistryEntry[]): string =>
|
|
161
|
+
JSON.stringify(apps.map((app) => [app.id, app.name, app.skills?.manifestHash ?? null, appAudienceRoles(app.nav) ?? null]).sort());
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Core's sync from the live registry into `ai.skills`. One app's broken catalog never blocks another app. Returns
|
|
165
|
+
* whether every advertised catalog was applied.
|
|
166
|
+
*/
|
|
167
|
+
export const reconcileAppSkills = async (apps: readonly AppRegistryEntry[], db: SQL = sql): Promise<boolean> => {
|
|
168
|
+
const liveIds = toPgTextArray(apps.map((app) => app.id));
|
|
169
|
+
await db.begin(async (tx) => {
|
|
170
|
+
await lock(tx);
|
|
171
|
+
await tx`UPDATE ai.app_skills SET available=false, updated_at=now()
|
|
172
|
+
WHERE available AND NOT (app_id=ANY(${liveIds}::text[]))`;
|
|
173
|
+
await tx`DELETE FROM ai.app_skill_catalogs WHERE last_seen_at < now() - ${APP_REGISTRY_TTL_MS * 2} * interval '1 millisecond'`;
|
|
174
|
+
});
|
|
175
|
+
let complete = true;
|
|
176
|
+
for (const app of apps) {
|
|
177
|
+
try {
|
|
178
|
+
const applied = await db.begin(async (tx) => {
|
|
179
|
+
await tx`SET LOCAL statement_timeout = '10s'`.simple();
|
|
180
|
+
await lock(tx);
|
|
181
|
+
return reconcileApp(app, tx);
|
|
182
|
+
});
|
|
183
|
+
if (!applied) complete = false;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
complete = false;
|
|
186
|
+
log.error("App skill reconciliation failed", { appId: app.id, error: error instanceof Error ? error.message : String(error) });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return complete;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** An app Skill Cloud does not offer because an administrator deleted it or it could not be installed. */
|
|
193
|
+
export type AiAppSkillIssue = {
|
|
194
|
+
appId: string;
|
|
195
|
+
appName: string;
|
|
196
|
+
name: string;
|
|
197
|
+
state: "deleted" | "name_taken" | "invalid";
|
|
198
|
+
available: boolean;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const appSkillIssues = async (): Promise<AiAppSkillIssue[]> => sql`SELECT app.app_id AS "appId", app.app_name AS "appName", app.name,
|
|
202
|
+
COALESCE(app.conflict, 'deleted') AS state, app.available
|
|
203
|
+
FROM ai.app_skills app LEFT JOIN ai.skills skill ON skill.id=app.skill_id
|
|
204
|
+
WHERE (app.skill_id IS NOT NULL AND skill.id IS NULL) OR app.conflict IS NOT NULL ORDER BY app.app_id, app.name LIMIT 500`;
|
|
205
|
+
|
|
206
|
+
export const appSkillVersion = async (skillId: string) =>
|
|
207
|
+
sql.begin(async (tx) => {
|
|
208
|
+
// Source association before Skill, matching reconciliation/reset lock order.
|
|
209
|
+
const [app] = await tx<AppSkillRow[]>`SELECT * FROM ai.app_skills WHERE skill_id=${skillId}::uuid FOR SHARE`;
|
|
210
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR SHARE`;
|
|
211
|
+
if (!row) return null;
|
|
212
|
+
const fields = {
|
|
213
|
+
name: row.name,
|
|
214
|
+
description: row.description,
|
|
215
|
+
instructions: row.instructions,
|
|
216
|
+
extraFrontmatter: z.record(z.string(), z.unknown()).parse(row.extra_frontmatter),
|
|
217
|
+
references: await listReferences(skillId, tx),
|
|
218
|
+
};
|
|
219
|
+
const source = app?.source ? parseStoredAppSkill(app.source) : null;
|
|
220
|
+
return {
|
|
221
|
+
current: { markdown: serializeAiSkillMarkdown(fields), references: fields.references },
|
|
222
|
+
app: source ? { markdown: serializeAiSkillMarkdown(source), references: source.references } : null,
|
|
223
|
+
/** Pass back to reset, so a release published after the review is not applied unseen. */
|
|
224
|
+
appVersion: source ? source.hash : null,
|
|
225
|
+
status: app ? appSkillStatus(contentHash(fields), app.source_hash, app.applied_hash) : null,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
export const resetAppSkill = async (skillId: string, expectedRevision: number, expectedAppVersion: string): Promise<boolean> =>
|
|
230
|
+
sql.begin(async (tx) => {
|
|
231
|
+
await lock(tx);
|
|
232
|
+
const [app] = await tx<AppSkillRow[]>`SELECT * FROM ai.app_skills WHERE skill_id=${skillId}::uuid FOR UPDATE`;
|
|
233
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
|
|
234
|
+
if (!row) return false;
|
|
235
|
+
if (Number(row.revision) !== expectedRevision) throw new AiSkillRevisionConflictError();
|
|
236
|
+
if (!app?.source || !app.source_hash) throw new AiSkillInputError("No app source is available for this Skill.");
|
|
237
|
+
if (app.source_hash !== expectedAppVersion)
|
|
238
|
+
throw new AiSkillRevisionConflictError(
|
|
239
|
+
"The app shipped a different version since you compared it. Compare it again before resetting.",
|
|
240
|
+
);
|
|
241
|
+
const source = parseStoredAppSkill(app.source);
|
|
242
|
+
try {
|
|
243
|
+
await tx.savepoint((attempt) => replaceSkillContent(row, source, attempt));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (nameTaken(error)) throw new AiSkillRevisionConflictError("A Skill with this name already exists.");
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
await tx`UPDATE ai.app_skills SET applied_hash=${source.hash}, conflict=NULL, updated_at=now() WHERE key=${app.key}`;
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
export const restoreAppSkill = async (appId: string, name: string): Promise<boolean> =>
|
|
253
|
+
sql.begin(async (tx) => {
|
|
254
|
+
await lock(tx);
|
|
255
|
+
const [app] = await tx<AppSkillRow[]>`SELECT * FROM ai.app_skills WHERE app_id=${appId} AND name=${name} FOR UPDATE`;
|
|
256
|
+
if (!app) return false;
|
|
257
|
+
if (!app.source) throw new AiSkillInputError("No app source is available for this Skill.");
|
|
258
|
+
if (app.skill_id && (await getRow(app.skill_id, tx))) throw new AiSkillRevisionConflictError("This app Skill is already installed.");
|
|
259
|
+
const source = parseStoredAppSkill(app.source);
|
|
260
|
+
let row: SkillRow;
|
|
261
|
+
try {
|
|
262
|
+
row = await tx.savepoint((attempt) => install(source, attempt));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (nameTaken(error)) throw new AiSkillRevisionConflictError("A Skill with this name already exists.");
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
await tx`UPDATE ai.app_skills SET skill_id=${row.id}::uuid, applied_hash=${source.hash}, conflict=NULL, updated_at=now() WHERE key=${app.key}`;
|
|
268
|
+
return true;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Links the Skill that already holds an app Skill's name to that app Skill. Its content stays as an administrator
|
|
273
|
+
* customization until an administrator resets it; grants, short ID and personal settings stay unchanged.
|
|
274
|
+
*/
|
|
275
|
+
export const adoptAppSkill = async (appId: string, name: string): Promise<boolean> =>
|
|
276
|
+
sql.begin(async (tx) => {
|
|
277
|
+
await lock(tx);
|
|
278
|
+
const [app] = await tx<AppSkillRow[]>`SELECT * FROM ai.app_skills WHERE app_id=${appId} AND name=${name} FOR UPDATE`;
|
|
279
|
+
if (!app) return false;
|
|
280
|
+
if (!app.source_hash) throw new AiSkillInputError("No app source is available for this Skill.");
|
|
281
|
+
if (app.skill_id && (await getRow(app.skill_id, tx))) throw new AiSkillRevisionConflictError("This app Skill is already installed.");
|
|
282
|
+
const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE name=${name} FOR UPDATE`;
|
|
283
|
+
if (!row) throw new AiSkillInputError("No Skill uses this name. Install the app Skill instead.");
|
|
284
|
+
const [linked] = await tx`SELECT 1 FROM ai.app_skills WHERE skill_id=${row.id}::uuid`;
|
|
285
|
+
if (linked) throw new AiSkillRevisionConflictError("This Skill already belongs to another app Skill.");
|
|
286
|
+
await tx`UPDATE ai.app_skills SET skill_id=${row.id}::uuid, applied_hash=${app.source_hash}, conflict=NULL, updated_at=now()
|
|
287
|
+
WHERE key=${app.key}`;
|
|
288
|
+
return true;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/** Platform-admin operations on app Skills. Callers enforce platform-admin authorization. */
|
|
292
|
+
export const aiAppSkills = {
|
|
293
|
+
appSkillIssues,
|
|
294
|
+
appVersion: appSkillVersion,
|
|
295
|
+
reset: resetAppSkill,
|
|
296
|
+
restore: restoreAppSkill,
|
|
297
|
+
adopt: adoptAppSkill,
|
|
298
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { logger } from "../services/logging";
|
|
4
|
+
import { AI_SKILL_CATALOG_MAX_CHARS, aiSkillCatalogChars } from "./skill-catalog";
|
|
5
|
+
import { type AiSkillContent, canonicalHash, contentHash, validateSkillContent } from "./skill-content";
|
|
6
|
+
import { type AiSkillExtraFrontmatter, type AiSkillReferenceInput, parseAiSkillMarkdown } from "./skill-format";
|
|
7
|
+
|
|
8
|
+
export type AppSkillDefinition = Readonly<{
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
instructions: string;
|
|
12
|
+
extraFrontmatter: AiSkillExtraFrontmatter;
|
|
13
|
+
references: readonly AiSkillReferenceInput[];
|
|
14
|
+
hash: string;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
const freezeJson = (value: unknown): void => {
|
|
18
|
+
if (value !== null && typeof value === "object") {
|
|
19
|
+
for (const child of Object.values(value)) freezeJson(child);
|
|
20
|
+
Object.freeze(value);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const validateAppSkillContent = (input: AiSkillContent): AppSkillDefinition => {
|
|
25
|
+
try {
|
|
26
|
+
const fields = validateSkillContent(input);
|
|
27
|
+
fields.instructions = fields.instructions.replace(
|
|
28
|
+
/\]\((references\/[a-z0-9][a-z0-9._-]*\.md)\)/g,
|
|
29
|
+
(_, path: string) => `](/skills/${fields.name}/${path})`,
|
|
30
|
+
);
|
|
31
|
+
// Link expansion must respect the same instruction size limits.
|
|
32
|
+
const validated = validateSkillContent(fields);
|
|
33
|
+
const paths = new Set(validated.references.map((ref) => ref.path));
|
|
34
|
+
for (const match of validated.instructions.matchAll(/references\/[^\s\])`"'<>]+\.md/g)) {
|
|
35
|
+
const path = match[0];
|
|
36
|
+
const owner = validated.instructions.slice(0, match.index).match(/\/skills\/([a-z0-9-]+)\/$/)?.[1];
|
|
37
|
+
// A fully qualified link to another loaded Skill names that Skill's files, not this catalog entry's references.
|
|
38
|
+
if (owner && owner !== validated.name) continue;
|
|
39
|
+
if (!paths.has(path)) throw new Error(`Reference "${path}" is not declared.`);
|
|
40
|
+
}
|
|
41
|
+
const definition = { ...validated, hash: contentHash(validated) };
|
|
42
|
+
freezeJson(definition);
|
|
43
|
+
return definition;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`Skill "${input.name}": ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const skill = (input: { markdown: string; references?: Readonly<Record<string, string>> }): AppSkillDefinition => {
|
|
50
|
+
const document = parseAiSkillMarkdown(input.markdown);
|
|
51
|
+
return validateAppSkillContent({
|
|
52
|
+
...document,
|
|
53
|
+
references: Object.entries(input.references ?? {}).map(([path, content]) => ({ path, content })),
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const appSkillManifestHash = (skills: readonly AppSkillDefinition[]): string =>
|
|
58
|
+
canonicalHash(skills.map(({ name, hash }) => ({ name, hash })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
|
|
59
|
+
|
|
60
|
+
export const validateAppSkillCatalog = (skills: readonly AppSkillDefinition[]): readonly AppSkillDefinition[] => {
|
|
61
|
+
const validated = skills.map(validateAppSkillContent);
|
|
62
|
+
if (new Set(validated.map((entry) => entry.name)).size !== validated.length) throw new Error("App skill names must be unique.");
|
|
63
|
+
if (aiSkillCatalogChars(validated) > AI_SKILL_CATALOG_MAX_CHARS)
|
|
64
|
+
throw new Error(
|
|
65
|
+
`App skills exceed the ${AI_SKILL_CATALOG_MAX_CHARS}-character always-visible Assistant catalog budget; this bounds the number of skills per app.`,
|
|
66
|
+
);
|
|
67
|
+
return Object.freeze(validated);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Catalog JSON is untrusted even though application authors use skill() to produce it.
|
|
71
|
+
const StoredSkillSchema = z.object({
|
|
72
|
+
name: z.string(),
|
|
73
|
+
description: z.string(),
|
|
74
|
+
instructions: z.string(),
|
|
75
|
+
extraFrontmatter: z.record(z.string(), z.unknown()),
|
|
76
|
+
references: z.array(z.object({ path: z.string(), content: z.string() })),
|
|
77
|
+
});
|
|
78
|
+
export const parseStoredAppSkill = (value: unknown): AppSkillDefinition => validateAppSkillContent(StoredSkillSchema.parse(value));
|
|
79
|
+
|
|
80
|
+
export const registerAppSkills = async (
|
|
81
|
+
appId: string,
|
|
82
|
+
skills: readonly AppSkillDefinition[],
|
|
83
|
+
manifestHash: string,
|
|
84
|
+
db: SQL = sql,
|
|
85
|
+
): Promise<void> => {
|
|
86
|
+
try {
|
|
87
|
+
await db.begin(async (tx) => {
|
|
88
|
+
await tx`SET LOCAL statement_timeout = '10s'`.simple();
|
|
89
|
+
const renewed = await tx`UPDATE ai.app_skill_catalogs SET last_seen_at = now()
|
|
90
|
+
WHERE app_id = ${appId} AND manifest_hash = ${manifestHash} RETURNING app_id`;
|
|
91
|
+
if (renewed.length) return;
|
|
92
|
+
await tx`INSERT INTO ai.app_skill_catalogs(app_id, manifest_hash, skills)
|
|
93
|
+
VALUES (${appId}, ${manifestHash}, (${JSON.stringify(skills)}::text)::jsonb)
|
|
94
|
+
ON CONFLICT (app_id, manifest_hash) DO UPDATE SET last_seen_at = now()`;
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Bun's PostgresError carries the SQLSTATE in `errno`; `code` names the driver error class.
|
|
98
|
+
const state = error && typeof error === "object" ? ("errno" in error ? error.errno : "code" in error ? error.code : null) : null;
|
|
99
|
+
if (["42P01", "3F000", "42703"].includes(String(state))) {
|
|
100
|
+
logger("ai:app-skills").warn("App skill schema is not available yet; next heartbeat will retry", { appId });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
};
|
package/src/ai/browser.ts
CHANGED
|
@@ -13,13 +13,23 @@ export { type AiTodoPlan, parseAiTodoPlan } from "./todo-contracts";
|
|
|
13
13
|
import { AI_TURN_ATTACHMENT_MAX_ITEMS } from "./limits";
|
|
14
14
|
import type { AiConversation, AiDraftContentPart } from "./types";
|
|
15
15
|
|
|
16
|
+
export {
|
|
17
|
+
type CloudAiChartColumn,
|
|
18
|
+
type CloudAiChartRow,
|
|
19
|
+
type CloudAiChartTable,
|
|
20
|
+
cloudAiChartTable,
|
|
21
|
+
} from "./chart-block";
|
|
16
22
|
export { parseAiSse } from "./client/transport";
|
|
17
23
|
export {
|
|
24
|
+
CLOUD_AI_CHART_MAX_SERIES,
|
|
25
|
+
CLOUD_AI_CHART_MAX_VALUES,
|
|
18
26
|
CLOUD_AI_TEXT_EDITOR_MAX_CHARS,
|
|
19
27
|
CloudAiCardInputSchema,
|
|
28
|
+
CloudAiChartInputSchema,
|
|
20
29
|
CloudAiLocalBashInputSchema,
|
|
21
30
|
CloudAiSurveyInputSchema,
|
|
22
31
|
CloudAiTextEditorInputSchema,
|
|
32
|
+
parseCloudAiChartInput,
|
|
23
33
|
} from "./default-tool-contracts";
|
|
24
34
|
export { guessAiMediaType } from "./file-media-type";
|
|
25
35
|
export { AI_IMAGE_INPUT_MAX_BYTES, AI_TURN_ATTACHMENT_MAX_ITEMS, AI_TURN_IMAGE_MAX_TOTAL_BYTES } from "./limits";
|