@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.
- package/package.json +4 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +1 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/ssr/Layout.tsx +20 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/ShellRuntime.island.tsx +17 -25
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/resource-search.css +25 -0
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- 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,
|
|
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
|
+
};
|