@k2b/cloud 0.36.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 +5 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +38 -9
- package/src/access/ui.ts +1 -0
- 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/answer-links.ts +8 -0
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +12 -0
- package/src/ai/chat/primitives.tsx +2 -1
- 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/executor.ts +46 -3
- 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/ai/types.ts +1 -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/browser/shell-focus.ts +67 -0
- package/src/cli/admin/workflows.ts +1 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- 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 +2 -0
- package/src/services/network-security.ts +28 -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/shared/network-address.ts +9 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +28 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +53 -26
- package/src/ssr/app-launchpad.tsx +1 -1
- 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 +14 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/global.css +155 -26
- package/src/styles/resource-search.css +25 -0
- package/src/styles/utilities-navigation.css +11 -3
- 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
package/src/styles/global.css
CHANGED
|
@@ -79,8 +79,8 @@ html:has(.cloud-app-canvas[data-layout-full-page="true"]) {
|
|
|
79
79
|
/* The authenticated rail is part of the canvas. Keeping it transparent also
|
|
80
80
|
covers shell-only routes such as 404 pages that have no app workspace. */
|
|
81
81
|
.cloud-app-canvas > .layout-rail {
|
|
82
|
-
width: calc(
|
|
83
|
-
|
|
82
|
+
width: calc(3.5rem + env(safe-area-inset-left, 0px));
|
|
83
|
+
padding-left: env(safe-area-inset-left, 0px);
|
|
84
84
|
background-color: transparent !important;
|
|
85
85
|
background-image: none !important;
|
|
86
86
|
}
|
|
@@ -294,11 +294,13 @@ solid-island {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
/* Cloud has three shell modes. Phones, below md, get the header and no rail.
|
|
297
|
-
From md, signed-in people get the rail instead of the header: up to
|
|
298
|
-
app area meets the window's top, right and bottom edges, and
|
|
299
|
-
a card
|
|
300
|
-
from md.
|
|
301
|
-
|
|
297
|
+
From md, signed-in people get the 56 px rail instead of the header: up to
|
|
298
|
+
1023 px the app area meets the window's top, right and bottom edges, and
|
|
299
|
+
from 1024 px it is a card beside the rail. Anonymous pages keep the header
|
|
300
|
+
and the card from md. Each query is the exact opposite of the next one, so
|
|
301
|
+
every width, also a fractional one under browser zoom, falls into exactly
|
|
302
|
+
one mode. */
|
|
303
|
+
@media not all and (min-width: 768px) {
|
|
302
304
|
.cloud-app-canvas {
|
|
303
305
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
304
306
|
}
|
|
@@ -360,35 +362,162 @@ solid-island {
|
|
|
360
362
|
The rail and the app area reach the window's edges also where the installed
|
|
361
363
|
app reports safe areas, such as the status bar and the home indicator. Their
|
|
362
364
|
own surfaces fill the insets, and their content stays inside them. */
|
|
363
|
-
@media (min-width: 768px)
|
|
365
|
+
@media (min-width: 768px) {
|
|
366
|
+
@media not all and (min-width: 1024px) {
|
|
367
|
+
.cloud-app-canvas[data-layout-authenticated] {
|
|
368
|
+
padding: 0;
|
|
369
|
+
}
|
|
370
|
+
.cloud-app-canvas[data-layout-authenticated] > .layout-rail {
|
|
371
|
+
padding-top: env(safe-area-inset-top, 0px);
|
|
372
|
+
padding-bottom: calc(var(--ui-space-shell) + env(safe-area-inset-bottom, 0px));
|
|
373
|
+
}
|
|
374
|
+
.cloud-app-canvas[data-layout-authenticated] > .layout-shell-content {
|
|
375
|
+
gap: 0;
|
|
376
|
+
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 0;
|
|
377
|
+
background: var(--k2b-surface);
|
|
378
|
+
}
|
|
379
|
+
/* A regular page scrolls `main`, so its end clears the home indicator. A
|
|
380
|
+
workspace reaches the bottom edge and clears it inside its own surfaces. */
|
|
381
|
+
.cloud-app-canvas[data-layout-authenticated] .layout-content-main:not(:has(.k2b-app-workspace)) {
|
|
382
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
383
|
+
}
|
|
384
|
+
.cloud-app-canvas[data-layout-authenticated] .layout-content-main .k2b-app-workspace:not(.k2b-app-workspace *) {
|
|
385
|
+
--k2b-workspace-radius: 0px;
|
|
386
|
+
--k2b-workspace-inset-bottom: env(safe-area-inset-bottom, 0px);
|
|
387
|
+
border: 0;
|
|
388
|
+
box-shadow: none;
|
|
389
|
+
}
|
|
390
|
+
.cloud-app-canvas[data-layout-authenticated] > .layout-shell-content > :not(.layout-content-main) .k2b-notice-card {
|
|
391
|
+
border: 0;
|
|
392
|
+
border-radius: 0;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* Signed-in card: from 1024 px the app area is a card on the app canvas beside
|
|
398
|
+
the rail, as on laptops. Its spacing and radius grow with the window, from
|
|
399
|
+
4 to 8 px and from 10 to 14 px at 1440 px, the wide card from there on, in
|
|
400
|
+
whole pixels, so the card and its columns keep whole widths. The
|
|
401
|
+
rail starts at the window's left edge and moves down with the card. In the
|
|
402
|
+
installed app the card and the rail stay below the status bar; the card keeps
|
|
403
|
+
its spacing at the bottom, and its surfaces keep their content above the
|
|
404
|
+
home indicator, as in the flush range. */
|
|
405
|
+
@media (min-width: 1024px) {
|
|
364
406
|
.cloud-app-canvas[data-layout-authenticated] {
|
|
365
|
-
|
|
407
|
+
--cloud-card-inset: round(clamp(0.25rem, calc(0.25rem + (100vw - 64rem) / 104), 0.5rem), 1px);
|
|
408
|
+
--cloud-card-radius: round(clamp(0.625rem, calc(0.625rem + (100vw - 64rem) / 104), 0.875rem), 1px);
|
|
409
|
+
--cloud-card-inset-bottom: max(0px, env(safe-area-inset-bottom, 0px) - var(--cloud-card-inset));
|
|
410
|
+
padding: max(var(--cloud-card-inset), env(safe-area-inset-top, 0px)) max(var(--cloud-card-inset), env(safe-area-inset-right, 0px))
|
|
411
|
+
var(--cloud-card-inset) 0;
|
|
366
412
|
}
|
|
367
413
|
.cloud-app-canvas[data-layout-authenticated] > .layout-rail {
|
|
368
|
-
|
|
369
|
-
margin-inline-start: 0;
|
|
370
|
-
padding: env(safe-area-inset-top, 0px) 0 calc(var(--ui-space-shell) + env(safe-area-inset-bottom, 0px))
|
|
371
|
-
env(safe-area-inset-left, 0px);
|
|
414
|
+
padding-bottom: var(--cloud-card-inset-bottom);
|
|
372
415
|
}
|
|
373
416
|
.cloud-app-canvas[data-layout-authenticated] > .layout-shell-content {
|
|
374
|
-
gap:
|
|
375
|
-
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 0;
|
|
376
|
-
background: var(--k2b-surface);
|
|
417
|
+
gap: var(--cloud-card-inset);
|
|
377
418
|
}
|
|
378
|
-
/* A regular page scrolls `main`, so its end clears the home indicator. A
|
|
379
|
-
workspace reaches the bottom edge and clears it inside its own surfaces. */
|
|
380
419
|
.cloud-app-canvas[data-layout-authenticated] .layout-content-main:not(:has(.k2b-app-workspace)) {
|
|
381
|
-
padding-bottom:
|
|
420
|
+
padding-bottom: var(--cloud-card-inset-bottom);
|
|
382
421
|
}
|
|
383
422
|
.cloud-app-canvas[data-layout-authenticated] .layout-content-main .k2b-app-workspace:not(.k2b-app-workspace *) {
|
|
384
|
-
--k2b-workspace-radius:
|
|
385
|
-
--k2b-workspace-inset-bottom:
|
|
386
|
-
|
|
387
|
-
|
|
423
|
+
--k2b-workspace-radius: var(--cloud-card-radius);
|
|
424
|
+
--k2b-workspace-inset-bottom: var(--cloud-card-inset-bottom);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* Focus: the app bar and the outermost workspace's navigation hide together
|
|
429
|
+
(Cmd/Ctrl+\ or the sidebar buttons). The canvas carries the state from the
|
|
430
|
+
`cloud_shell_focus` cookie, so the server renders the same first frame.
|
|
431
|
+
Phones ignore it. The app area takes the app bar's place and starts at the
|
|
432
|
+
card spacing, or at the window's edge where it is flush. The button that
|
|
433
|
+
shows them again floats at the app area's bottom start corner; on touch
|
|
434
|
+
screens a drag from the 18 px strip at the left edge does the same. From
|
|
435
|
+
768 to 1023 px the navigation is already an overlay behind the
|
|
436
|
+
workspace head's button, so there only the app bar hides. */
|
|
437
|
+
.cloud-shell-show,
|
|
438
|
+
.cloud-shell-reveal {
|
|
439
|
+
display: none;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@media (min-width: 768px) {
|
|
443
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] > .layout-rail {
|
|
444
|
+
display: none;
|
|
445
|
+
}
|
|
446
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] .cloud-shell-show {
|
|
447
|
+
position: absolute;
|
|
448
|
+
left: calc(env(safe-area-inset-left, 0px) + 0.75rem);
|
|
449
|
+
bottom: calc(env(safe-area-inset-bottom, 0px) + 0.75rem);
|
|
450
|
+
z-index: 15;
|
|
451
|
+
display: flex;
|
|
452
|
+
}
|
|
453
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] .cloud-shell-show .k2b-icon-button {
|
|
454
|
+
box-shadow: var(--k2b-shadow-float);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@media (min-width: 768px) and (pointer: coarse) {
|
|
459
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] > .cloud-shell-reveal {
|
|
460
|
+
position: fixed;
|
|
461
|
+
top: 0;
|
|
462
|
+
bottom: 0;
|
|
463
|
+
left: 0;
|
|
464
|
+
z-index: 16;
|
|
465
|
+
display: block;
|
|
466
|
+
width: calc(1.125rem + env(safe-area-inset-left, 0px));
|
|
467
|
+
touch-action: pan-y;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
@media (min-width: 768px) {
|
|
472
|
+
@media not all and (min-width: 1024px) {
|
|
473
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] > .layout-shell-content {
|
|
474
|
+
padding-left: env(safe-area-inset-left, 0px);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@media (min-width: 1024px) {
|
|
480
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] {
|
|
481
|
+
padding-left: max(var(--cloud-card-inset), env(safe-area-inset-left, 0px));
|
|
482
|
+
}
|
|
483
|
+
.cloud-app-canvas[data-layout-authenticated][data-shell-focus] .cloud-shell-show {
|
|
484
|
+
left: calc(max(var(--cloud-card-inset), env(safe-area-inset-left, 0px)) + 0.75rem);
|
|
485
|
+
bottom: calc(var(--cloud-card-inset) + var(--cloud-card-inset-bottom) + 0.75rem);
|
|
486
|
+
}
|
|
487
|
+
/* The navigation leaves like a hidden sidebar: its track, its resize
|
|
488
|
+
handle, and navigation panes go, and the work area rounds its start
|
|
489
|
+
corners with the card. An island may wrap the sidebar or Main, as in
|
|
490
|
+
Grids. */
|
|
491
|
+
.cloud-app-canvas[data-shell-focus] .k2b-app-workspace:not(.k2b-app-workspace *) {
|
|
492
|
+
--k2b-workspace-start-radius: var(--k2b-workspace-inner-radius);
|
|
388
493
|
}
|
|
389
|
-
.cloud-app-canvas[data-
|
|
390
|
-
|
|
391
|
-
|
|
494
|
+
.cloud-app-canvas[data-shell-focus]
|
|
495
|
+
.k2b-app-workspace:not(.k2b-app-workspace *)
|
|
496
|
+
> :is(.k2b-app-workspace__sidebar, .k2b-app-workspace__resize[data-app-workspace-resize="sidebar"]),
|
|
497
|
+
.cloud-app-canvas[data-shell-focus]
|
|
498
|
+
.k2b-app-workspace:not(.k2b-app-workspace *)
|
|
499
|
+
> :is(solid-island, solid-client)
|
|
500
|
+
> :is(.k2b-app-workspace__sidebar, .k2b-app-workspace__resize[data-app-workspace-resize="sidebar"]),
|
|
501
|
+
.cloud-app-canvas[data-shell-focus]
|
|
502
|
+
.k2b-app-workspace:not(.k2b-app-workspace *)
|
|
503
|
+
> .k2b-app-workspace__content
|
|
504
|
+
> .k2b-app-workspace__main
|
|
505
|
+
> :is(
|
|
506
|
+
.k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary),
|
|
507
|
+
.k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary) + .k2b-app-workspace__resize,
|
|
508
|
+
.k2b-app-workspace__resize:has(+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary))
|
|
509
|
+
),
|
|
510
|
+
.cloud-app-canvas[data-shell-focus]
|
|
511
|
+
.k2b-app-workspace:not(.k2b-app-workspace *)
|
|
512
|
+
> .k2b-app-workspace__content
|
|
513
|
+
> :is(solid-island, solid-client)
|
|
514
|
+
> .k2b-app-workspace__main
|
|
515
|
+
> :is(
|
|
516
|
+
.k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary),
|
|
517
|
+
.k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary) + .k2b-app-workspace__resize,
|
|
518
|
+
.k2b-app-workspace__resize:has(+ .k2b-app-workspace__main-pane[data-surface="navigation"]:not(.is-primary))
|
|
519
|
+
) {
|
|
520
|
+
display: none;
|
|
392
521
|
}
|
|
393
522
|
}
|
|
394
523
|
|
|
@@ -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;
|
|
@@ -750,10 +750,12 @@
|
|
|
750
750
|
@apply text-[0.6875rem] font-medium normal-case tracking-normal mb-5 text-dimmed select-none;
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
/* A rail control: a 44 px target in the 56 px rail. Its icon is 20 px
|
|
754
|
+
(`text-xl` on the icon). */
|
|
753
755
|
@utility rail-item {
|
|
754
756
|
border-right: 0;
|
|
755
757
|
border-radius: var(--ui-radius-surface);
|
|
756
|
-
@apply w-
|
|
758
|
+
@apply w-11 h-11;
|
|
757
759
|
@apply flex items-center justify-center text-dimmed;
|
|
758
760
|
@apply transition-[background-color,color] duration-150 ease-out;
|
|
759
761
|
|
|
@@ -795,6 +797,12 @@
|
|
|
795
797
|
pointer-events: none;
|
|
796
798
|
}
|
|
797
799
|
|
|
800
|
+
/* On the corner of the rail icon, not of its 44 px target. */
|
|
801
|
+
.rail-item > .cloud-app-badge {
|
|
802
|
+
top: 0.25rem;
|
|
803
|
+
right: 0.1875rem;
|
|
804
|
+
}
|
|
805
|
+
|
|
798
806
|
.app-icon > .cloud-app-badge {
|
|
799
807
|
top: -0.375rem;
|
|
800
808
|
right: -0.375rem;
|
|
@@ -841,8 +849,8 @@
|
|
|
841
849
|
.layout-profile-preferences__link,
|
|
842
850
|
.layout-profile-preferences__dropdown-trigger {
|
|
843
851
|
display: none;
|
|
844
|
-
width:
|
|
845
|
-
height:
|
|
852
|
+
width: 2.75rem;
|
|
853
|
+
height: 2.75rem;
|
|
846
854
|
align-items: center;
|
|
847
855
|
justify-content: center;
|
|
848
856
|
padding: 0;
|
|
@@ -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
|
+
};
|