@k2b/cloud 0.18.0 → 0.20.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 +13 -13
- package/scripts/build.ts +3 -1
- package/scripts/sync-recovery-smoke.ts +1 -4
- package/src/_internal/define-app.ts +6 -1
- package/src/_internal/process-sync.ts +16 -8
- package/src/_internal/status-preserving-ssr.ts +9 -1
- package/src/_internal/sync-budget.ts +306 -0
- package/src/ai/code-mode-skill.ts +3 -3
- package/src/ai/html-pdf-tool.ts +2 -1
- package/src/ai/markdown-pdf-tool.ts +9 -1
- package/src/config/env.ts +5 -0
- package/src/server/locale.ts +17 -4
- package/src/server/middleware/validator.ts +23 -1
- package/src/services/gateway.ts +5 -0
- package/src/services/index.ts +2 -0
- package/src/services/pdf/gotenberg.ts +9 -2
- package/src/services/pdf/index.ts +2 -0
- package/src/services/pdf/markdown.ts +53 -13
- package/src/services/pdf/offline-html.ts +55 -7
- package/src/services/pdf/template-preview.ts +3 -1
- package/src/services/settings/core-settings.ts +2 -0
- package/src/shared/markdown/index.ts +15 -4
- package/src/ssr/LayoutHeader.tsx +20 -42
- package/src/ssr/LayoutPreferences.island.tsx +2 -17
- package/src/ssr/MinimalLayout.tsx +44 -20
- package/src/ssr/MinimalLayoutPreferences.island.tsx +48 -0
- package/src/ssr/PageError.tsx +1 -1
- package/src/ssr/profile-preferences-messages.ts +4 -0
- package/src/ssr/workspace-navigation.ts +3 -1
- package/src/styles/utilities-navigation.css +69 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": {
|
|
@@ -93,23 +93,23 @@
|
|
|
93
93
|
"@firecrawl/anydoc": "0.2.4",
|
|
94
94
|
"@napi-rs/canvas": "1.0.9",
|
|
95
95
|
"pdfjs-dist": "6.3.289",
|
|
96
|
-
"@
|
|
97
|
-
"@
|
|
98
|
-
"@tabler/icons": "3.47.0",
|
|
96
|
+
"@simplewebauthn/server": "14.0.3",
|
|
97
|
+
"@tabler/icons": "3.48.0",
|
|
99
98
|
"@tailwindcss/typography": "0.5.20",
|
|
100
99
|
"@k2b/nessi": "0.12.1",
|
|
101
100
|
"@k2b/ssr": "0.14.0",
|
|
102
|
-
"@k2b/ui": "0.
|
|
103
|
-
"@k2b/stdlib": "0.
|
|
104
|
-
"@k2b/sync": "
|
|
101
|
+
"@k2b/ui": "0.9.1",
|
|
102
|
+
"@k2b/stdlib": "0.27.0",
|
|
103
|
+
"@k2b/sync": "7.0.0",
|
|
104
|
+
"@nats-io/jetstream": "3.4.0",
|
|
105
105
|
"@nats-io/transport-node": "3.4.0",
|
|
106
|
-
"@modelcontextprotocol/sdk": "1.30.
|
|
106
|
+
"@modelcontextprotocol/sdk": "1.30.1",
|
|
107
107
|
"bun-plugin-tailwind": "0.1.2",
|
|
108
|
-
"hono-openapi": "1.3.
|
|
108
|
+
"hono-openapi": "1.3.3",
|
|
109
109
|
"jose": "6.2.12",
|
|
110
|
-
"katex": "0.18.
|
|
110
|
+
"katex": "0.18.9",
|
|
111
111
|
"liquidjs": "10.29.0",
|
|
112
|
-
"marked": "
|
|
112
|
+
"marked": "18.0.14",
|
|
113
113
|
"mermaid": "12.0.0",
|
|
114
114
|
"nodemailer": "10.0.10",
|
|
115
115
|
"postcss": "8.5.28",
|
|
@@ -129,8 +129,8 @@
|
|
|
129
129
|
"@babel/preset-typescript": "8.0.1",
|
|
130
130
|
"@types/bun": "1.4.2",
|
|
131
131
|
"babel-preset-solid": "1.9.15",
|
|
132
|
-
"hono": "4.13.
|
|
133
|
-
"playwright": "1.
|
|
132
|
+
"hono": "4.13.9",
|
|
133
|
+
"playwright": "1.63.0",
|
|
134
134
|
"solid-js": "1.9.15",
|
|
135
135
|
"typescript": "5.9.3",
|
|
136
136
|
"zod": "4.6.5"
|
package/scripts/build.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { promisify } from "node:util";
|
|
|
32
32
|
import { brotliCompress, gzip, constants as zlibConstants } from "node:zlib";
|
|
33
33
|
import { Glob } from "bun";
|
|
34
34
|
import tailwind from "bun-plugin-tailwind";
|
|
35
|
+
import type { AppCliModules } from "../src/contracts/app";
|
|
35
36
|
import { writeAppFavicon } from "./app-favicon";
|
|
36
37
|
import { buildBrowserPerformance } from "./browser-performance";
|
|
37
38
|
import { buildPdfRenderer } from "./build-pdf-renderer";
|
|
@@ -181,7 +182,8 @@ if (existsSync(appAssets)) {
|
|
|
181
182
|
// `cld` plugins: one self-contained module bundle plus references per module.
|
|
182
183
|
// Imported after the app config so the framework sees the APP_DIR set above.
|
|
183
184
|
const { buildCliPlugin, writeCliPlugin } = await import("../src/_internal/cli-plugins");
|
|
184
|
-
|
|
185
|
+
const cliModules: AppCliModules = app?.meta.cli ?? {};
|
|
186
|
+
for (const [name, declaration] of Object.entries(cliModules)) {
|
|
185
187
|
const plugin = await buildCliPlugin({ appDir, appId, name, declaration, version });
|
|
186
188
|
await writeCliPlugin(resolve(dist, "cli", name), plugin);
|
|
187
189
|
}
|
|
@@ -68,10 +68,7 @@ Run "prepare", restart the application fleet while NATS stays up, then run "reco
|
|
|
68
68
|
check(phase === "prepare" || phase === "recover", "Pass prepare or recover");
|
|
69
69
|
const namespace = options.namespace?.trim() ?? "";
|
|
70
70
|
check(/^cloud-recovery-smoke-[A-Za-z0-9_-]{6,60}$/.test(namespace), "Pass a unique --namespace cloud-recovery-smoke-<suffix>");
|
|
71
|
-
const servers =
|
|
72
|
-
.split(",")
|
|
73
|
-
.map((value) => value.trim())
|
|
74
|
-
.filter(Boolean);
|
|
71
|
+
const servers = env.NATS_SERVERS.length ? env.NATS_SERVERS : ["nats://127.0.0.1:4222"];
|
|
75
72
|
const connection = await connect({ servers, name: `${APPLICATION}-${phase}`, ignoreClusterUpdates: true });
|
|
76
73
|
const sync = createSync({ connection, namespace, application: APPLICATION, defaults: { replicas: env.SYNC_REPLICAS } });
|
|
77
74
|
const job = sync.job<{ preparedAt: string }>({
|
|
@@ -48,6 +48,7 @@ import { requireInvocation } from "../server/middleware/invocation";
|
|
|
48
48
|
import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
|
|
49
49
|
import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
|
|
50
50
|
import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
|
|
51
|
+
import { validationErrorResponse } from "../server/middleware/validator";
|
|
51
52
|
import {
|
|
52
53
|
capabilityInvocationOperation,
|
|
53
54
|
searchInvocationOperation,
|
|
@@ -341,6 +342,9 @@ export const defineApp = <
|
|
|
341
342
|
basePath: opts.basePath,
|
|
342
343
|
template: ({ body, scripts, title, description, theme, lang, performanceRoute }) => {
|
|
343
344
|
const themeFixed = theme !== undefined;
|
|
345
|
+
// The inline layer statement fixes the cascade order before any
|
|
346
|
+
// stylesheet declares a layer. Tailwind's `properties` layer resets
|
|
347
|
+
// `--tw-*` in browsers without `@property` and must stay lowest.
|
|
344
348
|
return `<!DOCTYPE html>
|
|
345
349
|
<html lang="${normalizeLocale(lang)}" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}>
|
|
346
350
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
@@ -350,7 +354,7 @@ export const defineApp = <
|
|
|
350
354
|
<meta name="theme-color" content="#09090b">
|
|
351
355
|
<meta name="mobile-web-app-capable" content="yes">
|
|
352
356
|
<link rel="icon" href="${appFaviconHref(opts.id, v)}">
|
|
353
|
-
<style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
|
|
357
|
+
<style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
|
|
354
358
|
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
355
359
|
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
356
360
|
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
@@ -771,6 +775,7 @@ export const defineApp = <
|
|
|
771
775
|
if (advertiseOpenapi) {
|
|
772
776
|
const apiPrefix = opts.openapi!.replace(/\/openapi\.json$/, "") || "/";
|
|
773
777
|
const spec = await generateSpecs(startOpts.openapi!, {
|
|
778
|
+
defaultValidationErrorResponse: validationErrorResponse,
|
|
774
779
|
documentation: {
|
|
775
780
|
info: {
|
|
776
781
|
title: meta.name,
|
|
@@ -12,6 +12,7 @@ import { createSync, type Sync } from "@k2b/sync";
|
|
|
12
12
|
import { env } from "../config/env";
|
|
13
13
|
import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
|
|
14
14
|
import { connectNats } from "./nats-connection";
|
|
15
|
+
import { withSyncBudgets } from "./sync-budget";
|
|
15
16
|
|
|
16
17
|
let current: Sync | undefined;
|
|
17
18
|
let starting = false;
|
|
@@ -64,7 +65,11 @@ export type ProcessSync = {
|
|
|
64
65
|
stop: () => Promise<void>;
|
|
65
66
|
};
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Connect NATS, create and bind the process Sync instance, and wait until it is
|
|
70
|
+
* ready. Existing streams of its jobs, queues, and topics take the byte
|
|
71
|
+
* limits they declare before first use (see `sync-budget.ts`).
|
|
72
|
+
*/
|
|
68
73
|
export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
|
|
69
74
|
if (!env.SYNC_NAMESPACE.trim()) {
|
|
70
75
|
throw new Error(
|
|
@@ -78,13 +83,16 @@ export const startProcessSync = async ({ application }: { application: string })
|
|
|
78
83
|
const connection = await connectNats({ name: `${application}@${hostname()}` });
|
|
79
84
|
let sync: Sync | undefined;
|
|
80
85
|
try {
|
|
81
|
-
sync =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
sync = withSyncBudgets(
|
|
87
|
+
createSync({
|
|
88
|
+
connection,
|
|
89
|
+
namespace: env.SYNC_NAMESPACE,
|
|
90
|
+
application,
|
|
91
|
+
defaults: { replicas: env.SYNC_REPLICAS },
|
|
92
|
+
observe: (event) => observeSyncEvent(event, application),
|
|
93
|
+
}),
|
|
94
|
+
{ connection, namespace: env.SYNC_NAMESPACE },
|
|
95
|
+
);
|
|
88
96
|
bindProcessSync(sync);
|
|
89
97
|
await sync.ready();
|
|
90
98
|
} catch (error) {
|
|
@@ -28,6 +28,9 @@ type SsrHandler<E extends Env, T extends object> = (context: Context<E & PageEnv
|
|
|
28
28
|
* (such as the request locale for `<html lang>`) are present on every SSR
|
|
29
29
|
* page without per-route plumbing. Redirects and other passthrough Responses
|
|
30
30
|
* skip it.
|
|
31
|
+
*
|
|
32
|
+
* Rendered documents default to `Cache-Control: private, no-store` unless the
|
|
33
|
+
* handler or a middleware already chose a policy.
|
|
31
34
|
*/
|
|
32
35
|
export const createStatusPreservingSsrHandler = <T extends object>(
|
|
33
36
|
html: HtmlFn<T>,
|
|
@@ -55,7 +58,12 @@ export const createStatusPreservingSsrHandler = <T extends object>(
|
|
|
55
58
|
response.headers.forEach((value, key) => {
|
|
56
59
|
headers[key] = value;
|
|
57
60
|
});
|
|
58
|
-
|
|
61
|
+
const document = context.newResponse(response.body, status as StatusCode, headers);
|
|
62
|
+
// A rendered document carries the signed-in user's state. Without an
|
|
63
|
+
// explicit policy, a browser may show it again on Back from its HTTP
|
|
64
|
+
// cache, even after sign-out. Handlers that set their own policy keep it.
|
|
65
|
+
if (!document.headers.has("Cache-Control")) document.headers.set("Cache-Control", "private, no-store");
|
|
66
|
+
return document;
|
|
59
67
|
});
|
|
60
68
|
};
|
|
61
69
|
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brings existing JetStream streams to the byte limits that the Sync jobs,
|
|
3
|
+
* queues, and topics of one Cloud process declare.
|
|
4
|
+
*
|
|
5
|
+
* JetStream reserves a stream's whole `max_bytes` on every replica as soon as
|
|
6
|
+
* the stream exists. @k2b/sync 7 declares small limits: a job or queue without
|
|
7
|
+
* `retention` holds 256 messages at its payload limit, and `deadLetterRetention`
|
|
8
|
+
* sizes dead letters apart from the work or event stream. Sync never
|
|
9
|
+
* reconfigures an existing stream, though. A stream created with other limits,
|
|
10
|
+
* such as the 1 GiB that Sync 6 gave every job and queue without `retention`,
|
|
11
|
+
* fails the declaration with `ResourceDriftError` on `max_bytes`.
|
|
12
|
+
*
|
|
13
|
+
* Before a job, queue, or topic is first used, this module therefore changes
|
|
14
|
+
* the byte limit of such a stream in place: both streams of a job or queue,
|
|
15
|
+
* and the dead-letter stream of a topic. A stream that holds more than its new
|
|
16
|
+
* limit would lose its oldest messages; it keeps its limit instead:
|
|
17
|
+
*
|
|
18
|
+
* - a job or queue is declared with the stream's current limit, so its work
|
|
19
|
+
* stays usable, and a later start applies the new limit;
|
|
20
|
+
* - a topic is declared synchronously, before its streams can be inspected,
|
|
21
|
+
* so its declaration keeps reporting the drift until the stream holds less.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
type DeadLetterStore,
|
|
25
|
+
type Job,
|
|
26
|
+
type JobConfig,
|
|
27
|
+
type Queue,
|
|
28
|
+
type QueueConfig,
|
|
29
|
+
ResourceDriftError,
|
|
30
|
+
type Sync,
|
|
31
|
+
type Topic,
|
|
32
|
+
type TopicConfig,
|
|
33
|
+
} from "@k2b/sync";
|
|
34
|
+
import { jetstreamManager, RetentionPolicy, type StreamInfo } from "@nats-io/jetstream";
|
|
35
|
+
import type { NatsConnection } from "@nats-io/transport-node";
|
|
36
|
+
import { logger } from "../services/logging";
|
|
37
|
+
|
|
38
|
+
/** Sync 7's limits for a job or queue declared without `retention`, which @k2b/sync does not export. */
|
|
39
|
+
const SYNC_PAYLOAD_BYTES = 128 * 1024;
|
|
40
|
+
const SYNC_DEAD_LETTER_HEADROOM_BYTES = 4096;
|
|
41
|
+
const SYNC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/** Byte limit Sync 7 gives a job or queue without `retention`: 256 messages at its payload limit, at most 1 GiB. */
|
|
44
|
+
export const syncDefaultMaxBytes = (maxPayloadBytes = SYNC_PAYLOAD_BYTES): number =>
|
|
45
|
+
Math.min(1024 ** 3, 256 * (maxPayloadBytes + SYNC_DEAD_LETTER_HEADROOM_BYTES));
|
|
46
|
+
|
|
47
|
+
const log = logger("sync:budget");
|
|
48
|
+
|
|
49
|
+
type Kind = "job" | "queue" | "topic";
|
|
50
|
+
type Limits = { work: number; deadLetters: number };
|
|
51
|
+
|
|
52
|
+
/** Memoizes a promise, forgetting a rejection so the next call retries. */
|
|
53
|
+
const once = <T>(create: () => Promise<T>): (() => Promise<T>) => {
|
|
54
|
+
let pending: Promise<T> | undefined;
|
|
55
|
+
return () =>
|
|
56
|
+
(pending ??= create().catch((error: unknown) => {
|
|
57
|
+
pending = undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The stream and declared byte limit when Sync refused a declaration only for an existing stream's byte limit. */
|
|
63
|
+
const byteLimitDrift = (error: unknown): { stream: string; maxBytes: number } | undefined => {
|
|
64
|
+
if (!(error instanceof ResourceDriftError) || error.differences.length !== 1) return undefined;
|
|
65
|
+
const [difference] = error.differences;
|
|
66
|
+
if (difference?.field !== "max_bytes" || typeof difference.declared !== "number") return undefined;
|
|
67
|
+
return { stream: error.resource, maxBytes: difference.declared };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Byte limits a job or queue declares for its work and dead-letter streams. */
|
|
71
|
+
const declaredLimits = (config: QueueConfig | JobConfig): Limits => {
|
|
72
|
+
const work = config.retention?.maxBytes ?? syncDefaultMaxBytes(config.maxPayloadBytes);
|
|
73
|
+
return { work, deadLetters: config.deadLetterRetention?.maxBytes ?? work };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The declaration with the given limits, or unchanged when they are its own. */
|
|
77
|
+
const withLimits = <Config extends QueueConfig | JobConfig>(config: Config, target: Limits, limits: Limits): Config =>
|
|
78
|
+
limits.work === target.work && limits.deadLetters === target.deadLetters
|
|
79
|
+
? config
|
|
80
|
+
: {
|
|
81
|
+
...config,
|
|
82
|
+
retention: { maxAgeMs: SYNC_MAX_AGE_MS, ...config.retention, maxBytes: limits.work },
|
|
83
|
+
deadLetterRetention: { ...config.deadLetterRetention, maxBytes: limits.deadLetters },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const createStreamLimits = (connection: NatsConnection, namespace: string) => {
|
|
87
|
+
const manager = once(() => jetstreamManager(connection));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Work and dead-letter streams per job and queue of this namespace as they
|
|
91
|
+
* are now. Concurrent callers share one listing; a later call lists again,
|
|
92
|
+
* so streams that another process created or changed since are seen.
|
|
93
|
+
*/
|
|
94
|
+
let listing: Promise<Map<string, StreamInfo[]>> | undefined;
|
|
95
|
+
const list = () =>
|
|
96
|
+
(listing ??= (async () => {
|
|
97
|
+
const jsm = await manager();
|
|
98
|
+
const streams = new Map<string, StreamInfo[]>();
|
|
99
|
+
for await (const info of jsm.streams.list()) {
|
|
100
|
+
const metadata = info.config.metadata;
|
|
101
|
+
const kind = metadata?.["sync.kind"];
|
|
102
|
+
if (metadata?.["sync.namespace"] !== namespace || metadata["sync.managed"] !== "true") continue;
|
|
103
|
+
// A job's coalescing claims live in a KV bucket without a byte limit.
|
|
104
|
+
if ((kind !== "job" && kind !== "queue") || info.config.name.startsWith("KV_")) continue;
|
|
105
|
+
const key = `${kind}:${metadata["sync.id"]}`;
|
|
106
|
+
streams.set(key, [...(streams.get(key) ?? []), info]);
|
|
107
|
+
}
|
|
108
|
+
return streams;
|
|
109
|
+
})().finally(() => {
|
|
110
|
+
listing = undefined;
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Limits a job or queue declares: its own, except for an existing stream
|
|
115
|
+
* that holds more than its new limit, which keeps its current limit.
|
|
116
|
+
*/
|
|
117
|
+
const keep = async (kind: Kind, id: string, target: Limits): Promise<Limits> => {
|
|
118
|
+
const streams = (await list()).get(`${kind}:${id}`) ?? [];
|
|
119
|
+
const kept = (info: StreamInfo | undefined, maxBytes: number): number => {
|
|
120
|
+
if (!info || info.config.max_bytes === maxBytes || info.state.bytes <= maxBytes) return maxBytes;
|
|
121
|
+
log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
|
|
122
|
+
kind,
|
|
123
|
+
id,
|
|
124
|
+
stream: info.config.name,
|
|
125
|
+
limit: info.config.max_bytes,
|
|
126
|
+
target: maxBytes,
|
|
127
|
+
held: info.state.bytes,
|
|
128
|
+
});
|
|
129
|
+
return info.config.max_bytes;
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
work: kept(
|
|
133
|
+
streams.find((info) => info.config.retention === RetentionPolicy.Workqueue),
|
|
134
|
+
target.work,
|
|
135
|
+
),
|
|
136
|
+
deadLetters: kept(
|
|
137
|
+
streams.find((info) => info.config.retention !== RetentionPolicy.Workqueue),
|
|
138
|
+
target.deadLetters,
|
|
139
|
+
),
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Streams reported as too full to lower; a topic retries on every use, but logs once. */
|
|
144
|
+
const reported = new Set<string>();
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Provisions a declaration. Each existing stream whose byte limit differs
|
|
148
|
+
* from the declared one gets the declared limit if it holds no more; then
|
|
149
|
+
* the declaration is tried again. Each stream is changed at most once. Of a
|
|
150
|
+
* topic, only the dead-letter stream, whose subjects end in `.dlq.>`, is
|
|
151
|
+
* changed: its event stream keeps Sync's drift check, so applications that
|
|
152
|
+
* declare one topic differently still fail instead of overwriting each
|
|
153
|
+
* other's limit.
|
|
154
|
+
*/
|
|
155
|
+
const adopt = async (kind: Kind, id: string, ready: () => Promise<void>): Promise<void> => {
|
|
156
|
+
const changed = new Set<string>();
|
|
157
|
+
for (;;) {
|
|
158
|
+
try {
|
|
159
|
+
return await ready();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const drift = byteLimitDrift(error);
|
|
162
|
+
if (!drift || changed.has(drift.stream)) throw error;
|
|
163
|
+
changed.add(drift.stream);
|
|
164
|
+
const jsm = await manager();
|
|
165
|
+
const info = await jsm.streams.info(drift.stream);
|
|
166
|
+
if (kind === "topic" && !info.config.subjects.some((subject) => subject.endsWith(".dlq.>"))) throw error;
|
|
167
|
+
if (info.state.bytes > drift.maxBytes) {
|
|
168
|
+
if (reported.has(drift.stream)) throw error;
|
|
169
|
+
reported.add(drift.stream);
|
|
170
|
+
log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
|
|
171
|
+
kind,
|
|
172
|
+
id,
|
|
173
|
+
stream: drift.stream,
|
|
174
|
+
limit: info.config.max_bytes,
|
|
175
|
+
target: drift.maxBytes,
|
|
176
|
+
held: info.state.bytes,
|
|
177
|
+
});
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
await jsm.streams.update(drift.stream, { max_bytes: drift.maxBytes });
|
|
181
|
+
log.info("Changed the byte limit of a Sync stream", {
|
|
182
|
+
kind,
|
|
183
|
+
id,
|
|
184
|
+
stream: drift.stream,
|
|
185
|
+
from: info.config.max_bytes,
|
|
186
|
+
to: drift.maxBytes,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return { keep, adopt };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const deferDeadLetters = <T>(resolve: () => Promise<{ deadLetters: DeadLetterStore<T> }>): DeadLetterStore<T> => ({
|
|
196
|
+
page: async (options) => (await resolve()).deadLetters.page(options),
|
|
197
|
+
get: async (input) => (await resolve()).deadLetters.get(input),
|
|
198
|
+
list: async (options) => (await resolve()).deadLetters.list(options),
|
|
199
|
+
requeue: async (input) => (await resolve()).deadLetters.requeue(input),
|
|
200
|
+
delete: async (input) => (await resolve()).deadLetters.delete(input),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const deferJob = <Input>(resolve: () => Promise<Job<Input>>): Job<Input> => ({
|
|
204
|
+
ready: async () => (await resolve()).ready(),
|
|
205
|
+
submit: async (job) => (await resolve()).submit(job),
|
|
206
|
+
submitMany: async (jobs, options) => (await resolve()).submitMany(jobs, options),
|
|
207
|
+
submitBatch: async (jobs) => (await resolve()).submitBatch(jobs),
|
|
208
|
+
pause: async (options) => (await resolve()).pause(options),
|
|
209
|
+
resume: async () => (await resolve()).resume(),
|
|
210
|
+
process: async (options, handler) => (await resolve()).process(options, handler),
|
|
211
|
+
deadLetters: deferDeadLetters(resolve),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const deferQueue = <T>(resolve: () => Promise<Queue<T>>): Queue<T> => ({
|
|
215
|
+
ready: async () => (await resolve()).ready(),
|
|
216
|
+
send: async (message) => (await resolve()).send(message),
|
|
217
|
+
sendBatch: async (messages) => (await resolve()).sendBatch(messages),
|
|
218
|
+
pause: async (options) => (await resolve()).pause(options),
|
|
219
|
+
resume: async () => (await resolve()).resume(),
|
|
220
|
+
process: async (options, handler) => (await resolve()).process(options, handler),
|
|
221
|
+
reader: async (options) => (await resolve()).reader(options),
|
|
222
|
+
deadLetters: deferDeadLetters(resolve),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** A declared topic whose every provisioning use waits until `ready` has adopted its streams. */
|
|
226
|
+
const deferTopic = <T>(topic: Topic<T>, ready: () => Promise<void>): Topic<T> => {
|
|
227
|
+
const whenReady = <Result>(use: () => Promise<Result>): Promise<Result> => ready().then(use);
|
|
228
|
+
const iterateWhenReady = async function* <Event>(open: () => AsyncIterable<Event>): AsyncIterable<Event> {
|
|
229
|
+
await ready();
|
|
230
|
+
yield* open();
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
ready,
|
|
234
|
+
publish: (input) => whenReady(() => topic.publish(input)),
|
|
235
|
+
publishBatch: (input) => whenReady(() => topic.publishBatch(input)),
|
|
236
|
+
hub: (options) => {
|
|
237
|
+
const hub = topic.hub(options);
|
|
238
|
+
return { subscribe: (subscription) => iterateWhenReady(() => hub.subscribe(subscription)), close: () => hub.close() };
|
|
239
|
+
},
|
|
240
|
+
cursorSequence: (cursor) => topic.cursorSequence(cursor),
|
|
241
|
+
cursorAt: (sequence) => topic.cursorAt(sequence),
|
|
242
|
+
pauseConsumer: (input) => whenReady(() => topic.pauseConsumer(input)),
|
|
243
|
+
resumeConsumer: (input) => whenReady(() => topic.resumeConsumer(input)),
|
|
244
|
+
latestCursor: (options) => whenReady(() => topic.latestCursor(options)),
|
|
245
|
+
head: () => whenReady(() => topic.head()),
|
|
246
|
+
live: (options) => iterateWhenReady(() => topic.live(options)),
|
|
247
|
+
replay: (options) => iterateWhenReady(() => topic.replay(options)),
|
|
248
|
+
follow: (options) => iterateWhenReady(() => topic.follow(options)),
|
|
249
|
+
destroy: () => topic.destroy(),
|
|
250
|
+
process: (options, handler) => whenReady(() => topic.process(options, handler)),
|
|
251
|
+
deadLetters: {
|
|
252
|
+
list: (options) => whenReady(() => topic.deadLetters.list(options)),
|
|
253
|
+
get: (input) => whenReady(() => topic.deadLetters.get(input)),
|
|
254
|
+
delete: (input) => whenReady(() => topic.deadLetters.delete(input)),
|
|
255
|
+
replay: (input) => whenReady(() => topic.deadLetters.replay(input)),
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The process Sync whose jobs, queues, and topics adopt the byte limits of
|
|
262
|
+
* their existing streams before first use. A job or queue is declared to Sync
|
|
263
|
+
* on its first use or on `ready()`; a topic is declared at once. Every other
|
|
264
|
+
* primitive is Sync's own.
|
|
265
|
+
*/
|
|
266
|
+
export const withSyncBudgets = (sync: Sync, { connection, namespace }: { connection: NatsConnection; namespace: string }): Sync => {
|
|
267
|
+
const { keep, adopt } = createStreamLimits(connection, namespace);
|
|
268
|
+
/** Every job, queue, and topic declared so far, so `ready()` provisions them like Sync does. */
|
|
269
|
+
const declared = new Map<string, () => Promise<unknown>>();
|
|
270
|
+
|
|
271
|
+
const declareWork = <Config extends QueueConfig | JobConfig, Handle extends { ready(): Promise<void> }>(
|
|
272
|
+
kind: "job" | "queue",
|
|
273
|
+
config: Config,
|
|
274
|
+
create: (config: Config) => Handle,
|
|
275
|
+
): (() => Promise<Handle>) => {
|
|
276
|
+
// Declared once: Sync refuses a second declaration with other limits, so a
|
|
277
|
+
// failed first use retries provisioning with the limits chosen first.
|
|
278
|
+
const declaration = once(async () => {
|
|
279
|
+
const target = declaredLimits(config);
|
|
280
|
+
return create(withLimits(config, target, await keep(kind, config.id, target)));
|
|
281
|
+
});
|
|
282
|
+
const resolve = once(async () => {
|
|
283
|
+
const handle = await declaration();
|
|
284
|
+
await adopt(kind, config.id, () => handle.ready());
|
|
285
|
+
return handle;
|
|
286
|
+
});
|
|
287
|
+
declared.set(`${kind}:${config.id}`, resolve);
|
|
288
|
+
return resolve;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
...sync,
|
|
293
|
+
ready: async () => {
|
|
294
|
+
await Promise.all([...declared.values()].map((resolve) => resolve()));
|
|
295
|
+
await sync.ready();
|
|
296
|
+
},
|
|
297
|
+
job: <Input>(config: JobConfig) => deferJob(declareWork("job", config, (declaration) => sync.job<Input>(declaration))),
|
|
298
|
+
queue: <T>(config: QueueConfig) => deferQueue(declareWork("queue", config, (declaration) => sync.queue<T>(declaration))),
|
|
299
|
+
topic: <T>(config: TopicConfig) => {
|
|
300
|
+
const topic = sync.topic<T>(config);
|
|
301
|
+
const ready = once(() => adopt("topic", config.id, () => topic.ready()));
|
|
302
|
+
declared.set(`topic:${config.id}`, ready);
|
|
303
|
+
return deferTopic(topic, ready);
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
};
|
|
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
|
|
|
4
4
|
|
|
5
5
|
export const ASSISTANT_CODE_MODE_SKILL = {
|
|
6
6
|
"key": "assistant:code-mode",
|
|
7
|
-
"version":
|
|
7
|
+
"version": 56,
|
|
8
8
|
"name": "assistant-code-mode",
|
|
9
9
|
"description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
|
|
10
10
|
"instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, save one in Files, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
|
|
@@ -56,7 +56,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"path": "references/einvoice.md",
|
|
59
|
-
"content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes
|
|
59
|
+
"content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes, self-billing\nand self-billed credit notes, VAT categories S/Z/E/AE/K/G/O, units\nC62/HUR/DAY/KGM, and payment by credit transfer, cash, online service or\nclearing, or no payment means. Generation does not support UBL, XRechnung,\ndiscounts or prepayments. Readers preserve declared totals; parsing is not\narithmetic verification. Validation is not XSD or Schematron certification.\nNo XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; id?: string; vatId: string; // \"\" when the party has no VAT ID\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype Tax = {\n taxCategory?: \"S\" | \"Z\" | \"E\" | \"AE\" | \"K\" | \"G\" | \"O\"; // default \"S\"\n taxRate: string; taxExemptionReason?: string; taxExemptionReasonCode?: string;\n};\ntype InvoiceLine = Tax & {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: (Tax & {netAmount: string; taxAmount: string})[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\" | \"selfBillingCreditNote\";\n number: string; invoiceDate: string; dueDate: string;\n serviceDate?: string; period?: {startDate?: string; endDate?: string};\n currency: \"EUR\"; seller: Party & {taxRegistrationId?: string}; buyer: Party;\n deliverToCountryCode?: string; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment?: {\n typeCode?: \"10\" | \"30\" | \"58\" | \"68\" | \"97\"; // default \"58\"\n information?: string; iban?: string; accountName?: string;\n };\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\nA parsed invoice carries `serviceDate`, `period`, `payment` and the account\nfields only when the XML does: check each before reading it, for example\n`invoice.payment?.iban`. A parsed `payment` always has its `typeCode`.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n `serviceDate` (delivery date) and `period` (invoicing period) are optional\n and can be combined. A `period` needs a start or an end, and its end cannot\n precede its start.\n- `creditNote` and `selfBillingCreditNote` require `precedingInvoice`; every\n kind may supply it, and its date cannot be later than `invoiceDate`.\n Credit-note amounts stay unsigned.\n- `payment.typeCode`: `\"58\"` SEPA credit transfer, `\"30\"` credit transfer,\n `\"10\"` cash, `\"68\"` online payment service, `\"97\"` clearing between\n partners. 30 and 58 require `iban`; `accountName` is optional. The other\n codes forbid both. `information` is free text for any code. Omit `payment`\n when no payment means applies. Any other code fails both writing and the\n default reader; read such invoices with `{mode: \"incoming\"}`.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. A supplied `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description, `payment.information` and each note 4000; at most 100 notes.\n- Category S needs a positive rate; every other category uses `taxRate: \"0\"`\n and zero tax. E/AE/K/G/O need `taxExemptionReason` or a VATEX\n `taxExemptionReasonCode`; S/Z forbid both. O cannot be mixed with other\n categories and requires `vatId: \"\"` for both parties. A seller without a VAT\n ID needs `seller.id` and, outside O, `seller.taxRegistrationId`. AE/K need a\n buyer VAT ID, K/G a seller VAT ID, and K `deliverToCountryCode` plus a\n `serviceDate` or `period`.\n- `calculate` rounds each line half up to cents, then VAT per category and rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Example invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture is a credit-transfer invoice with a delivery date; it is not a\ndocument to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, tax category, exemption reason, delivery\ndate, account or business reference merely to satisfy input validation.\n\n## Reading received invoices\n\n`{mode: \"incoming\"}` (plus the same limits) reads a broader separate model:\nalso XRechnung 3.0/2.3 CII, other currencies, discounts, prepayments, all\npayment means and optional references. `data` is\n`{format: \"cii-en16931\", profile, xml, invoice, unmapped, filename?}`.\nAmounts are declared strings, never recalculated; O lines have no `taxRate`.\n`unmapped` lists supplementary XML elements and attributes with their paths; review it before\naccounting. Do not pass this `invoice` to `validate` or `serialize`.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "references/examples.md",
|
|
@@ -88,7 +88,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"path": "references/pdf.md",
|
|
91
|
-
"content": "# Generate PDFs\n\n`pdf.render`, `pdf.attach` and `pdf.facturX` are asynchronous and return a PDF\n`Blob`. They use the instance's configured PDF service. `pdf.open` is\nthe local text reader described in [Documents](documents.md).\n\n## Choose the path\n\nA document written in the chat needs no code. Use the chat tool\n`markdown_to_pdf` for text-first documents; it applies A4 presets and custom CSS\nand turns images into links. Use `html_to_pdf` for a chat `.html` file whose\nlayout needs HTML and CSS, images, or fonts. It takes optional CSS (file or\ninline), header and footer files, chat files as named assets, and the `page`\noptions below, then writes a sibling `.pdf` for `present`. Use `pdf.render` when\ncode builds the document from data, for Factur-X or attachments, and in Studio\nApps.\n\n## HTML and CSS\n\n```js\nconst document = await pdf.render({\n html: `<!doctype html><html><head><style>\n body { font-family: sans-serif; }\n h1 { color: #087f70; }\n tr { break-inside: avoid; }\n </style></head><body><h1>Stock report</h1><img src=\"logo.png\"></body></html>`,\n assets: [{ name: \"logo.png\", data: logoFile }],\n page: { format: \"A4\", landscape: false, margin: { top: 15, right: 15, bottom: 15, left: 15 } },\n tagged: true,\n});\nawait files.save(document, \"stock-report.pdf\");\n```\n\n`html` is required. `assets` defaults to an empty array and accepts named `Blob`\nvalues for local images, fonts and CSS. Use plain filenames, no directories;\nreference the exact filename from HTML or CSS. Duplicate names and the reserved\nnames `index.html`, `header.html`, `footer.html`, `factur-x.xml` fail.\n`headerHtml` and `footerHtml` are optional independent HTML strings with their own\nCSS. They load no assets; use `data:` URLs for images there. Page markers such as\n`<span class=\"pageNumber\"></span>` work in those templates, and the page margin\nmust leave room for them. Background colors are printed.\n\n`page.format` defaults to `A4`; alternatives are `A3`, `A5`, `Letter`, and `Legal`.\n`landscape` defaults to false. Each margin is a nonnegative millimeter number,\ndefaulting to 15. Use `page` for paper dimensions and margins; avoid conflicting\nCSS `@page` rules. `tagged` defaults to true, which requests a tagged PDF but does\nnot certify accessibility.\n\nStudio styles are not inherited. Scripts, redirects, frames and outbound\nresources are blocked. MathML (`math`) and the SVG elements `foreignObject` and\n`desc` are removed; write formulas and labels as HTML and CSS or as SVG text.\nSupply local assets or data URLs; this is not a URL-to-PDF browser or a\nJavaScript rendering environment.\n\n## Attach files\n\n```js\nconst result = await pdf.attach({\n document,\n attachments: [{\n name: \"details.xml\",\n data: new Blob([xml], { type: \"application/xml\" }),\n relationship: \"Data\",\n }],\n});\nawait files.shared.write(\"reports/with-details.pdf\", result);\n```\n\nThe source PDF and attachments are ordinary `Blob`s. Their origin does not\nmatter: explicit picker selections, authorized chat inputs, or app storage use\nthe same API. `relationship` defaults to `Unspecified`; alternatives are\n`Source`, `Data`, `Alternative`, and `Supplement`. MIME type comes from the Blob\nand defaults to `application/octet-stream` if empty. Provide at least one attachment. Names within the request\nmust be unique. All asset/attachment names are 1–180 characters, with no slash,\nbackslash or control characters, and cannot be `.` or `..`. Embedding an XML file alone does not create a compliant invoice.\n\n## Factur-X / ZUGFeRD\n\n```js\nconst checked = einvoice.validate(invoice);\nif (!checked.ok) throw new Error(JSON.stringify(checked.error));\nconst xml = einvoice.serialize(checked.data, { format: \"zugferd-2.5-en16931\" });\nif (!xml.ok) throw new Error(JSON.stringify(xml.error));\nconst document = await pdf.facturX({\n html: invoiceHtml,\n xml: xml.data.xml,\n profile: \"EN 16931\",\n});\nawait files.save(document, \"invoice.pdf\");\n```\n\n`facturX` accepts the same render options plus required `xml` and `profile`.\nProfiles: `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`. Use `EN 16931`\nwith the bundled `einvoice.serialize` output; that serializer does not support\nthe other profiles. The service embeds `factur-x.xml`, sets Factur-X 1.0 invoice\nmetadata and requests PDF/A-3b. The app must supply matching HTML and XML.\nNeither rendering nor parsing certifies XSD, Schematron, tax or invoice validity.\n\n## Save a PDF in Files\n\nA chat PDF stays in the chat until code writes it elsewhere. To save it in the\nuser's Files, pass its chat path in `code_run.inputPaths` and write it through\nthe discovered `filesv2.content.create` action:\n\n```js\nexport default async () => {\n const document = await files.read(\"/offer.pdf\");\n const target = await capabilities.run(\"filesv2.content.create\", {\n baseId: \"<exact ID from filesv2.bases.list>\",\n path: \"Offers/offer.pdf\",\n size: document.size,\n mediaType: \"application/pdf\",\n });\n return capabilities.streams.write(target.stream, document);\n};\n```\n\nAsk for the storage base and folder when the request does not name them. The user\nreviews the write. It creates a new file and fails when the path exists;\nreplacing requires the current `expectedRevision`. A `pdf.render` result can be\nwritten the same way without saving it to the chat first. See\n[Capability calls](capabilities.md) for stream limits and interrupted writes.\n\n## Cancellation, access and limits\n\nAll three methods accept a second `{ signal }` argument, for example the signal\nfrom a `work.run` job. Abort rejects with `AbortError`. Stopping the execution\nhost also cancels pending PDF requests. Rendering creates no stored file until\ncode explicitly saves it; do not automatically retry failed calls.\n\nSaved resources need Use access, not Manage. One-off scripts need an accessible,\nunrestricted current chat. The server checks access before reading the body.\nNo service URL, credentials, shell flags or arbitrary conversion route are\nexposed to app code. This is an internal conversion, not `http.fetch`; there is\nno external API approval prompt.\n\nConfigured service input, output and timeout limits apply. HTML, its headers,\nfooters, assets and invoice XML share the HTML input budget. PDF attachments\nand the source PDF share the PDF input budget. All transfers also have a 64 MiB\nceiling; multipart framing has a separate bounded overhead. Shared storage and\nchat export budgets remain independent. Errors include `PDF_NOT_CONFIGURED`,\n`PDF_LIMIT`, `PDF_TIMEOUT`, `PDF_FAILED`, `INVALID_INPUT`, and `ACCESS_DENIED`.\n"
|
|
91
|
+
"content": "# Generate PDFs\n\n`pdf.render`, `pdf.attach` and `pdf.facturX` are asynchronous and return a PDF\n`Blob`. They use the instance's configured PDF service. `pdf.open` is\nthe local text reader described in [Documents](documents.md).\n\n## Choose the path\n\nA document written in the chat needs no code. Use the chat tool\n`markdown_to_pdf` for text-first documents; it applies A4 presets and custom CSS\nand turns images into links. Use `html_to_pdf` for a chat `.html` file whose\nlayout needs HTML and CSS, images, or fonts. It takes optional CSS (file or\ninline), header and footer files, chat files as named assets, and the `page`\noptions below, then writes a sibling `.pdf` for `present`. Use `pdf.render` when\ncode builds the document from data, for Factur-X or attachments, and in Studio\nApps.\n\n## HTML and CSS\n\n```js\nconst document = await pdf.render({\n html: `<!doctype html><html><head><title>Stock report</title><style>\n body { font-family: sans-serif; }\n h1 { color: #087f70; }\n tr { break-inside: avoid; }\n </style></head><body><h1>Stock report</h1><img src=\"logo.png\"></body></html>`,\n assets: [{ name: \"logo.png\", data: logoFile }],\n page: { format: \"A4\", landscape: false, margin: { top: 15, right: 15, bottom: 15, left: 15 } },\n tagged: true,\n});\nawait files.save(document, \"stock-report.pdf\");\n```\n\n`html` is required. Give it a `<title>`: PDF viewers show it as the document\nname, and without one they show a random file name. `assets` defaults to an empty array and accepts named `Blob`\nvalues for local images, fonts and CSS. Use plain filenames, no directories;\nreference the exact filename from HTML or CSS. Duplicate names and the reserved\nnames `index.html`, `header.html`, `footer.html`, `factur-x.xml` fail.\n`headerHtml` and `footerHtml` are optional independent HTML strings with their own\nCSS. They load no assets; use `data:` URLs for images there. Page markers such as\n`<span class=\"pageNumber\"></span>` work in those templates, and the page margin\nmust leave room for them. Background colors are printed.\n\n`page.format` defaults to `A4`; alternatives are `A3`, `A5`, `Letter`, and `Legal`.\n`landscape` defaults to false. Each margin is a nonnegative millimeter number,\ndefaulting to 15. Use `page` for paper dimensions and margins; avoid conflicting\nCSS `@page` rules. `tagged` defaults to true, which requests a tagged PDF but does\nnot certify accessibility.\n\nStudio styles are not inherited. Scripts, redirects, frames and outbound\nresources are blocked. MathML (`math`) and the SVG elements `foreignObject` and\n`desc` are removed; write formulas and labels as HTML and CSS or as SVG text.\nSupply local assets or data URLs; this is not a URL-to-PDF browser or a\nJavaScript rendering environment.\n\n## Attach files\n\n```js\nconst result = await pdf.attach({\n document,\n attachments: [{\n name: \"details.xml\",\n data: new Blob([xml], { type: \"application/xml\" }),\n relationship: \"Data\",\n }],\n});\nawait files.shared.write(\"reports/with-details.pdf\", result);\n```\n\nThe source PDF and attachments are ordinary `Blob`s. Their origin does not\nmatter: explicit picker selections, authorized chat inputs, or app storage use\nthe same API. `relationship` defaults to `Unspecified`; alternatives are\n`Source`, `Data`, `Alternative`, and `Supplement`. MIME type comes from the Blob\nand defaults to `application/octet-stream` if empty. Provide at least one attachment. Names within the request\nmust be unique. All asset/attachment names are 1–180 characters, with no slash,\nbackslash or control characters, and cannot be `.` or `..`. Embedding an XML file alone does not create a compliant invoice.\n\n## Factur-X / ZUGFeRD\n\n```js\nconst checked = einvoice.validate(invoice);\nif (!checked.ok) throw new Error(JSON.stringify(checked.error));\nconst xml = einvoice.serialize(checked.data, { format: \"zugferd-2.5-en16931\" });\nif (!xml.ok) throw new Error(JSON.stringify(xml.error));\nconst document = await pdf.facturX({\n html: invoiceHtml,\n xml: xml.data.xml,\n profile: \"EN 16931\",\n});\nawait files.save(document, \"invoice.pdf\");\n```\n\n`facturX` accepts the same render options plus required `xml` and `profile`.\nProfiles: `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`. Use `EN 16931`\nwith the bundled `einvoice.serialize` output; that serializer does not support\nthe other profiles. The service embeds `factur-x.xml`, sets Factur-X 1.0 invoice\nmetadata and requests PDF/A-3b. The app must supply matching HTML and XML.\nNeither rendering nor parsing certifies XSD, Schematron, tax or invoice validity.\n\n## Save a PDF in Files\n\nA chat PDF stays in the chat until code writes it elsewhere. To save it in the\nuser's Files, pass its chat path in `code_run.inputPaths` and write it through\nthe discovered `filesv2.content.create` action:\n\n```js\nexport default async () => {\n const document = await files.read(\"/offer.pdf\");\n const target = await capabilities.run(\"filesv2.content.create\", {\n baseId: \"<exact ID from filesv2.bases.list>\",\n path: \"Offers/offer.pdf\",\n size: document.size,\n mediaType: \"application/pdf\",\n });\n return capabilities.streams.write(target.stream, document);\n};\n```\n\nAsk for the storage base and folder when the request does not name them. The user\nreviews the write. It creates a new file and fails when the path exists;\nreplacing requires the current `expectedRevision`. A `pdf.render` result can be\nwritten the same way without saving it to the chat first. See\n[Capability calls](capabilities.md) for stream limits and interrupted writes.\n\n## Cancellation, access and limits\n\nAll three methods accept a second `{ signal }` argument, for example the signal\nfrom a `work.run` job. Abort rejects with `AbortError`. Stopping the execution\nhost also cancels pending PDF requests. Rendering creates no stored file until\ncode explicitly saves it; do not automatically retry failed calls.\n\nSaved resources need Use access, not Manage. One-off scripts need an accessible,\nunrestricted current chat. The server checks access before reading the body.\nNo service URL, credentials, shell flags or arbitrary conversion route are\nexposed to app code. This is an internal conversion, not `http.fetch`; there is\nno external API approval prompt.\n\nConfigured service input, output and timeout limits apply. HTML, its headers,\nfooters, assets and invoice XML share the HTML input budget. PDF attachments\nand the source PDF share the PDF input budget. All transfers also have a 64 MiB\nceiling; multipart framing has a separate bounded overhead. Shared storage and\nchat export budgets remain independent. Errors include `PDF_NOT_CONFIGURED`,\n`PDF_LIMIT`, `PDF_TIMEOUT`, `PDF_FAILED`, `INVALID_INPUT`, and `ACCESS_DENIED`.\n"
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
"path": "references/publishing.md",
|
package/src/ai/html-pdf-tool.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies
|
|
|
100
100
|
return defineAiTool({
|
|
101
101
|
name: "html_to_pdf",
|
|
102
102
|
description:
|
|
103
|
-
'Convert one assistant-created conversation HTML file to a sibling PDF; the output path replaces .html with .pdf. Use it when the layout needs HTML and CSS, such as columns, exact tables, letterheads, invoices, certificates, images, or custom fonts; use markdown_to_pdf for text-first documents. Write the .html source with write_file first, appending long files in parts; CSS, header, footer, and asset files can be any conversation files, including uploads. Rendering is offline without JavaScript: scripts, frames, and remote URLs are removed or blocked, and MathML and the SVG foreignObject and desc elements are removed, so write formulas and labels as HTML and CSS or as SVG text. Embed images and fonts as data: URLs, or list conversation files in assets and reference each by its file name only, for example <img src="logo.png"> or url("brand.woff2"); percent-encode #, ?, %, and : in a name, such as logo%232.png for logo#2.png. cssPath and customCss apply after the document\'s own styles. page sets paper size (default A4), orientation, and millimeter margins (default 15); CSS @page sizes are ignored. Header and footer files are small separate HTML documents with their own inline styles; they load no assets, so use data: URLs for images there. <span class="pageNumber"></span> and <span class="totalPages"></span> print page numbers, and the top or bottom margin must leave room for them. All input files share the configured PDF input budget. Call present with the returned path. To also save the PDF in Files, follow the PDF reference of the assistant-code-mode skill.',
|
|
103
|
+
'Convert one assistant-created conversation HTML file to a sibling PDF; the output path replaces .html with .pdf. Use it when the layout needs HTML and CSS, such as columns, exact tables, letterheads, invoices, certificates, images, or custom fonts; use markdown_to_pdf for text-first documents. Write the .html source with write_file first, appending long files in parts; CSS, header, footer, and asset files can be any conversation files, including uploads. Rendering is offline without JavaScript: scripts, frames, and remote URLs are removed or blocked, and MathML and the SVG foreignObject and desc elements are removed, so write formulas and labels as HTML and CSS or as SVG text. Embed images and fonts as data: URLs, or list conversation files in assets and reference each by its file name only, for example <img src="logo.png"> or url("brand.woff2"); percent-encode #, ?, %, and : in a name, such as logo%232.png for logo#2.png. cssPath and customCss apply after the document\'s own styles. page sets paper size (default A4), orientation, and millimeter margins (default 15); CSS @page sizes are ignored. Header and footer files are small separate HTML documents with their own inline styles; they load no assets, so use data: URLs for images there. <span class="pageNumber"></span> and <span class="totalPages"></span> print page numbers, and the top or bottom margin must leave room for them. The HTML <title> names the PDF in viewers; without one, the file name does. All input files share the configured PDF input budget. Call present with the returned path. To also save the PDF in Files, follow the PDF reference of the assistant-code-mode skill.',
|
|
104
104
|
inputSchema: CloudAiHtmlToPdfInputSchema,
|
|
105
105
|
outputSchema: CloudAiHtmlToPdfOutputSchema,
|
|
106
106
|
approval: "never",
|
|
@@ -158,6 +158,7 @@ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies
|
|
|
158
158
|
// Like Code Mode, the document renders in standards mode. Separate CSS
|
|
159
159
|
// follows the document, so it wins over the document's own styles.
|
|
160
160
|
html: `<!doctype html>${source}${css ? `\n<style>\n${css}\n</style>\n` : ""}`,
|
|
161
|
+
title: basename(sourcePath).replace(HTML_EXTENSION, ""),
|
|
161
162
|
headerHtml,
|
|
162
163
|
footerHtml,
|
|
163
164
|
assets,
|