@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.
Files changed (104) hide show
  1. package/package.json +8 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-access-manager-sql.ts +10 -0
  5. package/src/_internal/bounded-json.ts +7 -1
  6. package/src/_internal/capabilities.ts +8 -0
  7. package/src/_internal/define-app.ts +69 -2
  8. package/src/_internal/heartbeat.ts +21 -0
  9. package/src/_internal/nats-connection.ts +10 -9
  10. package/src/_internal/postgres-application-name.ts +11 -0
  11. package/src/_internal/process-sync.ts +15 -6
  12. package/src/_internal/public-surface.ts +76 -0
  13. package/src/_internal/purpose-name.ts +7 -0
  14. package/src/_internal/readiness.ts +6 -1
  15. package/src/_internal/registry-validation.ts +1 -0
  16. package/src/_internal/registry.ts +49 -3
  17. package/src/_internal/resource-principals.ts +4 -34
  18. package/src/_internal/workload-credential.ts +154 -0
  19. package/src/_internal/workload-protocol.ts +36 -0
  20. package/src/ai/application-tasks.ts +5 -2
  21. package/src/ai/chart-block.ts +36 -711
  22. package/src/ai/default-tool-contracts.ts +21 -259
  23. package/src/api/admin-app-domains.ts +62 -0
  24. package/src/api/admin-app-keys.ts +66 -0
  25. package/src/api/admin-core-settings.ts +2 -0
  26. package/src/api/admin-identity.ts +27 -1
  27. package/src/api/admin-settings-env.ts +120 -0
  28. package/src/api/app-bootstrap.ts +131 -0
  29. package/src/api/capabilities.ts +17 -4
  30. package/src/api/index.ts +8 -0
  31. package/src/api/settings-ops.ts +35 -0
  32. package/src/app-key.ts +125 -0
  33. package/src/browser/CloudResourceSearch.tsx +48 -41
  34. package/src/capabilities/discussion-providers.ts +3 -2
  35. package/src/capabilities/resource-readers.ts +13 -2
  36. package/src/capabilities/response.ts +6 -2
  37. package/src/capabilities/server-invoke.ts +73 -29
  38. package/src/capabilities/server.ts +1 -1
  39. package/src/capabilities/types.ts +1 -1
  40. package/src/cli/admin/app-credentials.ts +32 -7
  41. package/src/cli/admin/app-domains.ts +140 -0
  42. package/src/cli/admin/app-keys.ts +226 -0
  43. package/src/cli/admin/index.ts +109 -84
  44. package/src/cli/admin/settings.ts +62 -0
  45. package/src/config/define-env.ts +63 -8
  46. package/src/config/env.ts +34 -22
  47. package/src/config/index.ts +12 -1
  48. package/src/config/workflow-ai.ts +28 -0
  49. package/src/contracts/app-domains.ts +91 -0
  50. package/src/contracts/app-status.ts +104 -0
  51. package/src/contracts/capabilities.ts +22 -0
  52. package/src/contracts/index.ts +1 -0
  53. package/src/contracts/registry.ts +2 -0
  54. package/src/contracts/settings-env.ts +21 -0
  55. package/src/contracts/settings-types.ts +6 -0
  56. package/src/index.ts +10 -0
  57. package/src/runtime/bootstrap-client.ts +195 -0
  58. package/src/runtime/bootstrap-protocol.ts +60 -0
  59. package/src/runtime/clients.ts +72 -0
  60. package/src/runtime/connections.ts +46 -0
  61. package/src/runtime/resolve-connections.ts +91 -0
  62. package/src/server/app-key.ts +14 -0
  63. package/src/server/client-ip.ts +1 -1
  64. package/src/server/index.ts +1 -0
  65. package/src/services/app-access-management.ts +1 -8
  66. package/src/services/app-admin-facts.ts +160 -0
  67. package/src/services/app-bootstrap.ts +134 -0
  68. package/src/services/app-domain-check.ts +152 -0
  69. package/src/services/app-domains.ts +172 -0
  70. package/src/services/app-keys.ts +301 -0
  71. package/src/services/downstream-budget.ts +2 -2
  72. package/src/services/gateway.ts +6 -0
  73. package/src/services/identity/index.ts +3 -0
  74. package/src/services/identity/resource-readers.ts +80 -0
  75. package/src/services/identity/runtime-config.ts +14 -6
  76. package/src/services/identity/workload-runtime.ts +253 -0
  77. package/src/services/index.ts +8 -1
  78. package/src/services/links/index.ts +16 -0
  79. package/src/services/links/runtime.ts +44 -0
  80. package/src/services/links/store.ts +105 -0
  81. package/src/services/logging/index.ts +7 -5
  82. package/src/services/logging/redaction.ts +6 -1
  83. package/src/services/logging/trace.ts +8 -8
  84. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  85. package/src/services/request-cache-redis.ts +2 -2
  86. package/src/services/service-account-credentials.ts +63 -21
  87. package/src/services/settings/api.ts +3 -3
  88. package/src/services/settings/binding.ts +45 -0
  89. package/src/services/settings/core-settings.ts +119 -1
  90. package/src/services/settings/crypto.ts +2 -2
  91. package/src/services/settings/defaults.ts +16 -2
  92. package/src/services/settings/env-status.ts +69 -0
  93. package/src/services/settings/index.ts +69 -26
  94. package/src/services/settings/store.ts +78 -8
  95. package/src/services/settings/validation.ts +40 -0
  96. package/src/shared/index.ts +11 -10
  97. package/src/shared/markdown/extensions/tables.ts +10 -1
  98. package/src/ssr/admin-navigation.ts +0 -1
  99. package/src/ssr/platform-messages.ts +0 -2
  100. package/src/start.ts +57 -0
  101. package/src/workflows/ai/index.ts +1 -0
  102. package/src/workflows/ai/runtime.ts +16 -8
  103. package/src/workflows/ai/settings.ts +5 -0
  104. 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) await (await requestCacheRedis()).del(...keys.map(REDIS_KEY));
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
- const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
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 value = await decryptValue(row.value);
164
- const validated = def ? validateSettingValue(def, value) : { ok: true as const, value };
165
- if (validated.ok) result.set(key, { stored: "readable", value: validated.value });
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
+ };
@@ -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
+ };
@@ -1,4 +1,5 @@
1
1
  export { startWorkflowAiRuntime, stopWorkflowAiRuntime, submitWorkflowAiTask, type WorkflowAiRuntimeOptions } from "./runtime";
2
+ export { getWorkflowAiBudget } from "./settings";
2
3
  export {
3
4
  createWorkflowAiTask,
4
5
  getWorkflowAiTask,
@@ -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 { env } from "../../config/env";
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: env.WORKFLOW_AI_BUDGET.globalConcurrent,
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
- }, options.attemptTimeoutMs ?? env.WORKFLOW_AI_ATTEMPT_TIMEOUT_MS);
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 ??= expireWorkflowAiTasks(env.WORKFLOW_AI_QUEUE_TIMEOUT_MS).finally(() => {
234
- recovery = null;
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 ?? env.WORKFLOW_AI_ATTEMPT_TIMEOUT_MS,
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: env.WORKFLOW_AI_BUDGET.globalConcurrent }, async (context) => {
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")));