@k2b/cloud 0.29.1 → 0.31.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.
Files changed (136) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/app-roles.ts +14 -0
  4. package/src/_internal/capabilities.ts +113 -5
  5. package/src/_internal/capability-sentences.ts +413 -0
  6. package/src/_internal/define-app.ts +24 -5
  7. package/src/_internal/registry-validation.ts +5 -0
  8. package/src/_internal/registry.ts +50 -6
  9. package/src/_internal/widget-declarations.ts +53 -0
  10. package/src/access/PermissionEditor.tsx +43 -24
  11. package/src/ai/app-skill-store.ts +298 -0
  12. package/src/ai/app-skills.ts +105 -0
  13. package/src/ai/approval-routes.ts +40 -4
  14. package/src/ai/approvals.ts +68 -10
  15. package/src/ai/browser.ts +10 -0
  16. package/src/ai/capabilities.ts +68 -9
  17. package/src/ai/chart-block.ts +380 -0
  18. package/src/ai/chat/action-sentences.ts +49 -0
  19. package/src/ai/chat/blocks.tsx +97 -40
  20. package/src/ai/chat/chart-block.tsx +79 -0
  21. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  22. package/src/ai/chat/message-actions.tsx +3 -1
  23. package/src/ai/chat/message-utils.ts +3 -0
  24. package/src/ai/chat/messages.ts +26 -2
  25. package/src/ai/chat/tool-groups.ts +66 -12
  26. package/src/ai/chat/tool-names.ts +6 -2
  27. package/src/ai/chat/turn-layout.ts +10 -2
  28. package/src/ai/chat/turn-view.tsx +123 -36
  29. package/src/ai/chat/web-tools.tsx +95 -1
  30. package/src/ai/client/controller.ts +6 -3
  31. package/src/ai/code-runtime-tools.ts +109 -61
  32. package/src/ai/code-source-contracts.ts +25 -21
  33. package/src/ai/code-source-tools.ts +3 -1
  34. package/src/ai/default-tool-contracts.ts +198 -0
  35. package/src/ai/default-tools.ts +25 -1
  36. package/src/ai/executor.ts +189 -35
  37. package/src/ai/fetch-file-tool.ts +41 -4
  38. package/src/ai/firecrawl-tools.ts +87 -4
  39. package/src/ai/index.ts +17 -1
  40. package/src/ai/migrate.ts +172 -14
  41. package/src/ai/protocol.ts +22 -1
  42. package/src/ai/routes.ts +11 -0
  43. package/src/ai/runtime-tools.ts +1 -0
  44. package/src/ai/runtime.ts +46 -7
  45. package/src/ai/skill-content.ts +54 -0
  46. package/src/ai/skills-routes.ts +22 -13
  47. package/src/ai/skills.ts +177 -231
  48. package/src/ai/store.ts +66 -1
  49. package/src/ai/tool-audit.ts +11 -1
  50. package/src/ai/tool-sentences.ts +38 -0
  51. package/src/ai/tools.ts +91 -3
  52. package/src/ai/types.ts +47 -1
  53. package/src/ai/web-provenance.ts +103 -0
  54. package/src/api/admin-ai-skills.ts +35 -16
  55. package/src/api/capabilities.ts +3 -2
  56. package/src/api/fanout.ts +128 -0
  57. package/src/api/help.ts +8 -3
  58. package/src/api/mcp.ts +4 -1
  59. package/src/api/search.ts +31 -106
  60. package/src/api/widgets.ts +291 -101
  61. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  62. package/src/browser/CloudResourceSearch.tsx +1 -1
  63. package/src/browser/command-shortcuts.ts +21 -6
  64. package/src/browser/ndjson.ts +34 -0
  65. package/src/browser/reload.ts +46 -0
  66. package/src/browser/search-stream.ts +4 -26
  67. package/src/browser/spotlight-position.ts +4 -1
  68. package/src/browser/widgets.ts +30 -0
  69. package/src/capabilities/executions.ts +3 -0
  70. package/src/capabilities/server.ts +20 -2
  71. package/src/cli/access.ts +48 -14
  72. package/src/cli/admin/ai-skills.ts +59 -39
  73. package/src/cli/commands.ts +20 -0
  74. package/src/cli/index.ts +16 -0
  75. package/src/cli/plugin.ts +30 -0
  76. package/src/contracts/announcement-cookie.ts +62 -0
  77. package/src/contracts/announcements.ts +14 -52
  78. package/src/contracts/app.ts +34 -2
  79. package/src/contracts/capabilities.ts +24 -0
  80. package/src/contracts/pwa-paths.ts +7 -2
  81. package/src/contracts/pwa.ts +2 -7
  82. package/src/contracts/registry.ts +10 -1
  83. package/src/contracts/shared.ts +5 -0
  84. package/src/contracts/widgets.ts +67 -2
  85. package/src/desktop/solid.tsx +6 -7
  86. package/src/index.ts +1 -0
  87. package/src/server/help.ts +8 -1
  88. package/src/server/index.ts +1 -0
  89. package/src/server/widget-request.ts +27 -0
  90. package/src/services/accounts/users.ts +15 -0
  91. package/src/services/approvals/index.ts +225 -0
  92. package/src/services/approvals/migrate.ts +33 -0
  93. package/src/services/help/index.ts +18 -5
  94. package/src/services/help/types.ts +3 -2
  95. package/src/services/identity/constants.ts +2 -0
  96. package/src/services/identity/key-ring.ts +123 -42
  97. package/src/services/identity/runtime-config.ts +13 -2
  98. package/src/shared/app-presentation.ts +11 -0
  99. package/src/shared/email-html.ts +43 -8
  100. package/src/ssr/AdminLayout.tsx +4 -7
  101. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  102. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  103. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  104. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  105. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  106. package/src/ssr/Layout.tsx +38 -14
  107. package/src/ssr/LayoutFooter.tsx +1 -1
  108. package/src/ssr/LayoutHeader.tsx +17 -20
  109. package/src/ssr/LayoutHelp.tsx +1 -1
  110. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  111. package/src/ssr/LayoutRail.tsx +1 -2
  112. package/src/ssr/PageTrail.tsx +23 -0
  113. package/src/ssr/PageTrailRow.island.tsx +20 -0
  114. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  115. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  116. package/src/ssr/PwaRuntime.island.tsx +1 -1
  117. package/src/ssr/app-navigation.ts +5 -7
  118. package/src/ssr/global-search-host.tsx +55 -0
  119. package/src/ssr/help.ts +3 -3
  120. package/src/ssr/islands/index.ts +1 -0
  121. package/src/ssr/layout-runtime.ts +60 -0
  122. package/src/ssr/mobile-menu-history.ts +1 -1
  123. package/src/ssr/page-trail-runtime.ts +313 -0
  124. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  125. package/src/ssr/page-trail.ts +59 -0
  126. package/src/ssr/platform-messages.ts +4 -0
  127. package/src/ssr/rail-context.ts +4 -3
  128. package/src/ssr/runtime.ts +14 -1
  129. package/src/styles/effects.css +21 -4
  130. package/src/styles/global.css +35 -2
  131. package/src/styles/utilities-layout.css +24 -1
  132. package/src/styles/utilities-navigation.css +6 -18
  133. package/src/ai/code-mode-skill.ts +0 -114
  134. package/src/ai/data-analysis-skill.ts +0 -10
  135. package/src/ai/grids-skill.ts +0 -158
  136. package/src/ai/skill-seeds.ts +0 -560
@@ -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
- export type AllowedLevel = GrantableLevel | { level: GrantableLevel; label?: string; icon?: string };
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.level === permission);
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
- const updateMut = mutation.create<{ accessId: string; permission: GrantableLevel }, { accessId: string; permission: GrantableLevel }>({
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, "permission", result.permission);
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
- const managers = (props.effectiveEntries?.(entries) ?? entries).filter(isManagerEntry);
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={(permission) => {
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 permission = allowed(principal)[0]?.level;
277
- if (permission && !busy()) grantMut.mutate({ principal, permission, display });
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: (permission: GrantableLevel) => void;
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 display = () => resolveEntryDisplay(props.entry.permission, props.allowed, t());
308
- const manageLabel = () => resolveEntryDisplay("admin", props.allowed, t()).label;
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.level === props.entry.permission);
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={() => props.entry.permission as GrantableLevel}
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.level,
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() }) : undefined,
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={(permission) => {
388
- if (permission !== props.entry.permission) props.onUpdatePermission(permission);
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
+ };