@k2b/cloud 0.37.0 → 0.38.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 (64) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/help-catalog.ts +123 -4
  3. package/src/_internal/help.ts +27 -6
  4. package/src/account/Avatar.tsx +20 -2
  5. package/src/account/ui.ts +1 -1
  6. package/src/ai/browser.ts +1 -0
  7. package/src/ai/chart-block.ts +44 -13
  8. package/src/ai/chat/chart-block.tsx +2 -11
  9. package/src/ai/chat/messages.ts +10 -0
  10. package/src/ai/chat/web-tools.tsx +23 -6
  11. package/src/ai/chat-tasks.ts +65 -1
  12. package/src/ai/default-tool-contracts.ts +30 -13
  13. package/src/ai/fetch-file-tool.ts +37 -3
  14. package/src/ai/migrate.ts +19 -2
  15. package/src/ai/readable-html.ts +1057 -0
  16. package/src/api/help.ts +23 -4
  17. package/src/api/mcp.ts +8 -2
  18. package/src/browser/FileChooser.tsx +11 -3
  19. package/src/browser/choose-files.browser-harness.tsx +4 -1
  20. package/src/browser/file-chooser-messages.ts +4 -0
  21. package/src/browser/file-providers.ts +14 -1
  22. package/src/browser/provider-folder.tsx +58 -5
  23. package/src/browser/resource-picker.tsx +5 -1
  24. package/src/cli/admin/workflows.ts +1 -0
  25. package/src/config/env.ts +14 -0
  26. package/src/contracts/file-provider.ts +48 -1
  27. package/src/contracts/outgoing-mail.ts +8 -1
  28. package/src/server/help.ts +136 -10
  29. package/src/server/services/geo.ts +21 -4
  30. package/src/services/allowed-network-fetch.ts +189 -0
  31. package/src/services/help/index.ts +46 -5
  32. package/src/services/help/store.ts +26 -5
  33. package/src/services/help/types.ts +10 -2
  34. package/src/services/index.ts +1 -0
  35. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  36. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  37. package/src/services/outgoing-mail/test-send.ts +18 -3
  38. package/src/services/outgoing-mail/transport.ts +24 -13
  39. package/src/services/settings/index.ts +48 -26
  40. package/src/shared/help.ts +41 -0
  41. package/src/shared/index.ts +9 -1
  42. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  43. package/src/shared/markdown/formula.ts +8 -8
  44. package/src/shared/markdown/index.ts +72 -11
  45. package/src/ssr/Layout.tsx +20 -2
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  47. package/src/ssr/LayoutHelp.tsx +569 -410
  48. package/src/ssr/ShellRuntime.island.tsx +17 -25
  49. package/src/ssr/help-messages.ts +60 -38
  50. package/src/ssr/help.ts +3 -0
  51. package/src/ssr/layout-help-article.ts +41 -0
  52. package/src/ssr/layout-help-navigation.ts +0 -12
  53. package/src/ssr/layout-help-registry.tsx +4 -11
  54. package/src/ssr/layout-help-search.ts +9 -12
  55. package/src/ssr/layout-help-start.ts +68 -0
  56. package/src/ssr/platform-messages.ts +4 -0
  57. package/src/ssr/shell-data.tsx +7 -0
  58. package/src/ssr/shell-shortcuts.ts +8 -0
  59. package/src/styles/effects.css +688 -185
  60. package/src/styles/resource-search.css +25 -0
  61. package/src/workflows/store/events.ts +24 -0
  62. package/src/workflows/store/index.ts +8 -0
  63. package/src/workflows/store/observability.ts +6 -1
  64. package/src/workflows/store/retention.ts +157 -0
@@ -428,6 +428,31 @@
428
428
  }
429
429
  }
430
430
 
431
+ /* On a phone the resource picker is a full-screen sheet: the whole width, from
432
+ below the status bar to the bottom edge, rounded at the top. Its results
433
+ take the room the search field and footer leave. Global search keeps its
434
+ floating card. */
435
+ @media (max-width: 47.999rem) {
436
+ .cloud-search-dialog.cloud-search-dialog--picker {
437
+ --search-top: max(0.5rem, env(safe-area-inset-top, 0px));
438
+ --search-max-height: calc(100dvh - var(--search-top));
439
+ inset: var(--search-top) 0 auto;
440
+ transform: none;
441
+ width: 100%;
442
+ height: var(--search-max-height);
443
+ border-width: 1px 0 0;
444
+ border-radius: var(--ui-radius-frame) var(--ui-radius-frame) 0 0;
445
+ }
446
+ .cloud-search-dialog--picker > .cloud-search-dialog__content,
447
+ .cloud-search-dialog--picker .cloud-resource-search {
448
+ height: 100%;
449
+ }
450
+ .cloud-search-dialog--picker .cloud-resource-search {
451
+ --search-body-height: max(0px, calc(var(--search-max-height) - 12rem));
452
+ padding-bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
453
+ }
454
+ }
455
+
431
456
  /* Border grips belong only to desktop Spotlight, never the resource picker. */
