@cosmicdrift/kumiko-bundled-features 0.401.0 → 0.403.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/dist/agent-tools/agent-manifest.js +123 -46
- package/dist/agent-tools/tool-catalog.d.ts +6 -0
- package/dist/agent-tools/tool-catalog.js +10 -11
- package/dist/agent-tools/types.d.ts +11 -10
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +2 -2
- package/dist/inbound-mail-foundation/entities.js +1 -0
- package/dist/inbound-mail-foundation/watch-lease.d.ts +1 -1
- package/dist/inbound-mail-foundation/watch-lease.js +2 -2
- package/dist/inbound-mail-foundation/watch-supervisor-types.d.ts +5 -5
- package/dist/inbound-mail-foundation/watch-supervisor.js +5 -7
- package/dist/managed-pages/feature.js +9 -1
- package/dist/managed-pages/render-page-body.d.ts +12 -1
- package/dist/managed-pages/render-page-body.js +51 -14
- package/dist/managed-pages/web/i18n.js +1 -2
- package/dist/managed-pages/web/lossy-markdown.d.ts +1 -1
- package/dist/managed-pages/web/lossy-markdown.js +2 -16
- package/dist/managed-pages/web/markdown-to-document.js +2 -2
- package/dist/managed-pages/web/page-body-section.js +0 -1
- package/dist/readiness/handlers/run-list.query.js +2 -1
- package/dist/readiness/run-view.d.ts +4 -7
- package/dist/template-resolver/compile-document-body.js +2 -2
- package/dist/template-resolver/document-sanitizer.js +3 -3
- package/dist/template-resolver/index.d.ts +1 -1
- package/dist/template-resolver/index.js +1 -1
- package/dist/template-resolver/web/document-schema.d.ts +1 -0
- package/dist/template-resolver/web/document-schema.js +16 -1
- package/dist/template-resolver/web/i18n.js +2 -2
- package/dist/template-resolver/web/use-collection-entry-create.js +4 -3
- package/dist/user-data-rights/boot-checks.js +38 -31
- package/dist/user-data-rights/boot-rules.d.ts +17 -0
- package/dist/user-data-rights/boot-rules.js +17 -0
- package/dist/user-data-rights/web/request-deletion-screen.js +3 -3
- package/package.json +11 -11
- package/src/agent-tools/changes.json +19 -0
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/readiness/changes.json +6 -0
- package/src/template-resolver/changes.json +12 -0
- package/src/user-data-rights/changes.json +12 -0
|
@@ -3,6 +3,23 @@ import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-type
|
|
|
3
3
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { recordManifestDenyQns } from "./manifest-deny-qns.js";
|
|
6
|
+
import { isExplicitlyAgentHidden } from "./tool-catalog.js";
|
|
7
|
+
const FALLBACK_LOCALE = "en";
|
|
8
|
+
/** Requested locale, then English, then the first remaining locale in code-point
|
|
9
|
+
* order; `undefined` when the entry has no text at all (the key is then omitted). */
|
|
10
|
+
function pickLabel(labels, locale) {
|
|
11
|
+
const nonEmpty = (value) => value !== undefined && value !== "" ? value : undefined;
|
|
12
|
+
const firstLocale = Object.keys(labels)
|
|
13
|
+
.sort(compareIds)
|
|
14
|
+
.find((key) => nonEmpty(labels[key]));
|
|
15
|
+
return (nonEmpty(labels[locale]) ??
|
|
16
|
+
nonEmpty(labels[FALLBACK_LOCALE]) ??
|
|
17
|
+
(firstLocale !== undefined ? labels[firstLocale] : undefined));
|
|
18
|
+
}
|
|
19
|
+
function labelProp(labels, locale) {
|
|
20
|
+
const label = pickLabel(labels, locale);
|
|
21
|
+
return label === undefined ? {} : { label };
|
|
22
|
+
}
|
|
6
23
|
/** The manifest surfaces one string per locale — a plural entry reads as its
|
|
7
24
|
* CLDR `other` form since the manifest has no `count` to select a category. */
|
|
8
25
|
function entryAsLabels(entry) {
|
|
@@ -73,13 +90,13 @@ function isFilterableField(field) {
|
|
|
73
90
|
function isPiiField(field) {
|
|
74
91
|
return "pii" in field && field.pii === true;
|
|
75
92
|
}
|
|
76
|
-
function buildField(entityName, fieldName, field, translations, searchableFields) {
|
|
93
|
+
function buildField(entityName, fieldName, field, translations, searchableFields, locale) {
|
|
77
94
|
const options = field.type === "select" || field.type === "multiSelect" ? field.options : undefined;
|
|
78
95
|
const references = field.type === "reference" ? field.entity : undefined;
|
|
79
96
|
return {
|
|
80
97
|
name: fieldName,
|
|
81
98
|
type: field.type,
|
|
82
|
-
|
|
99
|
+
...labelProp(labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`), locale),
|
|
83
100
|
...(field.description !== undefined && { description: field.description }),
|
|
84
101
|
...(options !== undefined && { options }),
|
|
85
102
|
...(references !== undefined && { references }),
|
|
@@ -88,29 +105,45 @@ function buildField(entityName, fieldName, field, translations, searchableFields
|
|
|
88
105
|
...(isPiiField(field) && { pii: true }),
|
|
89
106
|
};
|
|
90
107
|
}
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
function buildEntities(entities, translations, getSearchableFields) {
|
|
108
|
+
/** Shape, never data. Only entities the role reaches are emitted: the manifest is
|
|
109
|
+
* prompt payload, so every extra schema costs tokens on each turn. */
|
|
110
|
+
function buildEntities(entities, translations, getSearchableFields, visibleEntityNames, locale) {
|
|
95
111
|
const result = [];
|
|
96
112
|
for (const [entityName, entity] of entities) {
|
|
113
|
+
if (!visibleEntityNames.has(entityName))
|
|
114
|
+
continue;
|
|
97
115
|
const searchableFields = new Set(getSearchableFields(entityName));
|
|
98
|
-
const fields = Object.entries(entity.fields).map(([fieldName, field]) => buildField(entityName, fieldName, field, translations, searchableFields));
|
|
116
|
+
const fields = Object.entries(entity.fields).map(([fieldName, field]) => buildField(entityName, fieldName, field, translations, searchableFields, locale));
|
|
99
117
|
result.push({
|
|
100
118
|
name: entityName,
|
|
101
|
-
|
|
119
|
+
...labelProp(labelsForSuffix(translations, `:entity:${entityName}`), locale),
|
|
102
120
|
...(entity.description !== undefined && { description: entity.description }),
|
|
103
121
|
fields,
|
|
104
122
|
});
|
|
105
123
|
}
|
|
106
124
|
return result;
|
|
107
125
|
}
|
|
108
|
-
|
|
126
|
+
const FEATURE_DESCRIPTION_MAX_CHARS = 300;
|
|
127
|
+
// Only ". " before a capital ends the sentence, so "e.g. the" and "Art. 15" stay whole.
|
|
128
|
+
const SENTENCE_END = /\.\s+(?=\p{Lu})/u;
|
|
129
|
+
/** The manifest is prompt payload; the full text stays in `FeatureDefinition.description`
|
|
130
|
+
* for the docs generators. */
|
|
131
|
+
function leadSentence(description) {
|
|
132
|
+
const text = description.replace(/\s+/g, " ").trim();
|
|
133
|
+
const end = SENTENCE_END.exec(text);
|
|
134
|
+
const sentence = end === null ? text : text.slice(0, end.index + 1);
|
|
135
|
+
return sentence.slice(0, FEATURE_DESCRIPTION_MAX_CHARS);
|
|
136
|
+
}
|
|
137
|
+
function buildFeatures(features, visibleFeatureNames) {
|
|
109
138
|
const result = [];
|
|
110
139
|
for (const [, feature] of features) {
|
|
140
|
+
if (!visibleFeatureNames.has(feature.name))
|
|
141
|
+
continue;
|
|
111
142
|
result.push({
|
|
112
143
|
name: feature.name,
|
|
113
|
-
...(feature.description !== undefined && {
|
|
144
|
+
...(feature.description !== undefined && {
|
|
145
|
+
description: leadSentence(feature.description),
|
|
146
|
+
}),
|
|
114
147
|
...(feature.uiHints?.displayLabel !== undefined && {
|
|
115
148
|
displayLabel: feature.uiHints.displayLabel,
|
|
116
149
|
}),
|
|
@@ -152,7 +185,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
|
|
|
152
185
|
return undefined;
|
|
153
186
|
// `agent.expose: true` without a `description` still opts in — the author
|
|
154
187
|
// asked for exposure explicitly, there's just nothing to show as prose.
|
|
155
|
-
const description = def.description
|
|
188
|
+
const description = def.description !== undefined && def.description !== "" ? def.description : undefined;
|
|
156
189
|
let inputSchema;
|
|
157
190
|
try {
|
|
158
191
|
// A handler whose schema can't be expressed as JSON Schema (transforms,
|
|
@@ -168,7 +201,7 @@ function buildHandlerEntry(qn, kind, def, roles, getHandlerEntity, denyQns) {
|
|
|
168
201
|
return {
|
|
169
202
|
qn,
|
|
170
203
|
kind,
|
|
171
|
-
description,
|
|
204
|
+
...(description !== undefined && { description }),
|
|
172
205
|
risk: exposure.risk,
|
|
173
206
|
inputSchema,
|
|
174
207
|
...(entity !== undefined && { entity }),
|
|
@@ -199,7 +232,7 @@ function assertDenyQnsExist(denyQns, registry) {
|
|
|
199
232
|
throw new Error(`agent denyQns names handlers that are not registered: ${unknown.join(", ")}`);
|
|
200
233
|
}
|
|
201
234
|
}
|
|
202
|
-
function buildNavs(navs, workspaces, translations, roles, screens) {
|
|
235
|
+
function buildNavs(navs, workspaces, translations, roles, screens, locale) {
|
|
203
236
|
const result = [];
|
|
204
237
|
for (const [, nav] of navs) {
|
|
205
238
|
if (!uiVisible(nav.access, roles))
|
|
@@ -215,10 +248,10 @@ function buildNavs(navs, workspaces, translations, roles, screens) {
|
|
|
215
248
|
continue;
|
|
216
249
|
result.push({
|
|
217
250
|
id: nav.id,
|
|
218
|
-
|
|
251
|
+
...labelProp(entryAsLabels(translations[nav.label] ?? {}), locale),
|
|
219
252
|
...(nav.screen !== undefined && { screen: nav.screen }),
|
|
220
253
|
...(nav.parent !== undefined && { parent: nav.parent }),
|
|
221
|
-
workspaces: visibleWorkspaces,
|
|
254
|
+
...(visibleWorkspaces.length > 0 && { workspaces: visibleWorkspaces }),
|
|
222
255
|
});
|
|
223
256
|
}
|
|
224
257
|
return withoutOrphanedNavs(result);
|
|
@@ -235,6 +268,46 @@ function withoutOrphanedNavs(navs) {
|
|
|
235
268
|
remaining = reachable;
|
|
236
269
|
}
|
|
237
270
|
}
|
|
271
|
+
// Qualified handler, screen and nav ids all start with the owning feature name.
|
|
272
|
+
function featureNameOf(qualifiedId) {
|
|
273
|
+
const idx = qualifiedId.indexOf(":");
|
|
274
|
+
return idx === -1 ? qualifiedId : qualifiedId.slice(0, idx);
|
|
275
|
+
}
|
|
276
|
+
function visibleFeatureNamesOf(handlers, screens, navs) {
|
|
277
|
+
return new Set([...handlers.map((h) => h.qn), ...screens.map((s) => s.id), ...navs.map((n) => n.id)].map(featureNameOf));
|
|
278
|
+
}
|
|
279
|
+
function entitiesOfQueryToolsFor(registry, roles, denyQns) {
|
|
280
|
+
const names = [];
|
|
281
|
+
for (const [qn, def] of registry.getAllQueryHandlers()) {
|
|
282
|
+
const entityName = registry.getHandlerEntity(qn);
|
|
283
|
+
if (entityName === undefined || denyQns.has(qn) || isExplicitlyAgentHidden(def))
|
|
284
|
+
continue;
|
|
285
|
+
if (hasAccess({ roles }, def.access))
|
|
286
|
+
names.push(entityName);
|
|
287
|
+
}
|
|
288
|
+
return names;
|
|
289
|
+
}
|
|
290
|
+
// A reference target is "entity" or "<feature>:entity"; registry entity keys are the bare name.
|
|
291
|
+
function entityNameOfReference(target) {
|
|
292
|
+
return target.slice(target.lastIndexOf(":") + 1);
|
|
293
|
+
}
|
|
294
|
+
function referenceTargetsOf(entity) {
|
|
295
|
+
return Object.values(entity?.fields ?? {}).flatMap((field) => field.type === "reference" ? [entityNameOfReference(field.entity)] : []);
|
|
296
|
+
}
|
|
297
|
+
/** Reachable = tagged on a manifest handler, shown by a manifest screen, or read by a
|
|
298
|
+
* registry-derived `get_`/`list_` tool (which takes labels from the manifest entity). */
|
|
299
|
+
function visibleEntityNamesOf(registry, roles, denyQns, handlers, screens) {
|
|
300
|
+
const reachable = new Set([
|
|
301
|
+
...handlers.flatMap((handler) => (handler.entity !== undefined ? [handler.entity] : [])),
|
|
302
|
+
...screens.flatMap((screen) => [screen.entity ?? [], screen.detailFor ?? []].flat()),
|
|
303
|
+
...entitiesOfQueryToolsFor(registry, roles, denyQns),
|
|
304
|
+
]);
|
|
305
|
+
const entities = registry.getAllEntities();
|
|
306
|
+
return new Set([
|
|
307
|
+
...reachable,
|
|
308
|
+
...[...reachable].flatMap((n) => referenceTargetsOf(entities.get(n))),
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
238
311
|
function lastSegment(qualifiedId) {
|
|
239
312
|
const idx = qualifiedId.lastIndexOf(":");
|
|
240
313
|
return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
|
|
@@ -242,10 +315,31 @@ function lastSegment(qualifiedId) {
|
|
|
242
315
|
function detailIdParam(screen) {
|
|
243
316
|
return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
|
|
244
317
|
}
|
|
245
|
-
function screenTitles(translations, screenId, shortId) {
|
|
246
|
-
|
|
318
|
+
function screenTitles(translations, screenId, shortId, locale) {
|
|
319
|
+
const title = pickLabel(entryAsLabels(translations[`${screenId}.title`] ?? translations[`screen:${shortId}.title`] ?? {}), locale);
|
|
320
|
+
return title === undefined ? {} : { title };
|
|
321
|
+
}
|
|
322
|
+
function buildScreenEntry(screen, screenWorkspaces, translations, locale) {
|
|
323
|
+
return {
|
|
324
|
+
id: screen.id,
|
|
325
|
+
type: screen.type,
|
|
326
|
+
...screenTitles(translations, screen.id, lastSegment(screen.id), locale),
|
|
327
|
+
// `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
|
|
328
|
+
// double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
|
|
329
|
+
// `labelsForSuffix` does for entity/field labels, else fall back to the
|
|
330
|
+
// literal string as authored.
|
|
331
|
+
...(screen.description !== undefined && {
|
|
332
|
+
description: pickLabel(labelsForSuffix(translations, screen.description), locale) ?? screen.description,
|
|
333
|
+
}),
|
|
334
|
+
...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
|
|
335
|
+
// A detail screen takes the row id under its idParam; everything else is parameterless.
|
|
336
|
+
...(screen.detailFor !== undefined && { params: [detailIdParam(screen)] }),
|
|
337
|
+
...(screenWorkspaces.length > 0 && { workspaces: screenWorkspaces }),
|
|
338
|
+
...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
|
|
339
|
+
...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
|
|
340
|
+
};
|
|
247
341
|
}
|
|
248
|
-
function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
|
|
342
|
+
function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds, locale) {
|
|
249
343
|
const allNavs = [...navs.values()];
|
|
250
344
|
const result = [];
|
|
251
345
|
for (const [, screen] of screens) {
|
|
@@ -265,37 +359,19 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
|
|
|
265
359
|
});
|
|
266
360
|
if (!uiVisible(screen.access, roles) || !reachable)
|
|
267
361
|
continue;
|
|
268
|
-
const shortId = lastSegment(screen.id);
|
|
269
362
|
const screenWorkspaces = dedupeSorted(accessibleNavs.flatMap((nav) => visibleWorkspaceIds(nav.workspaces, workspaces, roles)));
|
|
270
|
-
result.push(
|
|
271
|
-
id: screen.id,
|
|
272
|
-
type: screen.type,
|
|
273
|
-
titles: screenTitles(translations, screen.id, shortId),
|
|
274
|
-
// `screen.description` may be a raw i18n key (`registry.getAllTranslations()`
|
|
275
|
-
// double-prefixes it, see build-app-schema.ts:77-81) — match on suffix like
|
|
276
|
-
// `labelsForSuffix` does for entity/field labels, else fall back to the
|
|
277
|
-
// literal string as authored.
|
|
278
|
-
...(screen.description !== undefined && {
|
|
279
|
-
description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
|
|
280
|
-
}),
|
|
281
|
-
...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
|
|
282
|
-
// A detail screen takes the row id under its idParam; everything else is parameterless.
|
|
283
|
-
params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
|
|
284
|
-
workspaces: screenWorkspaces,
|
|
285
|
-
...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
|
|
286
|
-
...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
|
|
287
|
-
});
|
|
363
|
+
result.push(buildScreenEntry(screen, screenWorkspaces, translations, locale));
|
|
288
364
|
}
|
|
289
365
|
return result;
|
|
290
366
|
}
|
|
291
|
-
function buildWorkspaces(workspaces, translations, roles) {
|
|
367
|
+
function buildWorkspaces(workspaces, translations, roles, locale) {
|
|
292
368
|
const result = [];
|
|
293
369
|
for (const [, workspace] of workspaces) {
|
|
294
370
|
if (!uiVisible(workspace.access, roles))
|
|
295
371
|
continue;
|
|
296
372
|
result.push({
|
|
297
373
|
id: workspace.id,
|
|
298
|
-
|
|
374
|
+
...labelProp(entryAsLabels(translations[workspace.label] ?? {}), locale),
|
|
299
375
|
...(workspace.default === true && { default: true }),
|
|
300
376
|
});
|
|
301
377
|
}
|
|
@@ -310,13 +386,14 @@ export function buildAgentManifest(registry, options) {
|
|
|
310
386
|
const navMap = registry.getAllNavs();
|
|
311
387
|
const { roles } = options;
|
|
312
388
|
assertDenyQnsExist(options.denyQns ?? [], registry);
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), new Set(options.denyQns ?? []));
|
|
389
|
+
const denyQns = new Set(options.denyQns ?? []);
|
|
390
|
+
const handlers = buildHandlers(registry.getAllQueryHandlers(), registry.getAllWriteHandlers(), roles, (qn) => registry.getHandlerEntity(qn), denyQns);
|
|
316
391
|
const screenMap = registry.getAllScreens();
|
|
317
|
-
const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap);
|
|
318
|
-
const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)));
|
|
319
|
-
const workspaces = buildWorkspaces(workspaceMap, translations, roles);
|
|
392
|
+
const navs = buildNavs(navMap, workspaceMap, translations, roles, screenMap, options.locale);
|
|
393
|
+
const screens = buildScreens(screenMap, navMap, workspaceMap, translations, roles, new Set(navs.map((nav) => nav.id)), options.locale);
|
|
394
|
+
const workspaces = buildWorkspaces(workspaceMap, translations, roles, options.locale);
|
|
395
|
+
const features = buildFeatures(registry.features, visibleFeatureNamesOf(handlers, screens, navs));
|
|
396
|
+
const entities = buildEntities(registry.getAllEntities(), translations, (name) => registry.getSearchableFields(name), visibleEntityNamesOf(registry, roles, denyQns, handlers, screens), options.locale);
|
|
320
397
|
const manifest = {
|
|
321
398
|
builtForRoles: [...roles],
|
|
322
399
|
features: sortedByKey(features, (f) => f.name),
|
|
@@ -330,6 +407,6 @@ export function buildAgentManifest(registry, options) {
|
|
|
330
407
|
...(options.currency && { currency: options.currency }),
|
|
331
408
|
},
|
|
332
409
|
};
|
|
333
|
-
recordManifestDenyQns(manifest,
|
|
410
|
+
recordManifestDenyQns(manifest, denyQns);
|
|
334
411
|
return manifest;
|
|
335
412
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
2
|
import type { AgentManifest, RegistrySearchView, ToolCatalog, ToolCatalogOptions } from "./types.ts";
|
|
3
|
+
/** The entity CRUD tools are enumerated off the registry, not off the manifest, so
|
|
4
|
+
* `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
|
|
5
|
+
* the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
|
|
6
|
+
* handlers never carry one, so reusing it would delete every entity tool in every app. */
|
|
7
|
+
export declare function isExplicitlyAgentHidden(def: QueryHandlerDef): boolean;
|
|
2
8
|
/** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
|
|
3
9
|
* sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
|
|
4
10
|
* QNs are `<feature>:query|write:<entity>:<verb>`, so the verb only ever appears once at that
|
|
@@ -72,7 +72,7 @@ function isDetailHandlerQn(qn, entityName) {
|
|
|
72
72
|
* `resolveAgentExposure` never runs for them. Only an EXPLICIT opt-out may hide one here —
|
|
73
73
|
* the resolver's own default is fail-closed on a missing `description`, and CRUD-generated
|
|
74
74
|
* handlers never carry one, so reusing it would delete every entity tool in every app. */
|
|
75
|
-
function isExplicitlyAgentHidden(def) {
|
|
75
|
+
export function isExplicitlyAgentHidden(def) {
|
|
76
76
|
return def.agent?.expose === false;
|
|
77
77
|
}
|
|
78
78
|
/** The model's filters feed the WHERE clause, and only the output is field-filtered — a field
|
|
@@ -105,10 +105,10 @@ function addToolsForListHandler(registry, qn, entityName, entity, roles, sink) {
|
|
|
105
105
|
addTool(sink, tool, { kind: "findBy", entityName, fieldName, qn });
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
-
function entityDisplayLabel(entity, entityName
|
|
108
|
+
function entityDisplayLabel(entity, entityName) {
|
|
109
109
|
if (entity?.description)
|
|
110
110
|
return entity.description;
|
|
111
|
-
const label = entity?.
|
|
111
|
+
const label = entity?.label;
|
|
112
112
|
if (label)
|
|
113
113
|
return label;
|
|
114
114
|
return entityName;
|
|
@@ -212,7 +212,7 @@ function stripFieldFromSchema(schema, field) {
|
|
|
212
212
|
return { ...rest, properties: strippedProperties, required: strippedRequired };
|
|
213
213
|
}
|
|
214
214
|
function handlerDescription(handler) {
|
|
215
|
-
return handler.description
|
|
215
|
+
return handler.description ?? `Run ${handler.qn}.`;
|
|
216
216
|
}
|
|
217
217
|
/** Drops the `query`/`write` verb segment from a handler QN, joins the rest with `_`, and
|
|
218
218
|
* sanitizes to a valid tool-call identifier. Only the FIRST matching segment is dropped —
|
|
@@ -373,18 +373,18 @@ function collectEntityHandlerQns(registry, roleFilter, denyQns) {
|
|
|
373
373
|
}
|
|
374
374
|
return { detailQnByEntity, listQnByEntity, entityListDetailQns };
|
|
375
375
|
}
|
|
376
|
-
function addGetTools(registry, detailQnByEntity, entityByName,
|
|
376
|
+
function addGetTools(registry, detailQnByEntity, entityByName, sink) {
|
|
377
377
|
const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
|
|
378
378
|
for (const [entityName, qn] of getEntries) {
|
|
379
|
-
const label = entityDisplayLabel(entityByName.get(entityName), entityName
|
|
379
|
+
const label = entityDisplayLabel(entityByName.get(entityName), entityName);
|
|
380
380
|
const { tool, descriptor } = buildGetTool(entityName, qn, label, entityHandlerRisk(registry, qn));
|
|
381
381
|
addTool(sink, tool, descriptor);
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
|
-
function addListTools(registry, listQnByEntity, entityByName, roles,
|
|
384
|
+
function addListTools(registry, listQnByEntity, entityByName, roles, sink) {
|
|
385
385
|
const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
|
|
386
386
|
for (const [entityName, qn] of listEntries) {
|
|
387
|
-
const label = entityDisplayLabel(entityByName.get(entityName), entityName
|
|
387
|
+
const label = entityDisplayLabel(entityByName.get(entityName), entityName);
|
|
388
388
|
const searchableFields = registry.getSearchableFields(entityName);
|
|
389
389
|
const filterableFields = filterableFieldsOf(entityByName.get(entityName), registry.getEntity(entityName), roles);
|
|
390
390
|
const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields, entityHandlerRisk(registry, qn));
|
|
@@ -472,14 +472,13 @@ function addClientTools(manifest, mode, sink) {
|
|
|
472
472
|
export function buildToolCatalog(registry, manifest, options) {
|
|
473
473
|
const sink = { tools: [], dispatchTable: new Map(), usedNames: new Set() };
|
|
474
474
|
const roleFilter = { roles: manifest.builtForRoles };
|
|
475
|
-
const locale = manifest.tenantSettings.locale;
|
|
476
475
|
const entityByName = new Map(manifest.entities.map((entity) => [entity.name, entity]));
|
|
477
476
|
const denyQns = new Set(options.denyQns ?? []);
|
|
478
477
|
assertCatalogMirrorsManifestDenyQns(manifest, denyQns);
|
|
479
478
|
addRegistrySearchTools(registry, roleFilter, denyQns, sink);
|
|
480
479
|
const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
|
|
481
|
-
addGetTools(registry, detailQnByEntity, entityByName,
|
|
482
|
-
addListTools(registry, listQnByEntity, entityByName, roleFilter.roles,
|
|
480
|
+
addGetTools(registry, detailQnByEntity, entityByName, sink);
|
|
481
|
+
addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, sink);
|
|
483
482
|
addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
|
|
484
483
|
if (options.mode !== "read-only") {
|
|
485
484
|
addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
|
|
@@ -105,8 +105,8 @@ export type AgentManifestOptions = {
|
|
|
105
105
|
export type AgentManifestField = {
|
|
106
106
|
readonly name: string;
|
|
107
107
|
readonly type: string;
|
|
108
|
-
/** Label
|
|
109
|
-
readonly
|
|
108
|
+
/** Label in the requested locale (English, then any other, as fallback); absent when untranslated. */
|
|
109
|
+
readonly label?: string;
|
|
110
110
|
readonly description?: string;
|
|
111
111
|
readonly options?: readonly string[];
|
|
112
112
|
/** Entity a `reference` field points at. */
|
|
@@ -119,14 +119,14 @@ export type AgentManifestField = {
|
|
|
119
119
|
};
|
|
120
120
|
export type AgentManifestEntity = {
|
|
121
121
|
readonly name: string;
|
|
122
|
-
readonly
|
|
122
|
+
readonly label?: string;
|
|
123
123
|
readonly description?: string;
|
|
124
124
|
readonly fields: readonly AgentManifestField[];
|
|
125
125
|
};
|
|
126
126
|
export type AgentManifestHandler = {
|
|
127
127
|
readonly qn: string;
|
|
128
128
|
readonly kind: "query" | "write";
|
|
129
|
-
readonly description
|
|
129
|
+
readonly description?: string;
|
|
130
130
|
readonly risk: AgentRisk;
|
|
131
131
|
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
132
132
|
readonly entity?: string;
|
|
@@ -136,11 +136,12 @@ export type AgentManifestHandler = {
|
|
|
136
136
|
export type AgentManifestScreen = {
|
|
137
137
|
readonly id: string;
|
|
138
138
|
readonly type: string;
|
|
139
|
-
readonly
|
|
139
|
+
readonly title?: string;
|
|
140
140
|
readonly description?: string;
|
|
141
141
|
readonly entity?: string;
|
|
142
|
-
|
|
143
|
-
readonly
|
|
142
|
+
/** Only present on detail screens. */
|
|
143
|
+
readonly params?: readonly string[];
|
|
144
|
+
readonly workspaces?: readonly string[];
|
|
144
145
|
/** Entity this screen is the detail view for. Distinct from `entity`: a custom
|
|
145
146
|
* screen can render entity A while being the detail view for B. */
|
|
146
147
|
readonly detailFor?: string;
|
|
@@ -149,14 +150,14 @@ export type AgentManifestScreen = {
|
|
|
149
150
|
};
|
|
150
151
|
export type AgentManifestNav = {
|
|
151
152
|
readonly id: string;
|
|
152
|
-
readonly
|
|
153
|
+
readonly label?: string;
|
|
153
154
|
readonly screen?: string;
|
|
154
155
|
readonly parent?: string;
|
|
155
|
-
readonly workspaces
|
|
156
|
+
readonly workspaces?: readonly string[];
|
|
156
157
|
};
|
|
157
158
|
export type AgentManifestWorkspace = {
|
|
158
159
|
readonly id: string;
|
|
159
|
-
readonly
|
|
160
|
+
readonly label?: string;
|
|
160
161
|
readonly default?: true;
|
|
161
162
|
};
|
|
162
163
|
export type AgentManifestFeature = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
|
|
2
2
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
3
|
import { Marked } from "marked";
|
|
4
|
-
import {
|
|
4
|
+
import { isAllowedDocumentUrlHref, isDocumentPlaceholderHref, } from "../../template-resolver/index.js";
|
|
5
5
|
import { resolveConsentLocale } from "./consent-text.js";
|
|
6
6
|
export const CONFIRMATION_LABELS = {
|
|
7
7
|
de: {
|
|
@@ -96,7 +96,7 @@ function formatDate(iso, locale, timeZone) {
|
|
|
96
96
|
// row as escaped text, so author HTML, images and refused links stay plain text.
|
|
97
97
|
const ROW_END = "\n\n";
|
|
98
98
|
function isMailLinkHref(href) {
|
|
99
|
-
return
|
|
99
|
+
return isAllowedDocumentUrlHref(href) && !isDocumentPlaceholderHref(href);
|
|
100
100
|
}
|
|
101
101
|
const inlineHtmlMarkdown = new Marked({ gfm: false, breaks: false });
|
|
102
102
|
inlineHtmlMarkdown.use({
|
|
@@ -214,6 +214,7 @@ export const syncCursorEntity = createEntity({
|
|
|
214
214
|
export const seenMessageEntity = createEntity({
|
|
215
215
|
table: "store_mail_seen_messages",
|
|
216
216
|
softDelete: false,
|
|
217
|
+
indexes: [{ unique: true, columns: ["accountId", "providerMessageId"] }],
|
|
217
218
|
fields: {
|
|
218
219
|
accountId: createTextField({
|
|
219
220
|
required: true,
|
|
@@ -5,7 +5,7 @@ export type WatchLease = {
|
|
|
5
5
|
readonly acquire: (account: MailAccountRecord, state: WatcherState) => Promise<WatchLeaseResult>;
|
|
6
6
|
};
|
|
7
7
|
export declare function createWatchLease(deps: {
|
|
8
|
-
readonly lock: DistributedLock
|
|
8
|
+
readonly lock: DistributedLock;
|
|
9
9
|
readonly ttlSeconds: number;
|
|
10
10
|
readonly renewIntervalMs: number;
|
|
11
11
|
readonly watchers: ReadonlyMap<string, WatcherState>;
|
|
@@ -20,7 +20,7 @@ export function createWatchLease(deps) {
|
|
|
20
20
|
async function renewLeaseOnce(account, state, generation) {
|
|
21
21
|
state.renewTimer = null;
|
|
22
22
|
// skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
|
|
23
|
-
if (!isRunning() || state.generation !== generation || !state.lockToken
|
|
23
|
+
if (!isRunning() || state.generation !== generation || !state.lockToken)
|
|
24
24
|
return;
|
|
25
25
|
let renewed;
|
|
26
26
|
try {
|
|
@@ -55,7 +55,7 @@ export function createWatchLease(deps) {
|
|
|
55
55
|
}
|
|
56
56
|
// kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional
|
|
57
57
|
async function acquire(account, state) {
|
|
58
|
-
if (
|
|
58
|
+
if (state.lockToken)
|
|
59
59
|
return "acquired";
|
|
60
60
|
// A throwing lock (Redis outage) reads as "not claimed": the next tick retries instead of leaving the start flag set.
|
|
61
61
|
const token = await lock.acquire(account.id, { ttlSeconds: ttlSeconds }).catch((err) => {
|
|
@@ -40,15 +40,15 @@ export type InboundMailSupervisorDeps = {
|
|
|
40
40
|
/** Coordinates the IMAP watch (not the poll) across worker processes:
|
|
41
41
|
* before `plugin.watch()`, ensureWatcher claims a TTL lease for the
|
|
42
42
|
* account via `lock.acquire` and renews it via `lock.renew` while the
|
|
43
|
-
* watcher lives.
|
|
44
|
-
*
|
|
45
|
-
readonly lock
|
|
43
|
+
* watcher lives. Required: without it every worker would hold its own
|
|
44
|
+
* IDLE connection per account. */
|
|
45
|
+
readonly lock: DistributedLock;
|
|
46
46
|
/** Access-token lifecycle for OAuth accounts. Defaults to a manager built
|
|
47
47
|
* from `db` + `providerCtx.secrets`; pass the instance shared with
|
|
48
48
|
* `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
|
|
49
49
|
readonly oauthTokens?: OAuthAccessTokenManager;
|
|
50
50
|
/** TTL (seconds) for the per-account watch lease. Default 90s, renewed
|
|
51
|
-
* every ttl/3.
|
|
51
|
+
* every ttl/3. */
|
|
52
52
|
readonly watchLeaseTtlSeconds?: number;
|
|
53
53
|
readonly log?: (line: string) => void;
|
|
54
54
|
};
|
|
@@ -60,7 +60,7 @@ export type WatcherState = {
|
|
|
60
60
|
* an already stopped watcher. */
|
|
61
61
|
generation: number;
|
|
62
62
|
/** Token from `deps.lock.acquire` while this worker owns the account's
|
|
63
|
-
* watch lease; null when unclaimed (
|
|
63
|
+
* watch lease; null when unclaimed (claim lost). */
|
|
64
64
|
lockToken: string | null;
|
|
65
65
|
renewTimer: ReturnType<typeof setTimeout> | null;
|
|
66
66
|
/** Consecutive renew() throws — tear down once failures span a full TTL. */
|
|
@@ -17,11 +17,9 @@
|
|
|
17
17
|
// db,
|
|
18
18
|
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
19
19
|
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
20
|
-
// //
|
|
21
|
-
// //
|
|
22
|
-
// //
|
|
23
|
-
// // for a single-process deployment — every active account gets
|
|
24
|
-
// // watched locally, as before.
|
|
20
|
+
// // One DistributedLock (same Redis, same key prefix) shared across
|
|
21
|
+
// // every worker process, so only one of them holds the IMAP IDLE
|
|
22
|
+
// // connection per account.
|
|
25
23
|
// lock: createDistributedLock(redis, `${RedisKeys.lock}inbound-mail:watch:`),
|
|
26
24
|
// });
|
|
27
25
|
// await supervisor.start();
|
|
@@ -34,7 +32,7 @@
|
|
|
34
32
|
// **Multi-worker coordination:** the reconciliation poll remains deliberately
|
|
35
33
|
// N-fold: every worker polls every active account, which is idempotent and
|
|
36
34
|
// cheap. Only `plugin.watch()` holds a long-lived connection, and only one
|
|
37
|
-
// worker may hold it per account.
|
|
35
|
+
// worker may hold it per account. `ensureWatcher` claims a
|
|
38
36
|
// TTL lease (`lock.acquire`) before connecting; a worker that fails to claim
|
|
39
37
|
// it leaves the account to its current holder, while the poll still covers
|
|
40
38
|
// it. The holder renews the lease via heartbeat (`lock.renew`, every ttl/3)
|
|
@@ -375,7 +373,7 @@ export function createInboundMailSupervisor(deps) {
|
|
|
375
373
|
log(`inbound-mail: stop watcher ${accountId} failed: ${describeError(err)}`);
|
|
376
374
|
}
|
|
377
375
|
}
|
|
378
|
-
if (
|
|
376
|
+
if (lockToken) {
|
|
379
377
|
try {
|
|
380
378
|
await deps.lock.release(accountId, lockToken);
|
|
381
379
|
}
|
|
@@ -184,7 +184,15 @@ export function createManagedPagesFeature(opts) {
|
|
|
184
184
|
}
|
|
185
185
|
const html = wrapLayout({
|
|
186
186
|
title: data.title,
|
|
187
|
-
bodyHtml: renderPageBody({
|
|
187
|
+
bodyHtml: renderPageBody({
|
|
188
|
+
tenantId,
|
|
189
|
+
slug,
|
|
190
|
+
lang,
|
|
191
|
+
version: data.version,
|
|
192
|
+
updatedAt: data.updatedAt,
|
|
193
|
+
body: data.body,
|
|
194
|
+
bodyFormat: data.bodyFormat,
|
|
195
|
+
}),
|
|
188
196
|
lang: data.lang,
|
|
189
197
|
slug,
|
|
190
198
|
description: data.description,
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
import { sanitizeDocumentHtml } from "../template-resolver/index.ts";
|
|
2
3
|
import type { BodyFormat } from "./constants.ts";
|
|
3
4
|
type WarnLogger = Pick<Logger, "warn">;
|
|
4
5
|
export type RenderablePageBody = {
|
|
6
|
+
readonly tenantId: string;
|
|
5
7
|
readonly slug: string;
|
|
8
|
+
readonly lang: string;
|
|
9
|
+
readonly version: number;
|
|
10
|
+
readonly updatedAt: string;
|
|
6
11
|
readonly body: string;
|
|
7
12
|
readonly bodyFormat: BodyFormat;
|
|
8
13
|
};
|
|
9
|
-
export
|
|
14
|
+
export type PageBodyRendererDeps = {
|
|
15
|
+
readonly sanitizeDocument?: typeof sanitizeDocumentHtml;
|
|
16
|
+
readonly renderMarkdown?: (markdown: string) => string;
|
|
17
|
+
readonly maxEntries?: number;
|
|
18
|
+
};
|
|
19
|
+
export declare function createPageBodyRenderer(deps?: PageBodyRendererDeps): (page: RenderablePageBody, log?: WarnLogger) => string;
|
|
20
|
+
export declare const renderPageBody: (page: RenderablePageBody, log?: WarnLogger) => string;
|
|
10
21
|
export {};
|