@k2b/cloud 0.36.0 → 0.37.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 +3 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +38 -9
- package/src/access/ui.ts +1 -0
- package/src/ai/chat/answer-links.ts +8 -0
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/primitives.tsx +2 -1
- package/src/ai/executor.ts +46 -3
- package/src/ai/types.ts +1 -0
- package/src/browser/shell-focus.ts +67 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- package/src/services/index.ts +1 -0
- package/src/services/network-security.ts +28 -0
- package/src/shared/network-address.ts +9 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +8 -0
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +36 -1
- package/src/ssr/app-launchpad.tsx +1 -1
- package/src/ssr/platform-messages.ts +10 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/styles/global.css +155 -26
- package/src/styles/utilities-navigation.css +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -106,8 +106,8 @@
|
|
|
106
106
|
"@tabler/icons": "3.48.0",
|
|
107
107
|
"@tailwindcss/typography": "0.5.20",
|
|
108
108
|
"@k2b/nessi": "0.18.0",
|
|
109
|
-
"@k2b/ssr": "0.
|
|
110
|
-
"@k2b/ui": "0.
|
|
109
|
+
"@k2b/ssr": "0.17.0",
|
|
110
|
+
"@k2b/ui": "0.27.0",
|
|
111
111
|
"@k2b/stdlib": "0.29.0",
|
|
112
112
|
"@k2b/sync": "7.0.0",
|
|
113
113
|
"@nats-io/jetstream": "3.4.0",
|
package/scripts/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
- `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
|
|
4
4
|
- `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
|
|
5
5
|
- `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
|
|
6
|
+
- `cli-pack.ts` — packs a standalone `cld` module with its verified manifest and skill references: `bun run packages/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>`. Reads the package name and version from the current directory; publish the output with `npm publish <dir>` or archive it with `npm pack <dir>`.
|
|
6
7
|
- `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
|
|
7
8
|
- `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
|
|
8
9
|
- `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Pack a `cld` module as a package plugin, for authors without a Cloud
|
|
4
|
+
* application:
|
|
5
|
+
*
|
|
6
|
+
* bun run node_modules/@k2b/cloud/scripts/cli-pack.ts src/cli.ts --references src/cli-references --out dist/cld-plugin
|
|
7
|
+
*
|
|
8
|
+
* Writes the same verified bundle the application build writes to
|
|
9
|
+
* `dist/cli/<name>/` (`cli.js`, `references/`, `manifest.json`), plus a
|
|
10
|
+
* `package.json` whose `cld.manifest` points at the manifest. Name and version
|
|
11
|
+
* come from `package.json` in the current directory.
|
|
12
|
+
*/
|
|
13
|
+
import { lstat, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { buildCliPlugin, writeCliPlugin } from "../src/_internal/cli-plugins";
|
|
17
|
+
import { validateCloudCliModule } from "../src/cli/plugin";
|
|
18
|
+
|
|
19
|
+
const USAGE = "Usage: bun run node_modules/@k2b/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>";
|
|
20
|
+
|
|
21
|
+
const HELP = `${USAGE}
|
|
22
|
+
|
|
23
|
+
Packs a cld module as a package plugin: the bundled module cli.js, the skill
|
|
24
|
+
references (index.md plus further Markdown files), manifest.json with the
|
|
25
|
+
SHA-512 of every file, and a package.json with "cld": { "manifest": "manifest.json" }.
|
|
26
|
+
Name and version come from package.json in the current directory.
|
|
27
|
+
|
|
28
|
+
--out must be missing, empty, or an earlier pack output, which is replaced.
|
|
29
|
+
Ship the result with \`npm publish <out>\` or \`npm pack <out>\`.`;
|
|
30
|
+
|
|
31
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
|
|
33
|
+
const readJson = (path: string): Promise<unknown> =>
|
|
34
|
+
readFile(path, "utf8")
|
|
35
|
+
.then(JSON.parse)
|
|
36
|
+
.catch(() => null);
|
|
37
|
+
|
|
38
|
+
const parseArgs = (args: string[]) => {
|
|
39
|
+
const [module, ...rest] = args;
|
|
40
|
+
const flags = new Map<string, string>();
|
|
41
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
42
|
+
const [flag, value] = [rest[index]!, rest[index + 1]];
|
|
43
|
+
if (!["--references", "--out"].includes(flag) || !value || value.startsWith("-") || flags.has(flag)) throw new Error(USAGE);
|
|
44
|
+
flags.set(flag, value);
|
|
45
|
+
}
|
|
46
|
+
const references = flags.get("--references");
|
|
47
|
+
const out = flags.get("--out");
|
|
48
|
+
if (!module || module.startsWith("-") || !references || !out) throw new Error(USAGE);
|
|
49
|
+
return { module: resolve(module), references: resolve(references), out: resolve(out) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const containsPath = (directory: string, path: string): boolean => {
|
|
53
|
+
const inside = relative(directory, path);
|
|
54
|
+
return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Empty `out` for a fresh pack; anything but an earlier pack output stays untouched. */
|
|
58
|
+
const prepareOut = async (out: string, inputs: string[]): Promise<void> => {
|
|
59
|
+
const refusal = `Refusing --out ${out}: it must be missing, empty, or an earlier pack output.`;
|
|
60
|
+
const output = await realpath(out).catch(() => out);
|
|
61
|
+
for (const input of inputs) {
|
|
62
|
+
const path = await realpath(input).catch(() => input);
|
|
63
|
+
if (containsPath(out, input) || containsPath(output, path)) throw new Error(refusal);
|
|
64
|
+
}
|
|
65
|
+
const info = await lstat(out).catch(() => null);
|
|
66
|
+
if (!info) return;
|
|
67
|
+
if (!info.isDirectory()) throw new Error(refusal);
|
|
68
|
+
const entries = await readdir(out);
|
|
69
|
+
if (entries.length === 0) return;
|
|
70
|
+
const previous = await readJson(join(out, "package.json"));
|
|
71
|
+
if (
|
|
72
|
+
entries.some((entry) => !["package.json", "manifest.json", "cli.js", "references"].includes(entry)) ||
|
|
73
|
+
!isRecord(previous) ||
|
|
74
|
+
!isRecord(previous.cld) ||
|
|
75
|
+
previous.cld.manifest !== "manifest.json"
|
|
76
|
+
)
|
|
77
|
+
throw new Error(refusal);
|
|
78
|
+
await rm(out, { recursive: true });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const pack = async (args: string[]): Promise<void> => {
|
|
82
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
83
|
+
console.log(HELP);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const options = parseArgs(args);
|
|
87
|
+
const cwd = process.cwd();
|
|
88
|
+
const manifest = await readJson(join(cwd, "package.json"));
|
|
89
|
+
if (
|
|
90
|
+
!isRecord(manifest) ||
|
|
91
|
+
typeof manifest.name !== "string" ||
|
|
92
|
+
!manifest.name ||
|
|
93
|
+
typeof manifest.version !== "string" ||
|
|
94
|
+
!manifest.version
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(`${join(cwd, "package.json")} needs a string name and version.`);
|
|
97
|
+
}
|
|
98
|
+
const { name: packageName, version } = manifest;
|
|
99
|
+
await prepareOut(options.out, [cwd, options.module, options.references]);
|
|
100
|
+
const loaded: unknown = await import(pathToFileURL(options.module).href);
|
|
101
|
+
const module = validateCloudCliModule(isRecord(loaded) ? loaded.default : undefined);
|
|
102
|
+
const plugin = await buildCliPlugin({
|
|
103
|
+
appDir: cwd,
|
|
104
|
+
appId: packageName,
|
|
105
|
+
name: module.name,
|
|
106
|
+
declaration: { module: options.module, references: options.references },
|
|
107
|
+
version,
|
|
108
|
+
});
|
|
109
|
+
await writeCliPlugin(options.out, plugin);
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(options.out, "package.json"),
|
|
112
|
+
`${JSON.stringify({ name: packageName, version, type: "module", cld: { manifest: "manifest.json" } }, null, 2)}\n`,
|
|
113
|
+
);
|
|
114
|
+
const references = plugin.manifest.files.filter((file) => file.path.startsWith("references/")).length;
|
|
115
|
+
console.log(
|
|
116
|
+
`Packed ${packageName}@${version} (module "${module.name}", ${references} reference files) to ${options.out}. Ship it with \`npm publish ${options.out}\` or \`npm pack ${options.out}\`.`,
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
pack(Bun.argv.slice(2)).catch((error: unknown) => {
|
|
121
|
+
console.error(error instanceof Error ? error.message.replace(/\s*\n\s*/g, " ") : String(error));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const SHELL_FOCUS_COOKIE = "cloud_shell_focus";
|
|
2
|
+
|
|
3
|
+
export const readShellFocusCookie = (cookieHeader: string | null | undefined): boolean =>
|
|
4
|
+
cookieHeader
|
|
5
|
+
?.split(";")
|
|
6
|
+
.map((part) => part.trim())
|
|
7
|
+
.find((part) => part.startsWith(`${SHELL_FOCUS_COOKIE}=`))
|
|
8
|
+
?.slice(SHELL_FOCUS_COOKIE.length + 1) === "1";
|
|
9
|
+
|
|
10
|
+
export const shellFocusCookie = (hidden: boolean, secure: boolean): string =>
|
|
11
|
+
`${SHELL_FOCUS_COOKIE}=${hidden ? "1" : ""}; Path=/; Max-Age=${hidden ? 31536000 : 0}; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LocaleProvider } from "@k2b/ui";
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
import PermissionEditor from "./PermissionEditor";
|
|
4
|
+
|
|
5
|
+
const host = document.getElementById("root");
|
|
6
|
+
if (!host) throw new Error("Missing harness root");
|
|
7
|
+
render(
|
|
8
|
+
() => (
|
|
9
|
+
<LocaleProvider locale="en">
|
|
10
|
+
<PermissionEditor
|
|
11
|
+
initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
|
|
12
|
+
id: `access-${index}`,
|
|
13
|
+
principal: { type: "user", userId: `user-${index}` },
|
|
14
|
+
displayName,
|
|
15
|
+
permission: "read",
|
|
16
|
+
createdAt: "2026-10-15T07:00:00.000Z",
|
|
17
|
+
}))}
|
|
18
|
+
// Equal-width labels isolate pending-state layout from a normal change in label width.
|
|
19
|
+
allowedLevels={[
|
|
20
|
+
{ level: "read", label: "Level 1" },
|
|
21
|
+
{ level: "write", label: "Level 2" },
|
|
22
|
+
]}
|
|
23
|
+
allowAuthenticated={false}
|
|
24
|
+
grantAccess={async () => {
|
|
25
|
+
throw new Error("Not used");
|
|
26
|
+
}}
|
|
27
|
+
updateAccess={() =>
|
|
28
|
+
new Promise<void>((resolve) => {
|
|
29
|
+
// The test waits for this mark, so it does not depend on how the editor shows a pending change.
|
|
30
|
+
document.documentElement.dataset.saving = "true";
|
|
31
|
+
document.addEventListener(
|
|
32
|
+
"settle-access",
|
|
33
|
+
() => {
|
|
34
|
+
delete document.documentElement.dataset.saving;
|
|
35
|
+
resolve();
|
|
36
|
+
},
|
|
37
|
+
{ once: true },
|
|
38
|
+
);
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
revokeAccess={async () => {}}
|
|
42
|
+
/>
|
|
43
|
+
</LocaleProvider>
|
|
44
|
+
),
|
|
45
|
+
host,
|
|
46
|
+
);
|
|
@@ -236,8 +236,16 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
236
236
|
};
|
|
237
237
|
|
|
238
238
|
type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
|
|
239
|
+
// The row whose change is saving: it shows the chosen level with a spinner, the other rows wait.
|
|
240
|
+
const [updating, setUpdating] = createSignal<LevelChange | null>(null);
|
|
241
|
+
const [revoking, setRevoking] = createSignal<string | null>(null);
|
|
242
|
+
const updatingRow = (accessId: string) => {
|
|
243
|
+
const change = updating();
|
|
244
|
+
return change?.accessId === accessId ? change : undefined;
|
|
245
|
+
};
|
|
239
246
|
const updateMut = mutation.create<LevelChange, LevelChange>({
|
|
240
247
|
mutation: async (data) => {
|
|
248
|
+
setUpdating(data);
|
|
241
249
|
await props.updateAccess(data.accessId, data.permission, data.scope);
|
|
242
250
|
return data;
|
|
243
251
|
},
|
|
@@ -247,6 +255,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
247
255
|
}
|
|
248
256
|
},
|
|
249
257
|
onError: (err) => prompts.error(err.message),
|
|
258
|
+
onFinally: () => {
|
|
259
|
+
if (!disposed) setUpdating(null);
|
|
260
|
+
},
|
|
250
261
|
});
|
|
251
262
|
|
|
252
263
|
const revokeMut = mutation.create<string | null, AccessEntry>({
|
|
@@ -257,6 +268,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
257
268
|
variant: "danger",
|
|
258
269
|
});
|
|
259
270
|
if (!confirmed) return null;
|
|
271
|
+
setRevoking(entry.id);
|
|
260
272
|
await props.revokeAccess(entry.id);
|
|
261
273
|
return entry.id;
|
|
262
274
|
},
|
|
@@ -264,6 +276,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
264
276
|
if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
|
|
265
277
|
},
|
|
266
278
|
onError: (err) => prompts.error(err.message),
|
|
279
|
+
onFinally: () => {
|
|
280
|
+
if (!disposed) setRevoking(null);
|
|
281
|
+
},
|
|
267
282
|
});
|
|
268
283
|
const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
|
|
269
284
|
// The service refuses to remove the last manager; the row says so up front.
|
|
@@ -288,6 +303,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
288
303
|
coverage={coverage}
|
|
289
304
|
canEdit={canEdit()}
|
|
290
305
|
pending={entry.id === pending()}
|
|
306
|
+
updating={updatingRow(entry.id)}
|
|
307
|
+
revoking={entry.id === revoking()}
|
|
291
308
|
disabled={busy()}
|
|
292
309
|
lastManager={entry.id === lastManagerId()}
|
|
293
310
|
allowed={allowed(entry.principal)}
|
|
@@ -332,8 +349,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
332
349
|
function AccessEntryRow(props: {
|
|
333
350
|
entry: AccessEntry;
|
|
334
351
|
canEdit: boolean;
|
|
352
|
+
/** Another change is saving: the row keeps its controls, disabled. */
|
|
335
353
|
disabled: boolean;
|
|
354
|
+
/** This row's grant is saving. */
|
|
336
355
|
pending?: boolean;
|
|
356
|
+
/** This row's level change is saving. */
|
|
357
|
+
updating?: { permission: GrantableLevel; scope: string | undefined };
|
|
358
|
+
/** This row's removal is saving. */
|
|
359
|
+
revoking?: boolean;
|
|
337
360
|
/** The only entry that manages the resource: it keeps its level and cannot be removed. */
|
|
338
361
|
lastManager: boolean;
|
|
339
362
|
allowed: ResolvedLevel[];
|
|
@@ -352,8 +375,14 @@ function AccessEntryRow(props: {
|
|
|
352
375
|
const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
|
|
353
376
|
const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
|
|
354
377
|
const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
|
|
355
|
-
|
|
356
|
-
|
|
378
|
+
// Whether the row offers a picker depends only on what the viewer may do, never on a change in flight, so a
|
|
379
|
+
// saving change never swaps the picker for the badge and the rows keep their size.
|
|
380
|
+
const hasPicker = () => props.canEdit && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
|
|
381
|
+
const saving = () => props.pending || props.updating !== undefined || props.revoking === true;
|
|
382
|
+
// While a level change saves, the picker already shows the chosen level.
|
|
383
|
+
const shownKey = () => (props.updating ? levelKey(props.updating.permission, props.updating.scope) : entryKey());
|
|
384
|
+
const shownDisplay = () =>
|
|
385
|
+
props.updating ? resolveEntryDisplay(props.updating.permission, props.updating.scope, props.allowed, t()) : display();
|
|
357
386
|
|
|
358
387
|
const badgeClass =
|
|
359
388
|
"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";
|
|
@@ -362,15 +391,12 @@ function AccessEntryRow(props: {
|
|
|
362
391
|
<>
|
|
363
392
|
<i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
|
|
364
393
|
<span>{display().label}</span>
|
|
365
|
-
<Show when={isInteractive()}>
|
|
366
|
-
<i class="ti ti-chevron-down text-[10px]" />
|
|
367
|
-
</Show>
|
|
368
394
|
</>
|
|
369
395
|
);
|
|
370
396
|
|
|
371
397
|
return (
|
|
372
398
|
<div>
|
|
373
|
-
<div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={
|
|
399
|
+
<div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={saving() ? "true" : undefined}>
|
|
374
400
|
<Show
|
|
375
401
|
when={props.entry.principal.type === "user"}
|
|
376
402
|
fallback={
|
|
@@ -415,10 +441,12 @@ function AccessEntryRow(props: {
|
|
|
415
441
|
|
|
416
442
|
{/* Permission badge — interactive single-value picker when editable,
|
|
417
443
|
plain span otherwise. */}
|
|
418
|
-
<Show when={
|
|
444
|
+
<Show when={hasPicker()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
|
|
419
445
|
<SelectChip
|
|
420
446
|
aria-label={t().permissionFor({ name: displayName() })}
|
|
421
|
-
value={
|
|
447
|
+
value={shownKey}
|
|
448
|
+
disabled={props.disabled}
|
|
449
|
+
loading={props.pending || props.updating !== undefined}
|
|
422
450
|
options={props.allowed.map((option) => {
|
|
423
451
|
const locked = props.lastManager && (option.level !== "admin" || option.scope !== undefined);
|
|
424
452
|
return {
|
|
@@ -429,7 +457,7 @@ function AccessEntryRow(props: {
|
|
|
429
457
|
description: locked ? t().lastManagerOption({ level: manageLabel() }) : option.description,
|
|
430
458
|
};
|
|
431
459
|
})}
|
|
432
|
-
icon={`ti ${
|
|
460
|
+
icon={`ti ${shownDisplay().icon}`}
|
|
433
461
|
position="bottom-left"
|
|
434
462
|
onValueChange={(key) => {
|
|
435
463
|
const option = props.allowed.find((candidate) => candidate.key === key);
|
|
@@ -452,6 +480,7 @@ function AccessEntryRow(props: {
|
|
|
452
480
|
size="xs"
|
|
453
481
|
onClick={props.onRevoke}
|
|
454
482
|
disabled={props.disabled || props.pending || props.lastManager}
|
|
483
|
+
loading={props.revoking}
|
|
455
484
|
aria-label={t().remove({ name: displayName() })}
|
|
456
485
|
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
457
486
|
aria-description={props.lastManager ? removeLabel() : undefined}
|
package/src/access/ui.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* from depending on Cloud's broad legacy UI entrypoint.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
export { isManagerEntry, type ManagerCandidate } from "./managers";
|
|
9
10
|
export type { AllowedLevel, GrantableLevel } from "./PermissionEditor";
|
|
10
11
|
export { default as PermissionEditor } from "./PermissionEditor";
|
|
11
12
|
export { default as PrincipalPicker, principalKey } from "./PrincipalPicker";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root scheme-less Cloud resource links in sanitized Assistant answer HTML.
|
|
3
|
+
* Normalized links stay root-relative under /app/ or /open/ and can never change origin.
|
|
4
|
+
*/
|
|
5
|
+
export function rootResourceLinks(html: string): string {
|
|
6
|
+
// sanitize-html emits double-quoted attributes with ", <, and > escaped in values.
|
|
7
|
+
return html.replace(/<a\b[^>]*>/g, (tag) => tag.replace(/(\shref=")((?:app|open)\/)/g, "$1/$2"));
|
|
8
|
+
}
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -34,6 +34,7 @@ const messages = i18n.define({
|
|
|
34
34
|
turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
|
|
35
35
|
if (code === "model_unavailable") return "The model service did not answer.";
|
|
36
36
|
if (code === "provider_stopped") return "The model provider ended this answer early.";
|
|
37
|
+
if (code === "tool_failed") return "A step failed, and the model stopped without an answer.";
|
|
37
38
|
if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
|
|
38
39
|
if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
|
|
39
40
|
if (code === "time_limit")
|
|
@@ -336,6 +337,7 @@ const messages = i18n.define({
|
|
|
336
337
|
turnErrorReason: ({ code, limitMinutes }) => {
|
|
337
338
|
if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
|
|
338
339
|
if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
|
|
340
|
+
if (code === "tool_failed") return "Ein Schritt ist fehlgeschlagen, und das Modell hat danach nicht geantwortet.";
|
|
339
341
|
if (code === "quota_exhausted")
|
|
340
342
|
return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
|
|
341
343
|
if (code === "context_full")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
2
|
+
import { rootResourceLinks } from "./answer-links";
|
|
2
3
|
import { useAiChatActions } from "./message-actions";
|
|
3
4
|
|
|
4
5
|
export function AssistantMarkdownBlock(props: { html: string }) {
|
|
@@ -41,5 +42,5 @@ export function AssistantMarkdownBlock(props: { html: string }) {
|
|
|
41
42
|
element.addEventListener("click", handleClick);
|
|
42
43
|
onCleanup(() => element.removeEventListener("click", handleClick));
|
|
43
44
|
});
|
|
44
|
-
return <div ref={element} class="assistant-markdown-block" innerHTML={props.html} />;
|
|
45
|
+
return <div ref={element} class="assistant-markdown-block" innerHTML={rootResourceLinks(props.html)} />;
|
|
45
46
|
}
|
package/src/ai/executor.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
|
23
23
|
import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
24
24
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
25
25
|
import { aiChatMessages } from "./chat/messages";
|
|
26
|
+
import { isFailedTool } from "./chat/tool-groups";
|
|
26
27
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
27
28
|
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
28
29
|
import { createCloudCompactFn } from "./compaction";
|
|
@@ -134,6 +135,17 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
|
|
|
134
135
|
};
|
|
135
136
|
};
|
|
136
137
|
|
|
138
|
+
const lastToolRoundFailed = (messages: AiStoredMessage[], blocks: AiTurnBlock[]): boolean => {
|
|
139
|
+
const latest = messages.findLast(({ message }) => message.role === "assistant")?.message;
|
|
140
|
+
if (latest?.role !== "assistant") return false;
|
|
141
|
+
const callIds = new Set(latest.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
|
|
142
|
+
// A worker may have stored a malformed call but crashed before its error result; load repairs that result without an event.
|
|
143
|
+
return (
|
|
144
|
+
[...callIds].some(isMalformedToolCallId) ||
|
|
145
|
+
blocks.some((block) => block.kind === "tool" && callIds.has(block.callId) && isFailedTool(block))
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
137
149
|
const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
|
|
138
150
|
try {
|
|
139
151
|
await aiConversations.indexConversationResources(input);
|
|
@@ -1500,6 +1512,7 @@ export class AiTurnExecutor {
|
|
|
1500
1512
|
const outcome = await this.driveChatLoop({
|
|
1501
1513
|
loop,
|
|
1502
1514
|
pipeline,
|
|
1515
|
+
initialRoundFailed: lastToolRoundFailed(turnMessages, turnBlocks),
|
|
1503
1516
|
conversationId,
|
|
1504
1517
|
turnId,
|
|
1505
1518
|
abortController,
|
|
@@ -1572,6 +1585,7 @@ export class AiTurnExecutor {
|
|
|
1572
1585
|
private async driveChatLoop(input: {
|
|
1573
1586
|
loop: NessiLoop;
|
|
1574
1587
|
pipeline: StreamPipeline;
|
|
1588
|
+
initialRoundFailed: boolean;
|
|
1575
1589
|
conversationId: string;
|
|
1576
1590
|
turnId: string;
|
|
1577
1591
|
abortController: AbortController;
|
|
@@ -1613,6 +1627,10 @@ export class AiTurnExecutor {
|
|
|
1613
1627
|
} = input;
|
|
1614
1628
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1615
1629
|
let lastIssueMessage: string | null = null;
|
|
1630
|
+
let lastResponse: AssistantMessage | undefined;
|
|
1631
|
+
let roundFailed = input.initialRoundFailed;
|
|
1632
|
+
let followedFailedRound = false;
|
|
1633
|
+
const malformedCallIds = new Set<string>();
|
|
1616
1634
|
|
|
1617
1635
|
try {
|
|
1618
1636
|
for await (const event of loop) {
|
|
@@ -1672,6 +1690,7 @@ export class AiTurnExecutor {
|
|
|
1672
1690
|
.noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
|
|
1673
1691
|
.catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
|
|
1674
1692
|
const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
|
|
1693
|
+
if (toolBlock?.kind === "tool" && isFailedTool(toolBlock)) roundFailed = true;
|
|
1675
1694
|
// The policy keys calls by the name the model called, as the persisted calls it seeds from.
|
|
1676
1695
|
noteToolCall(
|
|
1677
1696
|
toolBlock?.kind === "tool"
|
|
@@ -1687,20 +1706,44 @@ export class AiTurnExecutor {
|
|
|
1687
1706
|
result: event.result,
|
|
1688
1707
|
isError: event.isError === true,
|
|
1689
1708
|
});
|
|
1690
|
-
} else if (event.type === "turn_end"
|
|
1691
|
-
|
|
1709
|
+
} else if (event.type === "turn_end") {
|
|
1710
|
+
lastResponse = event.message;
|
|
1711
|
+
followedFailedRound = roundFailed;
|
|
1712
|
+
const calls = new Set(event.message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
|
|
1713
|
+
// nessi closes a round after executing its tools. Malformed calls have stored failed blocks,
|
|
1714
|
+
// but are added by Cloud's store wrapper, so they are absent from the event's message.
|
|
1715
|
+
roundFailed = pipeline.blocks.some(
|
|
1716
|
+
(block) => block.kind === "tool" && (calls.has(block.callId) || malformedCallIds.has(block.callId)) && isFailedTool(block),
|
|
1717
|
+
);
|
|
1718
|
+
malformedCallIds.clear();
|
|
1719
|
+
if (!abortController.signal.aborted && isToolRound(event.message)) noteToolRound();
|
|
1692
1720
|
} else if (event.type === "issue") {
|
|
1721
|
+
if (event.issue.kind === "malformed_tool_call" && event.issue.callId) malformedCallIds.add(event.issue.callId);
|
|
1693
1722
|
lastIssueMessage = event.issue.message;
|
|
1694
1723
|
log.warn("AI turn issue", { conversationId, turnId, kind: event.issue.kind, message: event.issue.message });
|
|
1695
1724
|
} else if (event.type === "loop_end") {
|
|
1725
|
+
const stoppedAfterFailure =
|
|
1726
|
+
event.reason === "stop" &&
|
|
1727
|
+
!abortController.signal.aborted &&
|
|
1728
|
+
followedFailedRound &&
|
|
1729
|
+
lastResponse !== undefined &&
|
|
1730
|
+
!lastResponse.content.some((block) => block.type === "tool_call" || (block.type === "text" && block.text.trim() !== ""));
|
|
1731
|
+
const doneReason = stoppedAfterFailure ? "error" : event.reason;
|
|
1696
1732
|
const aggregate = await withDurableTurnTiming(turnId, event.aggregate);
|
|
1697
1733
|
if (aggregate.assistantMessageCount > 0) {
|
|
1698
1734
|
await aiConversations
|
|
1699
|
-
.setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason
|
|
1735
|
+
.setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason })
|
|
1700
1736
|
.catch(() => undefined);
|
|
1701
1737
|
}
|
|
1702
1738
|
const timing = aggregate.timing;
|
|
1703
1739
|
if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
|
|
1740
|
+
if (stoppedAfterFailure)
|
|
1741
|
+
return {
|
|
1742
|
+
kind: "finished",
|
|
1743
|
+
status: "failed",
|
|
1744
|
+
failure: { error: { code: "tool_failed" }, detail: "The model stopped without an answer after a failed tool call." },
|
|
1745
|
+
timing,
|
|
1746
|
+
};
|
|
1704
1747
|
if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
|
|
1705
1748
|
const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
|
|
1706
1749
|
const reason: AiTurnFailureReason =
|
package/src/ai/types.ts
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { shellFocusCookie } from "../_internal/shell-focus";
|
|
2
|
+
|
|
3
|
+
export const SHELL_FOCUS_CHANGE_EVENT = "cloud:shell-focus-change";
|
|
4
|
+
|
|
5
|
+
const canvas = () => document.querySelector<HTMLElement>(".cloud-app-canvas[data-layout-authenticated]");
|
|
6
|
+
|
|
7
|
+
export const readShellFocus = (): boolean => canvas()?.dataset.shellFocus === "true";
|
|
8
|
+
|
|
9
|
+
export const setShellFocus = (hidden: boolean): void => {
|
|
10
|
+
const element = canvas();
|
|
11
|
+
if (!element) return;
|
|
12
|
+
const focused = document.activeElement;
|
|
13
|
+
document.cookie = shellFocusCookie(hidden, location.protocol === "https:");
|
|
14
|
+
if (hidden) element.dataset.shellFocus = "true";
|
|
15
|
+
else delete element.dataset.shellFocus;
|
|
16
|
+
window.dispatchEvent(new CustomEvent(SHELL_FOCUS_CHANGE_EVENT, { detail: { hidden } }));
|
|
17
|
+
if (
|
|
18
|
+
window.matchMedia("(min-width: 768px)").matches &&
|
|
19
|
+
(!focused ||
|
|
20
|
+
focused === document.body ||
|
|
21
|
+
focused.matches("[data-cloud-shell-focus-toggle]") ||
|
|
22
|
+
!focused.isConnected ||
|
|
23
|
+
focused.getClientRects().length === 0)
|
|
24
|
+
) {
|
|
25
|
+
element.querySelector<HTMLElement>(`[data-cloud-shell-focus-toggle="${hidden ? "show" : "hide"}"]`)?.focus({ preventScroll: true });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const toggleShellFocus = (): void => setShellFocus(!readShellFocus());
|
|
30
|
+
|
|
31
|
+
/** Delegation also handles the server's buttons before their islands hydrate. */
|
|
32
|
+
export const attachShellFocusControls = (): (() => void) => {
|
|
33
|
+
const click = (event: MouseEvent) => {
|
|
34
|
+
if (!(event.target instanceof Element)) return;
|
|
35
|
+
const button = event.target.closest<HTMLElement>("[data-cloud-shell-focus-toggle]");
|
|
36
|
+
if (button) setShellFocus(button.dataset.cloudShellFocusToggle === "hide");
|
|
37
|
+
};
|
|
38
|
+
let drag: { pointerId: number; x: number; y: number } | undefined;
|
|
39
|
+
const start = (event: PointerEvent) => {
|
|
40
|
+
if (event.pointerType === "mouse" || !(event.target instanceof Element) || !event.target.closest("[data-cloud-shell-reveal]")) return;
|
|
41
|
+
drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
|
42
|
+
};
|
|
43
|
+
const move = (event: PointerEvent) => {
|
|
44
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
45
|
+
const dx = event.clientX - drag.x;
|
|
46
|
+
if (dx >= 40 && dx > Math.abs(event.clientY - drag.y)) {
|
|
47
|
+
drag = undefined;
|
|
48
|
+
setShellFocus(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const stop = (event: PointerEvent) => {
|
|
52
|
+
if (event.pointerId === drag?.pointerId) drag = undefined;
|
|
53
|
+
};
|
|
54
|
+
document.addEventListener("click", click);
|
|
55
|
+
document.addEventListener("pointerdown", start);
|
|
56
|
+
window.addEventListener("pointermove", move);
|
|
57
|
+
window.addEventListener("pointerup", stop);
|
|
58
|
+
window.addEventListener("pointercancel", stop);
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener("click", click);
|
|
61
|
+
document.removeEventListener("pointerdown", start);
|
|
62
|
+
window.removeEventListener("pointermove", move);
|
|
63
|
+
window.removeEventListener("pointerup", stop);
|
|
64
|
+
window.removeEventListener("pointercancel", stop);
|
|
65
|
+
drag = undefined;
|
|
66
|
+
};
|
|
67
|
+
};
|
package/src/cli/commands.ts
CHANGED
|
@@ -244,6 +244,8 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
|
|
|
244
244
|
|
|
245
245
|
export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
|
|
246
246
|
const root = buildCommandTree(config.commands, config.groupSummaries);
|
|
247
|
+
// `cld <module> reference` prints the module's skill references instead.
|
|
248
|
+
if (root.children.has("reference")) throw new Error(`CLI module "${config.name}" must not declare the reserved command "reference"`);
|
|
247
249
|
if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
|
|
248
250
|
throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
|
|
249
251
|
}
|
package/src/cli/plugin.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cld`
|
|
3
|
-
* `cld`, which installs and loads them.
|
|
2
|
+
* Verified `cld` bundle contract shared by application-served plugins,
|
|
3
|
+
* package plugins, and `cld`, which installs and loads them.
|
|
4
4
|
*
|
|
5
5
|
* An application serves each declared CLI module under
|
|
6
6
|
* `/cli/plugins/<name>/`: `manifest.json`, the bundled module `cli.js`, and
|
|
@@ -34,9 +34,9 @@ export type CloudCliPluginManifest = {
|
|
|
34
34
|
apiVersion: number;
|
|
35
35
|
/** Module name and plugin ID. */
|
|
36
36
|
name: string;
|
|
37
|
-
/** ID of the application
|
|
37
|
+
/** ID of the serving application, or name of the publishing package. */
|
|
38
38
|
app: string;
|
|
39
|
-
/**
|
|
39
|
+
/** Application build or package version. */
|
|
40
40
|
version: string;
|
|
41
41
|
entry: typeof CLOUD_CLI_PLUGIN_ENTRY;
|
|
42
42
|
/** SHA-512 over the file list; see {@link cloudCliPluginDigest}. */
|
|
@@ -88,7 +88,7 @@ export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string
|
|
|
88
88
|
.every((segment) => !segment.startsWith("-")),
|
|
89
89
|
);
|
|
90
90
|
|
|
91
|
-
/** Validate a
|
|
91
|
+
/** Validate a bundle manifest, including its digest. Throws an `Error` naming the first problem. */
|
|
92
92
|
export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
|
|
93
93
|
if (!isRecord(value)) throw new Error("plugin manifest is not an object");
|
|
94
94
|
const { apiVersion, name, app, version, entry, digest, files } = value;
|