432
457
  .cloud-search-dialog:has(.cloud-global-search) {
433
458
  overflow: visible;
@@ -10,11 +10,28 @@
10
10
  * A schedule tick is an event. A button press is an event. An inbound message
11
11
  * is an event. Nothing needs its own registration path.
12
12
  */
13
+ import { Buffer } from "node:buffer";
13
14
  import { type SQL, sql } from "bun";
14
15
  import type { WorkflowJsonValue } from "../contracts";
15
16
  import { createWorkflowRun } from "./runs";
16
17
  import { withTransaction } from "./transaction";
17
18
 
19
+ /** UTF-8 bytes of serialized JSON, independently for data and context. */
20
+ export const WORKFLOW_EVENT_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
21
+
22
+ export class WorkflowEventTooLargeError extends Error {
23
+ readonly code = "WORKFLOW_EVENT_TOO_LARGE";
24
+ readonly limitBytes = WORKFLOW_EVENT_MAX_PAYLOAD_BYTES;
25
+
26
+ constructor(
27
+ readonly field: "data" | "context",
28
+ readonly bytes: number,
29
+ ) {
30
+ super(`Workflow event ${field} is ${bytes} bytes; the limit is ${WORKFLOW_EVENT_MAX_PAYLOAD_BYTES} bytes (16 MiB).`);
31
+ this.name = "WorkflowEventTooLargeError";
32
+ }
33
+ }
34
+
18
35
  export type WorkflowEventInput = {
19
36
  appId: string;
20
37
  scopeId: string;
@@ -173,6 +190,13 @@ export const emitWorkflowEvent = async (
173
190
  const context = event.context ?? {};
174
191
  const authorization = event.authorization ?? {};
175
192
 
193
+ // Validate before even opening a savepoint: callers may catch this inside
194
+ // their own transaction and continue writing domain state.
195
+ for (const field of ["data", "context"] as const) {
196
+ const bytes = Buffer.byteLength(JSON.stringify(field === "data" ? data : context), "utf8");
197
+ if (bytes > WORKFLOW_EVENT_MAX_PAYLOAD_BYTES) throw new WorkflowEventTooLargeError(field, bytes);
198
+ }
199
+
176
200
  return withTransaction(options.db, async (tx) => {
177
201
  const inserted = await tx<{ id: string }[]>`
178
202
  INSERT INTO workflows.event (
@@ -45,8 +45,10 @@ export {
45
45
  emitWorkflowEvent,
46
46
  listUndispatchedWorkflowEvents,
47
47
  type UndispatchedWorkflowEvent,
48
+ WORKFLOW_EVENT_MAX_PAYLOAD_BYTES,
48
49
  type WorkflowEmission,
49
50
  type WorkflowEventInput,
51
+ WorkflowEventTooLargeError,
50
52
  } from "./events";
51
53
  export {
52
54
  getWorkflowRun,
@@ -66,6 +68,12 @@ export {
66
68
  type WorkflowStepSummary,
67
69
  workflowHealth,
68
70
  } from "./observability";
71
+ export {
72
+ expireWorkflowEventPayloads,
73
+ expireWorkflowEventPayloadsBatch,
74
+ startWorkflowEventRetention,
75
+ WORKFLOW_EVENT_RETENTION_DAYS,
76
+ } from "./retention";
69
77
  export {
70
78
  beginWorkflowEffect,
71
79
  claimWorkflowRun,
@@ -387,6 +387,7 @@ export type WorkflowRunDetail = WorkflowRunSummary & {
387
387
  source: string;
388
388
  eventId: string | null;
389
389
  eventData: Record<string, WorkflowJsonValue> | null;
390
+ eventPayloadExpired: boolean;
390
391
  steps: WorkflowStepSummary[];
391
392
  children: Record<WorkflowRunState, number>;
392
393
  };
@@ -412,10 +413,13 @@ export const getWorkflowRun = async (runId: string, options: { db?: SQL } = {}):
412
413
  source: string;
413
414
  event_id: string | null;
414
415
  event_data: Record<string, WorkflowJsonValue> | null;
416
+ event_payload_expired: boolean;
415
417
  })[]
416
418
  >`
417
419
  SELECT ${db.unsafe(RUN_SELECT)},
418
- r.inputs, r.result, r.effects_used, v.effect_budget, v.source, r.event_id, e.data AS event_data
420
+ r.inputs, r.result, r.effects_used, v.effect_budget, v.source, r.event_id,
421
+ CASE WHEN e.payload_expired_at IS NULL THEN e.data END AS event_data,
422
+ (e.payload_expired_at IS NOT NULL) AS event_payload_expired
419
423
  ${db.unsafe(RUN_FROM)}
420
424
  WHERE r.id = ${runId}::uuid
421
425
  `;
@@ -438,6 +442,7 @@ export const getWorkflowRun = async (runId: string, options: { db?: SQL } = {}):
438
442
  source: row.source,
439
443
  eventId: row.event_id,
440
444
  eventData: row.event_data,
445
+ eventPayloadExpired: row.event_payload_expired,
441
446
  children,
442
447
  steps,
443
448
  };
@@ -0,0 +1,157 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { logger } from "../../services/logging";
3
+ import { toPgUuidArray } from "../../services/postgres";
4
+ import { withTransaction } from "./transaction";
5
+
6
+ /**
7
+ * Events are cause records; runs keep their own inputs and context. After 30
8
+ * days only the slim cause row remains: Mail backfill and Grids idempotency
9
+ * still need its dedupe key. This window is fixed, not configurable.
10
+ */
11
+ export const WORKFLOW_EVENT_RETENTION_DAYS = 30;
12
+
13
+ const DEFAULT_BATCH_SIZE = 1000;
14
+ const log = logger("workflows:retention");
15
+
16
+ const retentionIndexes = [
17
+ {
18
+ name: "idx_workflows_event_retention",
19
+ table: "workflows.event",
20
+ definition:
21
+ "((COALESCE(dispatched_at, dispatch_failed_at)), id) WHERE payload_expired_at IS NULL AND (dispatched_at IS NOT NULL OR dispatch_failed_at IS NOT NULL)",
22
+ },
23
+ {
24
+ name: "idx_workflows_run_event",
25
+ table: "workflows.run",
26
+ definition: "(event_id) WHERE event_id IS NOT NULL",
27
+ },
28
+ ] as const;
29
+
30
+ const readRetentionIndex = async (db: SQL, index: (typeof retentionIndexes)[number]) => {
31
+ const name = `workflows.${index.name}`;
32
+ const [state] = await db<{ present: boolean; valid: boolean; building: boolean }[]>`
33
+ SELECT to_regclass(${name}) IS NOT NULL AS present,
34
+ EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = to_regclass(${name}) AND indisvalid AND indisready AND indislive) AS valid,
35
+ EXISTS (SELECT 1 FROM pg_catalog.pg_stat_progress_create_index progress
36
+ WHERE progress.datname = pg_catalog.current_database()
37
+ AND (progress.relid = to_regclass(${index.table}) OR progress.relid IS NULL)) AS building
38
+ `;
39
+ return state ?? { present: false, valid: false, building: false };
40
+ };
41
+
42
+ const dropRetentionIndex = (db: SQL, name: string) => db.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS workflows.${name}`).simple();
43
+
44
+ /**
45
+ * Build after Core setup without blocking event or run writes. Each statement
46
+ * runs outside a transaction, including through transaction poolers. Interrupted
47
+ * builds are replaced; active builds are left for the next sweep. Never throws.
48
+ */
49
+ export const ensureWorkflowEventRetentionIndexes = async (db: SQL = sql): Promise<boolean> => {
50
+ try {
51
+ for (const index of retentionIndexes) {
52
+ const before = await readRetentionIndex(db, index);
53
+ if (before.valid) continue;
54
+ if (before.building) return false;
55
+ if (before.present) await dropRetentionIndex(db, index.name);
56
+ try {
57
+ await db.unsafe(`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${index.name} ON ${index.table} ${index.definition}`).simple();
58
+ } catch (error) {
59
+ const after = await readRetentionIndex(db, index);
60
+ log.warn("Workflow event retention index build failed; retrying on the next sweep", {
61
+ index: index.name,
62
+ error: error instanceof Error ? error.message : String(error),
63
+ });
64
+ if (after.present && !after.valid && !after.building) await dropRetentionIndex(db, index.name);
65
+ return false;
66
+ }
67
+ if (!(await readRetentionIndex(db, index)).valid) return false;
68
+ }
69
+ return true;
70
+ } catch (error) {
71
+ log.warn("Workflow event retention indexes are unavailable; retrying on the next sweep", {
72
+ error: error instanceof Error ? error.message : String(error),
73
+ });
74
+ return false;
75
+ }
76
+ };
77
+
78
+ /** Expire one bounded batch atomically; concurrent Core replicas skip locked rows. */
79
+ export const expireWorkflowEventPayloadsBatch = async (options: { olderThan: Date; limit?: number; db?: SQL }): Promise<number> => {
80
+ const limit = options.limit ?? DEFAULT_BATCH_SIZE;
81
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("Workflow event retention batch size must be a positive integer.");
82
+ return withTransaction(options.db, async (tx) => {
83
+ const rows = await tx<{ id: string }[]>`
84
+ SELECT e.id FROM workflows.event e
85
+ WHERE e.payload_expired_at IS NULL
86
+ AND (e.dispatched_at IS NOT NULL OR e.dispatch_failed_at IS NOT NULL)
87
+ AND COALESCE(e.dispatched_at, e.dispatch_failed_at) < ${options.olderThan}
88
+ AND NOT EXISTS (
89
+ SELECT 1 FROM workflows.run r
90
+ WHERE r.event_id = e.id AND r.state NOT IN ('succeeded', 'failed', 'canceled')
91
+ )
92
+ ORDER BY COALESCE(e.dispatched_at, e.dispatch_failed_at), e.id
93
+ LIMIT ${limit}
94
+ FOR UPDATE SKIP LOCKED
95
+ `;
96
+ if (rows.length === 0) return 0;
97
+ const ids = toPgUuidArray(rows.map((row) => row.id));
98
+ await tx`DELETE FROM workflows.event_delivery WHERE event_id = ANY(${ids}::uuid[])`;
99
+ await tx`
100
+ UPDATE workflows.event
101
+ SET data = '{}'::jsonb, context = '{}'::jsonb, authorization_snapshot = '{}'::jsonb,
102
+ payload_expired_at = now()
103
+ WHERE id = ANY(${ids}::uuid[])
104
+ `;
105
+ return rows.length;
106
+ });
107
+ };
108
+
109
+ /** Sweep for at most five minutes, stopping between batches on cancellation. */
110
+ export const expireWorkflowEventPayloads = async (
111
+ options: { now?: Date; signal?: AbortSignal; db?: SQL; batchSize?: number } = {},
112
+ ): Promise<number> => {
113
+ const olderThan = new Date((options.now ?? new Date()).getTime() - WORKFLOW_EVENT_RETENTION_DAYS * 86_400_000);
114
+ const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
115
+ const deadline = Date.now() + 5 * 60_000;
116
+ let expired = 0;
117
+ while (!options.signal?.aborted && Date.now() < deadline) {
118
+ const count = await expireWorkflowEventPayloadsBatch({ olderThan, limit: batchSize, db: options.db });
119
+ expired += count;
120
+ if (count < batchSize) break;
121
+ }
122
+ return expired;
123
+ };
124
+
125
+ const RETENTION_INTERVAL_MS = 6 * 60 * 60 * 1000;
126
+ let retentionTimer: ReturnType<typeof setInterval> | null = null;
127
+ let retentionRunning = false;
128
+
129
+ /** Core owns this scheduler; SKIP LOCKED makes multiple replicas safe. */
130
+ export const startWorkflowEventRetention = (): (() => void) => {
131
+ if (retentionTimer) return () => undefined;
132
+ const abort = new AbortController();
133
+ const expire = async (): Promise<void> => {
134
+ if (retentionRunning) return;
135
+ retentionRunning = true;
136
+ try {
137
+ if (!(await ensureWorkflowEventRetentionIndexes())) {
138
+ log.info("Skipped workflow event retention sweep until its indexes are valid");
139
+ return;
140
+ }
141
+ const expired = await expireWorkflowEventPayloads({ signal: abort.signal });
142
+ if (expired > 0) log.info("Expired workflow event payloads", { expired });
143
+ } catch (error) {
144
+ log.error("Workflow event retention sweep failed", { error: error instanceof Error ? error.message : String(error) });
145
+ } finally {
146
+ retentionRunning = false;
147
+ }
148
+ };
149
+ void expire();
150
+ retentionTimer = setInterval(() => void expire(), RETENTION_INTERVAL_MS);
151
+ retentionTimer.unref?.();
152
+ return () => {
153
+ abort.abort();
154
+ if (retentionTimer) clearInterval(retentionTimer);
155
+ retentionTimer = null;
156
+ };
157
+ };