@k2b/cloud 0.46.0 → 0.48.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 +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-access-manager-sql.ts +10 -0
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +8 -0
- package/src/_internal/define-app.ts +69 -2
- package/src/_internal/heartbeat.ts +21 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +15 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +154 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/api/admin-app-domains.ts +62 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +17 -4
- package/src/api/index.ts +8 -0
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/capabilities/discussion-providers.ts +3 -2
- package/src/capabilities/resource-readers.ts +13 -2
- package/src/capabilities/response.ts +6 -2
- package/src/capabilities/server-invoke.ts +73 -29
- package/src/capabilities/server.ts +1 -1
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +109 -84
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +34 -22
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +22 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +10 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +46 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/client-ip.ts +1 -1
- package/src/server/index.ts +1 -0
- package/src/services/app-access-management.ts +1 -8
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-bootstrap.ts +134 -0
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-keys.ts +301 -0
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/gateway.ts +6 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +8 -1
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +7 -5
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +8 -8
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/request-cache-redis.ts +2 -2
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +119 -1
- package/src/services/settings/crypto.ts +2 -2
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +78 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +57 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
import { sql } from "bun";
|
|
17
17
|
import { HTTPException } from "hono/http-exception";
|
|
18
18
|
import { hasRole, type User } from "../../contracts/shared";
|
|
19
|
+
import { connections } from "../../runtime/connections";
|
|
19
20
|
import { claimCacheFill, completeCacheFill, MISSING_SETTING } from "../cache-fill";
|
|
20
21
|
import { stripImageDataUrlMetadata } from "../image-metadata";
|
|
21
22
|
import { toPgTextArray } from "../postgres";
|
|
22
23
|
import { requestCacheRedis } from "../request-cache-redis";
|
|
24
|
+
import { bindSettingValue, resolveBoundSetting, settingOwners, unwrapSettingValue } from "./binding";
|
|
23
25
|
import { decryptValue, encryptValue } from "./crypto";
|
|
24
26
|
import { SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
25
27
|
import { SettingUnreadableError } from "./errors";
|
|
@@ -32,7 +34,7 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
|
32
34
|
type SqlClient = typeof sql;
|
|
33
35
|
|
|
34
36
|
export type StoredSettingState = "none" | "readable" | "unreadable";
|
|
35
|
-
export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
37
|
+
export type SettingRead = { value: unknown; stored: StoredSettingState; bindingMismatch?: true };
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* Forget cached values so every container re-reads on next access.
|
|
@@ -43,7 +45,8 @@ export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
|
43
45
|
* therefore invalidate after it commits.
|
|
44
46
|
*/
|
|
45
47
|
export const invalidateSettingsCache = async (keys: readonly string[]): Promise<void> => {
|
|
46
|
-
if (keys.length > 0)
|
|
48
|
+
if (keys.length > 0)
|
|
49
|
+
await (await requestCacheRedis()).del(...keys.flatMap((key) => [key, ...(SETTINGS_MAP.get(key)?.resetOnChange ?? [])]).map(REDIS_KEY));
|
|
47
50
|
};
|
|
48
51
|
const REDIS_TTL_SEC = 300;
|
|
49
52
|
|
|
@@ -87,6 +90,17 @@ export const readKeyState = async (key: string, db: SqlClient = sql): Promise<Se
|
|
|
87
90
|
export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
|
|
88
91
|
const result = new Map<string, unknown>();
|
|
89
92
|
if (keys.length === 0) return result;
|
|
93
|
+
// Missing startup is a programming error, never a cache outage or unreadable row.
|
|
94
|
+
connections();
|
|
95
|
+
// Bound access is read with its endpoint in one database snapshot. Never
|
|
96
|
+
// resolve it from an independently cached secret.
|
|
97
|
+
const boundKeys = keys.filter((key) => settingOwners(key).length > 0);
|
|
98
|
+
if (boundKeys.length) {
|
|
99
|
+
const bound = await readStates(boundKeys);
|
|
100
|
+
const other = await bulkRead(keys.filter((key) => !bound.has(key)));
|
|
101
|
+
for (const key of keys) result.set(key, bound.has(key) ? bound.get(key)!.value : other.get(key));
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
90
104
|
let cached: Array<string | null>;
|
|
91
105
|
try {
|
|
92
106
|
cached = await (await requestCacheRedis()).mget(...keys.map(REDIS_KEY));
|
|
@@ -152,17 +166,30 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
152
166
|
export const readStates = async (keys: readonly string[], db: SqlClient = sql): Promise<Map<string, SettingRead>> => {
|
|
153
167
|
const result = new Map<string, SettingRead>();
|
|
154
168
|
if (keys.length === 0) return result;
|
|
155
|
-
|
|
169
|
+
connections();
|
|
170
|
+
const snapshotKeys = [...new Set(keys.flatMap((key) => [key, ...settingOwners(key).map((owner) => owner.key)]))];
|
|
171
|
+
const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray(snapshotKeys)}::text[])`;
|
|
156
172
|
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
173
|
+
const resolve = async (key: string) => {
|
|
174
|
+
const row = byKey.get(key);
|
|
175
|
+
const def = SETTINGS_MAP.get(key);
|
|
176
|
+
if (!row) return resolveFallback(def);
|
|
177
|
+
const raw = await decryptValue(row.value);
|
|
178
|
+
const checked = def ? validateSettingValue(def, unwrapSettingValue(key, raw)) : { ok: true as const, value: raw };
|
|
179
|
+
if (!checked.ok) throw new SettingUnreadableError(key);
|
|
180
|
+
return checked.value;
|
|
181
|
+
};
|
|
157
182
|
for (const key of keys) {
|
|
158
183
|
const row = byKey.get(key);
|
|
159
184
|
const def = SETTINGS_MAP.get(key);
|
|
160
185
|
result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
|
|
161
186
|
if (!row) continue;
|
|
162
187
|
try {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
188
|
+
const raw = await decryptValue(row.value);
|
|
189
|
+
const bound = await resolveBoundSetting(key, raw, resolve);
|
|
190
|
+
const validated = def ? validateSettingValue(def, bound.value) : { ok: true as const, value: bound.value };
|
|
191
|
+
if (validated.ok)
|
|
192
|
+
result.set(key, { stored: "readable", value: validated.value, ...(bound.mismatch ? { bindingMismatch: true } : {}) });
|
|
166
193
|
} catch {
|
|
167
194
|
// Keep this process's fallback without changing the stored row or cache.
|
|
168
195
|
}
|
|
@@ -183,6 +210,7 @@ const knownKeysWith = (extraKnownKeys: readonly string[]) =>
|
|
|
183
210
|
Array.from(new Set([...allKnownKeys(), ...retainedLegacyKeys, ...extraKnownKeys]));
|
|
184
211
|
|
|
185
212
|
export const listLegacyKeys = async (extraKnownKeys: readonly string[] = []): Promise<LegacySettingRow[]> => {
|
|
213
|
+
connections();
|
|
186
214
|
const knownKeys = knownKeysWith(extraKnownKeys);
|
|
187
215
|
const rows = await sql<LegacyStoredRow[]>`
|
|
188
216
|
SELECT key, value, updated_at
|
|
@@ -220,6 +248,37 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
|
|
|
220
248
|
return { deleted };
|
|
221
249
|
};
|
|
222
250
|
|
|
251
|
+
/** Serialize a setting and every value it discards, including missing rows. */
|
|
252
|
+
export const lockSettingChanges = async (tx: SqlClient, key: string): Promise<void> => {
|
|
253
|
+
const owners = SETTINGS.filter((def) => def.resetOnChange?.length && (def.key === key || def.resetOnChange.includes(key)));
|
|
254
|
+
for (const owner of owners.sort((a, b) => a.key.localeCompare(b.key)))
|
|
255
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`settings.change:${owner.key}`}, 0))`;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Keep an empty row so environment bootstrap cannot restore discarded access. */
|
|
259
|
+
export const resetChangedSettings = async (
|
|
260
|
+
tx: SqlClient,
|
|
261
|
+
key: string,
|
|
262
|
+
value: unknown,
|
|
263
|
+
preserve: ReadonlySet<string> = new Set(),
|
|
264
|
+
prior?: SettingRead,
|
|
265
|
+
): Promise<void> => {
|
|
266
|
+
const def = SETTINGS_MAP.get(key);
|
|
267
|
+
if (!def?.resetOnChange?.length) return;
|
|
268
|
+
const previous = prior ?? (await readKeyState(key, tx));
|
|
269
|
+
if (previous.stored !== "unreadable" && JSON.stringify(previous.value) === JSON.stringify(value)) return;
|
|
270
|
+
for (const target of def.resetOnChange) {
|
|
271
|
+
if (preserve.has(target)) continue;
|
|
272
|
+
const targetDef = SETTINGS_MAP.get(target);
|
|
273
|
+
if (!targetDef) throw new Error(`Unknown setting: ${target}`);
|
|
274
|
+
const empty = await encryptValue(targetDef.default);
|
|
275
|
+
await tx`
|
|
276
|
+
INSERT INTO settings.entries (key, value, updated_at) VALUES (${target}, ${empty}, now())
|
|
277
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
223
282
|
/**
|
|
224
283
|
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
225
284
|
*
|
|
@@ -238,8 +297,16 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
238
297
|
|
|
239
298
|
const storedValue =
|
|
240
299
|
def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
|
|
241
|
-
const encrypted = await encryptValue(storedValue);
|
|
242
300
|
const write = async (tx: SqlClient) => {
|
|
301
|
+
await lockSettingChanges(tx, key);
|
|
302
|
+
await resetChangedSettings(tx, key, validated.value);
|
|
303
|
+
const encrypted = await encryptValue(
|
|
304
|
+
await bindSettingValue(key, storedValue, async (owner) => {
|
|
305
|
+
const state = await readKeyState(owner, tx);
|
|
306
|
+
if (state.stored === "unreadable") throw new SettingUnreadableError(owner);
|
|
307
|
+
return state.value;
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
243
310
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
244
311
|
let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
245
312
|
if (!row) {
|
|
@@ -256,7 +323,7 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
256
323
|
if (options?.replaceUnreadable !== true) {
|
|
257
324
|
let readable = false;
|
|
258
325
|
try {
|
|
259
|
-
readable = validateSettingValue(def, await decryptValue(row.value)).ok;
|
|
326
|
+
readable = validateSettingValue(def, unwrapSettingValue(key, await decryptValue(row.value))).ok;
|
|
260
327
|
} catch {
|
|
261
328
|
// Only the key is exposed by the recovery error.
|
|
262
329
|
}
|
|
@@ -283,7 +350,10 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
|
|
|
283
350
|
|
|
284
351
|
/** Delete the DB row and invalidate Redis. With `db`, the caller owns geo budget pair validation and post-commit invalidation. */
|
|
285
352
|
export const deleteKey = async (key: string, db?: SqlClient): Promise<void> => {
|
|
353
|
+
const def = SETTINGS_MAP.get(key);
|
|
286
354
|
const remove = async (tx: SqlClient) => {
|
|
355
|
+
await lockSettingChanges(tx, key);
|
|
356
|
+
await resetChangedSettings(tx, key, resolveFallback(def));
|
|
287
357
|
await tx`DELETE FROM settings.entries WHERE key = ${key}`;
|
|
288
358
|
};
|
|
289
359
|
if (db) await remove(db);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BlockList, isIP } from "node:net";
|
|
2
|
+
import { parseNetworkAllowlist } from "../../shared/network-address";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_TRUSTED_PROXIES = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
|
|
5
|
+
|
|
6
|
+
/** One parser for admin writes and the gateway's startup check. */
|
|
7
|
+
export const trustedProxySet = (entries: readonly string[]): BlockList => {
|
|
8
|
+
const set = new BlockList();
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const [address = "", prefix, ...rest] = entry.split("/");
|
|
11
|
+
const version = isIP(address);
|
|
12
|
+
const max = version === 4 ? 32 : 128;
|
|
13
|
+
if (!version || rest.length || address.includes("%") || (prefix !== undefined && !/^\d{1,3}$/.test(prefix)))
|
|
14
|
+
throw new Error("Trusted proxies must contain only IP addresses or CIDR ranges");
|
|
15
|
+
const bits = prefix === undefined ? max : Number(prefix);
|
|
16
|
+
if (bits < 1 || bits > max) throw new Error("Trusted proxy CIDR prefixes must be greater than zero and within the address width");
|
|
17
|
+
set.addSubnet(address, bits, version === 4 ? "ipv4" : "ipv6");
|
|
18
|
+
}
|
|
19
|
+
return set;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const validateTrustedProxies = (value: unknown): string | undefined => {
|
|
23
|
+
if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string"))
|
|
24
|
+
return "Trusted proxies must be a list of strings";
|
|
25
|
+
try {
|
|
26
|
+
trustedProxySet(value);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return error instanceof Error ? error.message : "Invalid trusted proxies";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const validateNetworkAllowlist = (value: unknown): string | undefined => {
|
|
33
|
+
if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string"))
|
|
34
|
+
return "Network targets must be a list of strings";
|
|
35
|
+
try {
|
|
36
|
+
parseNetworkAllowlist(value);
|
|
37
|
+
} catch {
|
|
38
|
+
return "Network targets must be exact host names, IP addresses or canonical CIDRs; /0 is forbidden. Host:port and [ipv6]:port are supported.";
|
|
39
|
+
}
|
|
40
|
+
};
|
package/src/shared/index.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// Cloud-specific shared utils (NOT in stdlib)
|
|
2
2
|
|
|
3
|
+
// The standalone editor owns the formula engine; Cloud preserves its existing shared API.
|
|
4
|
+
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "@k2b/editor/formula";
|
|
5
|
+
export {
|
|
6
|
+
createProgressValue,
|
|
7
|
+
evaluateFormula,
|
|
8
|
+
evaluateTableCell,
|
|
9
|
+
formatValue,
|
|
10
|
+
isFormula,
|
|
11
|
+
isTotalRow,
|
|
12
|
+
parseProgressValue,
|
|
13
|
+
} from "@k2b/editor/formula";
|
|
3
14
|
// Re-export from stdlib for backward compatibility
|
|
4
15
|
// Prefer importing directly from @k2b/stdlib
|
|
5
16
|
export { dates, dates as calendar, encoding, fileIcons, gradients } from "@k2b/stdlib";
|
|
@@ -49,16 +60,6 @@ export * from "./locale-preference";
|
|
|
49
60
|
export * from "./login-method";
|
|
50
61
|
export { markdown } from "./markdown";
|
|
51
62
|
export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
|
|
52
|
-
export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
|
|
53
|
-
export {
|
|
54
|
-
createProgressValue,
|
|
55
|
-
evaluateFormula,
|
|
56
|
-
evaluateTableCell,
|
|
57
|
-
formatValue,
|
|
58
|
-
isFormula,
|
|
59
|
-
isTotalRow,
|
|
60
|
-
parseProgressValue,
|
|
61
|
-
} from "./markdown/formula";
|
|
62
63
|
export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
|
|
63
64
|
export { createMockCover, createMockCoverSvg, parseDataUrl } from "./mock-cover";
|
|
64
65
|
export * from "./network-address";
|
|
@@ -20,8 +20,17 @@
|
|
|
20
20
|
* NULL/datetime cell-formatting, no zebra columns. If you need any of
|
|
21
21
|
* those, you're rendering data not prose; use the Grids app.
|
|
22
22
|
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type EvalContext,
|
|
26
|
+
evaluateFormula,
|
|
27
|
+
formatValue,
|
|
28
|
+
isFormula,
|
|
29
|
+
isTotalRow,
|
|
30
|
+
type ProgressValue,
|
|
31
|
+
parseProgressValue,
|
|
32
|
+
} from "@k2b/editor/formula";
|
|
23
33
|
import type { MarkedExtension, Tokens } from "marked";
|
|
24
|
-
import { type EvalContext, evaluateFormula, formatValue, isFormula, isTotalRow, type ProgressValue, parseProgressValue } from "../formula";
|
|
25
34
|
import { escapeHtml } from "../shared";
|
|
26
35
|
|
|
27
36
|
type Align = "left" | "right" | "center" | null;
|
|
@@ -70,7 +70,6 @@ export const buildAdminGroups = (apps: readonly RuntimeContext["apps"][number][]
|
|
|
70
70
|
links: [
|
|
71
71
|
{ href: "/admin", icon: "ti-dashboard", label: t.overview },
|
|
72
72
|
{ href: "/admin/apps", icon: "ti-apps", label: t.apps },
|
|
73
|
-
{ href: "/admin/app-credentials", icon: "ti-key", label: t.appCredentials },
|
|
74
73
|
{ href: "/admin/rail", icon: "ti-layout-sidebar", label: t.rail },
|
|
75
74
|
{ href: "/admin/announcements", icon: "ti-speakerphone", label: t.announcements },
|
|
76
75
|
],
|
|
@@ -40,7 +40,6 @@ export const platformMessages = i18n.define({
|
|
|
40
40
|
general: "General",
|
|
41
41
|
rail: "App bar",
|
|
42
42
|
announcements: "Announcements",
|
|
43
|
-
appCredentials: "App credentials",
|
|
44
43
|
ai: "AI",
|
|
45
44
|
observability: "Observability",
|
|
46
45
|
baseSettings: "Base settings",
|
|
@@ -147,7 +146,6 @@ export const platformMessages = i18n.define({
|
|
|
147
146
|
general: "Allgemein",
|
|
148
147
|
rail: "App-Leiste",
|
|
149
148
|
announcements: "Ankündigungen",
|
|
150
|
-
appCredentials: "App-Schlüssel",
|
|
151
149
|
ai: "KI",
|
|
152
150
|
observability: "Observability",
|
|
153
151
|
baseSettings: "Grundeinstellungen",
|
package/src/start.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Keep this entry independent of the platform barrel and application modules.
|
|
2
|
+
import { parseAppKey } from "./app-key";
|
|
3
|
+
import { env } from "./config/env";
|
|
4
|
+
import { bootstrapConnections } from "./runtime/bootstrap-client";
|
|
5
|
+
import type { BootstrapResponse } from "./runtime/bootstrap-protocol";
|
|
6
|
+
import { initializeClients } from "./runtime/clients";
|
|
7
|
+
import { bindConnections, hasConnections } from "./runtime/connections";
|
|
8
|
+
import { resolveConnections } from "./runtime/resolve-connections";
|
|
9
|
+
|
|
10
|
+
export type PlatformStartOptions = {
|
|
11
|
+
appId: string;
|
|
12
|
+
/** Core and Gateway always use infrastructure environment values. */
|
|
13
|
+
source?: "env";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let initializing = false;
|
|
17
|
+
|
|
18
|
+
/** Initialize before importing any application module, also in dev and data workers. */
|
|
19
|
+
export const initializePlatform = async ({ appId, source }: PlatformStartOptions): Promise<void> => {
|
|
20
|
+
if (!/^[a-z][a-z0-9-]{0,79}$/.test(appId)) throw new Error("Platform start requires a valid appId");
|
|
21
|
+
if (initializing || hasConnections()) throw new Error("Platform has already started");
|
|
22
|
+
initializing = true;
|
|
23
|
+
try {
|
|
24
|
+
const keyValue = env.CLOUD_APP_KEY;
|
|
25
|
+
const envOnly = source === "env" || appId === "core" || appId === "gateway";
|
|
26
|
+
if (keyValue && envOnly) throw new Error("App keys are forbidden in forced env mode (including Core and Gateway)");
|
|
27
|
+
let bootstrap: BootstrapResponse | undefined;
|
|
28
|
+
if (keyValue) {
|
|
29
|
+
// Parsing never prints the private value. A present invalid key cannot fall back to env.
|
|
30
|
+
const key = parseAppKey(keyValue);
|
|
31
|
+
if (key.appId !== appId) throw new Error("App-key appId does not match the declared application");
|
|
32
|
+
bootstrap = await bootstrapConnections(key, env.CLOUD_CORE_INTERNAL_ORIGIN);
|
|
33
|
+
}
|
|
34
|
+
const resolved = resolveConnections({ appId, core: bootstrap?.connections });
|
|
35
|
+
resolved.bootstrap = {
|
|
36
|
+
mode: bootstrap ? "core" : "env",
|
|
37
|
+
...(bootstrap ? { keyId: bootstrap.identity.keyId } : {}),
|
|
38
|
+
sources: resolved.sources,
|
|
39
|
+
keyFormat: "cloud-app-key-v1",
|
|
40
|
+
protocol: 1,
|
|
41
|
+
};
|
|
42
|
+
bindConnections(resolved);
|
|
43
|
+
try {
|
|
44
|
+
await initializeClients();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
bindConnections(undefined);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
initializing = false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const startPlatform = async <T>({ load, ...options }: PlatformStartOptions & { load: () => Promise<T> }): Promise<T> => {
|
|
55
|
+
await initializePlatform(options);
|
|
56
|
+
return load();
|
|
57
|
+
};
|
|
@@ -7,8 +7,10 @@ import { resolveAiWorkflowModel, runAiStructured } from "../../ai/structured";
|
|
|
7
7
|
import { executeAiTask } from "../../ai/task-execution";
|
|
8
8
|
import { resolveApplicationAiTaskModel } from "../../ai/task-model";
|
|
9
9
|
import { isAiSettingsError } from "../../ai/validate";
|
|
10
|
-
import {
|
|
10
|
+
import { DEFAULT_WORKFLOW_AI_BUDGET } from "../../config/workflow-ai";
|
|
11
|
+
import { coreSettings } from "../../services/settings/api";
|
|
11
12
|
import { WorkflowAiAdmissionError, workflowAiModelHash } from "./policy";
|
|
13
|
+
import { getWorkflowAiBudget } from "./settings";
|
|
12
14
|
|
|
13
15
|
import {
|
|
14
16
|
claimWorkflowAiTask,
|
|
@@ -44,13 +46,14 @@ export type WorkflowAiProcessOptions = Required<Omit<WorkflowAiRuntimeOptions, "
|
|
|
44
46
|
attemptTimeoutMs?: number;
|
|
45
47
|
};
|
|
46
48
|
|
|
49
|
+
let workerBudget = DEFAULT_WORKFLOW_AI_BUDGET;
|
|
47
50
|
const workflowAiJob = lazySync((sync) =>
|
|
48
51
|
sync.job<{ taskId: string }>({
|
|
49
52
|
id: WORKFLOW_AI_JOB_ID,
|
|
50
53
|
whenFull: "reject",
|
|
51
54
|
owner: "cloud",
|
|
52
55
|
delivery: {
|
|
53
|
-
maxInFlight:
|
|
56
|
+
maxInFlight: workerBudget.globalConcurrent,
|
|
54
57
|
ackWaitMs: 30_000,
|
|
55
58
|
maxAttempts: MAX_ATTEMPTS,
|
|
56
59
|
backoffMs: [1_000, 2_000],
|
|
@@ -137,6 +140,7 @@ export const processWorkflowAiTask = async (
|
|
|
137
140
|
ctx: { signal: AbortSignal; heartbeat(): Promise<void> },
|
|
138
141
|
options: WorkflowAiProcessOptions,
|
|
139
142
|
): Promise<void> => {
|
|
143
|
+
const attemptTimeoutMs = options.attemptTimeoutMs ?? (await coreSettings.get<number>("ai.workflow_attempt_timeout_ms"));
|
|
140
144
|
const task = await claimWorkflowAiTask(taskId);
|
|
141
145
|
if (!task) {
|
|
142
146
|
const terminal = await getWorkflowAiTask(taskId);
|
|
@@ -150,7 +154,7 @@ export const processWorkflowAiTask = async (
|
|
|
150
154
|
const deadline = setTimeout(() => {
|
|
151
155
|
timedOut = true;
|
|
152
156
|
controller.abort(new Error("Workflow AI attempt deadline exceeded. Retry the workflow or inspect the provider."));
|
|
153
|
-
},
|
|
157
|
+
}, attemptTimeoutMs);
|
|
154
158
|
let canceled = false;
|
|
155
159
|
let monitorError: unknown;
|
|
156
160
|
let checking = false;
|
|
@@ -230,9 +234,12 @@ let recovery: Promise<void> | null = null;
|
|
|
230
234
|
let shutdown: AbortController | null = null;
|
|
231
235
|
|
|
232
236
|
const recover = (): Promise<void> => {
|
|
233
|
-
recovery ??=
|
|
234
|
-
|
|
235
|
-
|
|
237
|
+
recovery ??= coreSettings
|
|
238
|
+
.get<number>("ai.workflow_queue_timeout_ms")
|
|
239
|
+
.then((timeout) => expireWorkflowAiTasks(timeout))
|
|
240
|
+
.finally(() => {
|
|
241
|
+
recovery = null;
|
|
242
|
+
});
|
|
236
243
|
return recovery;
|
|
237
244
|
};
|
|
238
245
|
|
|
@@ -242,14 +249,15 @@ export const startWorkflowAiRuntime = async (input: WorkflowAiRuntimeOptions = {
|
|
|
242
249
|
runStructured: input.runStructured ?? runAiStructured,
|
|
243
250
|
maxAttempts: MAX_ATTEMPTS,
|
|
244
251
|
cancelPollMs: input.cancelPollMs ?? DEFAULT_CANCEL_POLL_MS,
|
|
245
|
-
attemptTimeoutMs: input.attemptTimeoutMs
|
|
252
|
+
attemptTimeoutMs: input.attemptTimeoutMs,
|
|
246
253
|
};
|
|
254
|
+
workerBudget = await getWorkflowAiBudget();
|
|
247
255
|
const next = workflowAiJob();
|
|
248
256
|
activeJob = next;
|
|
249
257
|
const controller = new AbortController();
|
|
250
258
|
shutdown = controller;
|
|
251
259
|
try {
|
|
252
|
-
activeWorker = await next.process({ concurrency:
|
|
260
|
+
activeWorker = await next.process({ concurrency: workerBudget.globalConcurrent }, async (context) => {
|
|
253
261
|
try {
|
|
254
262
|
await processWorkflowAiTask(
|
|
255
263
|
context.input.taskId,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { workflowAiBudgetSchema } from "../../config/workflow-ai";
|
|
2
|
+
import { coreSettings } from "../../services/settings/api";
|
|
3
|
+
|
|
4
|
+
export const getWorkflowAiBudget = async () =>
|
|
5
|
+
workflowAiBudgetSchema.parse(JSON.parse(await coreSettings.get<string>("ai.workflow_budget")));
|