@k2b/cloud 0.6.0-rc.0 → 0.7.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/README.md +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AiBackgroundAdmissionError, AiBackgroundCostError } from "../../ai/inference-calls";
|
|
1
2
|
import { StructuredOutputError } from "@k2b/nessi";
|
|
2
3
|
import type { Job, Worker } from "@k2b/sync";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -96,7 +97,7 @@ export const executeWorkflowAiRequest = async (task: WorkflowAiTask, runStructur
|
|
|
96
97
|
const request = task.request;
|
|
97
98
|
const common = {
|
|
98
99
|
appId: task.appId,
|
|
99
|
-
attribution: { workflowRunId: task.runId, userId: task.usageUserId },
|
|
100
|
+
attribution: { workflowRunId: task.runId, stepKey: task.stepKey, userId: task.usageUserId },
|
|
100
101
|
requestedModelId: task.modelProfileId,
|
|
101
102
|
signal,
|
|
102
103
|
temperature: 0,
|
|
@@ -159,12 +160,15 @@ export const executeWorkflowAiRequest = async (task: WorkflowAiTask, runStructur
|
|
|
159
160
|
};
|
|
160
161
|
|
|
161
162
|
const retryableError = (error: unknown): boolean => {
|
|
163
|
+
if (error instanceof AiBackgroundAdmissionError) return error.retryable;
|
|
164
|
+
if (error instanceof AiBackgroundCostError) return false;
|
|
162
165
|
if (isAiSettingsError(error)) return false;
|
|
163
166
|
if (error instanceof StructuredOutputError) return error.code === "loop_failed";
|
|
164
167
|
return true;
|
|
165
168
|
};
|
|
166
169
|
|
|
167
170
|
const errorCode = (error: unknown): string => {
|
|
171
|
+
if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) return error.code;
|
|
168
172
|
if (isAiSettingsError(error)) return error.aiError.code.toUpperCase();
|
|
169
173
|
if (error instanceof StructuredOutputError) return `WORKFLOW_AI_${error.code.toUpperCase()}`;
|
|
170
174
|
return "WORKFLOW_AI_PROVIDER_ERROR";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { notifyWorkflowWorker } from "../store/worker-runtime";
|
|
1
2
|
import { type SQL, sql } from "bun";
|
|
2
3
|
import type { WorkflowJsonValue } from "../contracts";
|
|
3
4
|
import { wakeWorkflowRunsWaitingOn } from "../store/runs";
|
|
@@ -225,6 +226,7 @@ export const workflowAiTaskCancellationRequested = async (id: string, db: SQL =
|
|
|
225
226
|
|
|
226
227
|
export const wakeWorkflowAiTask = async (task: Pick<WorkflowAiTask, "appId" | "id">): Promise<void> => {
|
|
227
228
|
await wakeWorkflowRunsWaitingOn({ appId: task.appId, kind: WORKFLOW_AI_DEPENDENCY_KIND, key: task.id });
|
|
229
|
+
notifyWorkflowWorker(task.appId);
|
|
228
230
|
};
|
|
229
231
|
|
|
230
232
|
export const markWorkflowAiTaskCanceledIfRequested = async (id: string, db: SQL = sql): Promise<WorkflowAiTask | null> => {
|
|
@@ -139,27 +139,27 @@ export const runWorkflowAiAction = async (
|
|
|
139
139
|
}
|
|
140
140
|
};
|
|
141
141
|
|
|
142
|
-
export const
|
|
142
|
+
export const workflowAiActionCost = async (
|
|
143
143
|
ctx: Pick<WorkflowActionContext, "effectKey">,
|
|
144
|
-
summary: string,
|
|
145
144
|
injected?: Pick<WorkflowAiActionDependencies, "exists">,
|
|
146
|
-
): Promise<
|
|
145
|
+
): Promise<Record<string, number>> => {
|
|
147
146
|
const deps = injected ?? (await dependencies());
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
summary,
|
|
151
|
-
...(exists ? {} : { consumes: { maxAiCalls: 1 } }),
|
|
152
|
-
issues: ["AI output is not available during a dry run."],
|
|
153
|
-
};
|
|
147
|
+
return (await deps.exists(ctx.effectKey)) ? {} : { maxAiCalls: 1 };
|
|
154
148
|
};
|
|
155
149
|
|
|
150
|
+
const planWorkflowAiAction = async (summary: string): Promise<WorkflowPlannedEffect> => ({
|
|
151
|
+
summary,
|
|
152
|
+
issues: ["AI output is not available during a dry run."],
|
|
153
|
+
});
|
|
154
|
+
|
|
156
155
|
export const AI_WORKFLOW_ACTIONS = {
|
|
157
156
|
aiExtractData: workflowAction.idempotent<typeof extractDataConfig, WorkflowJsonValue>({
|
|
158
157
|
label: "AI extract structured data",
|
|
159
158
|
description: "Extracts one bounded, validated object from supplied input.",
|
|
160
159
|
outputType: "core.value",
|
|
161
160
|
config: extractDataConfig,
|
|
162
|
-
|
|
161
|
+
cost: (ctx) => workflowAiActionCost(ctx),
|
|
162
|
+
plan: () => planWorkflowAiAction("Extract structured data with AI."),
|
|
163
163
|
run: (ctx, values) =>
|
|
164
164
|
runWorkflowAiAction(ctx, {
|
|
165
165
|
kind: "extract_data",
|
|
@@ -190,7 +190,8 @@ export const AI_WORKFLOW_ACTIONS = {
|
|
|
190
190
|
saveAs: identifier("Variable name for the generated text."),
|
|
191
191
|
},
|
|
192
192
|
},
|
|
193
|
-
|
|
193
|
+
cost: (ctx) => workflowAiActionCost(ctx),
|
|
194
|
+
plan: () => planWorkflowAiAction("Generate text with AI."),
|
|
194
195
|
run: (ctx, values) =>
|
|
195
196
|
runWorkflowAiAction(ctx, {
|
|
196
197
|
kind: "generate_text",
|
|
@@ -214,7 +215,8 @@ export const AI_WORKFLOW_ACTIONS = {
|
|
|
214
215
|
saveAs: identifier("Variable name for the selected choice."),
|
|
215
216
|
},
|
|
216
217
|
},
|
|
217
|
-
|
|
218
|
+
cost: (ctx) => workflowAiActionCost(ctx),
|
|
219
|
+
plan: () => planWorkflowAiAction("Classify one value with AI."),
|
|
218
220
|
run: (ctx, values) =>
|
|
219
221
|
runWorkflowAiAction(ctx, {
|
|
220
222
|
kind: "classify",
|
|
@@ -254,7 +256,8 @@ export const AI_WORKFLOW_ACTIONS = {
|
|
|
254
256
|
saveAs: identifier("Variable name for the selected choices."),
|
|
255
257
|
},
|
|
256
258
|
},
|
|
257
|
-
|
|
259
|
+
cost: (ctx) => workflowAiActionCost(ctx),
|
|
260
|
+
plan: () => planWorkflowAiAction("Classify multiple values with AI."),
|
|
258
261
|
run: (ctx, values) =>
|
|
259
262
|
runWorkflowAiAction(ctx, {
|
|
260
263
|
kind: "classify_many",
|
|
@@ -136,8 +136,6 @@ export type WorkflowActionResult<Output> =
|
|
|
136
136
|
export type WorkflowPlannedEffect = {
|
|
137
137
|
/** Human-readable summary shown in the dry-run view. */
|
|
138
138
|
summary: string;
|
|
139
|
-
/** Counts consumed from the effect budget, keyed by budget dimension. */
|
|
140
|
-
consumes?: Record<string, number>;
|
|
141
139
|
/**
|
|
142
140
|
* What the step would produce.
|
|
143
141
|
*
|
|
@@ -202,8 +200,10 @@ export type WorkflowActionContext = {
|
|
|
202
200
|
tx?: SQL;
|
|
203
201
|
/** Stable across replays of the same step — the key an idempotent effect uses. */
|
|
204
202
|
effectKey: string;
|
|
205
|
-
/** Keeps a long-running action's lease alive.
|
|
206
|
-
|
|
203
|
+
/** Keeps a long-running action's lease alive. Pass the application's current
|
|
204
|
+
* transaction to fence its writes against cancellation and worker takeover
|
|
205
|
+
* until commit. Do not call an ambient heartbeat while holding that fence. */
|
|
206
|
+
heartbeat(tx?: SQL): Promise<void>;
|
|
207
207
|
};
|
|
208
208
|
|
|
209
209
|
// ─── Action definition ───────────────────────────────────────────────────────
|
|
@@ -218,6 +218,8 @@ type RunHook<Config, Output> = (ctx: WorkflowActionContext, config: Config) => P
|
|
|
218
218
|
*/
|
|
219
219
|
type AuthorizeHook<Config> = (ctx: WorkflowActionContext, config: Config) => Promise<boolean>;
|
|
220
220
|
type PlanHook<Config> = (ctx: WorkflowActionContext, config: Config) => Promise<WorkflowPlannedEffect>;
|
|
221
|
+
/** Cheap, side-effect-free budget counts; shared by execution and dry runs. */
|
|
222
|
+
type CostHook<Config> = (ctx: WorkflowActionContext, config: Config) => Record<string, number> | Promise<Record<string, number>>;
|
|
221
223
|
|
|
222
224
|
/**
|
|
223
225
|
* `NoInfer` because `run` alone defines what an action produces. Reconcile
|
|
@@ -238,6 +240,7 @@ export type WorkflowActionDefinition<Effect extends WorkflowEffectClass, Schema
|
|
|
238
240
|
run: RunHook<FromFieldSchema<Schema>, Output>;
|
|
239
241
|
/** What a dry run reports. Required for everything that leaves the process. */
|
|
240
242
|
plan?: PlanHook<FromFieldSchema<Schema>>;
|
|
243
|
+
cost?: CostHook<FromFieldSchema<Schema>>;
|
|
241
244
|
/** Asks afterwards whether an ambiguous effect landed. */
|
|
242
245
|
reconcile?: ReconcileHook<Output>;
|
|
243
246
|
/** Re-checks permission at the moment of the effect, on the effect's own handle. */
|
|
@@ -250,6 +253,7 @@ type ActionBase<Schema extends ObjectSchema> = {
|
|
|
250
253
|
description: string;
|
|
251
254
|
config: Schema;
|
|
252
255
|
outputType?: string;
|
|
256
|
+
cost?: CostHook<FromFieldSchema<Schema>>;
|
|
253
257
|
};
|
|
254
258
|
|
|
255
259
|
/**
|
|
@@ -270,7 +274,7 @@ type ActionBase<Schema extends ObjectSchema> = {
|
|
|
270
274
|
export const workflowAction = {
|
|
271
275
|
/** Deterministic given inputs and prior outcomes. Reading mutable state is not pure. */
|
|
272
276
|
pure: <const Schema extends ObjectSchema, Output = void>(
|
|
273
|
-
definition: ActionBase<Schema> & {
|
|
277
|
+
definition: Omit<ActionBase<Schema>, "cost"> & {
|
|
274
278
|
run: RunHook<FromFieldSchema<Schema>, Output>;
|
|
275
279
|
},
|
|
276
280
|
): WorkflowActionDefinition<"pure", Schema, Output> => ({ ...definition, effect: "pure" }),
|
|
@@ -327,6 +331,7 @@ export type ErasedWorkflowAction = {
|
|
|
327
331
|
outputType?: string;
|
|
328
332
|
run: RunHook<never, unknown>;
|
|
329
333
|
plan?: PlanHook<never>;
|
|
334
|
+
cost?: CostHook<never>;
|
|
330
335
|
reconcile?: ReconcileHook<unknown>;
|
|
331
336
|
authorize?: AuthorizeHook<never>;
|
|
332
337
|
};
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* started, settle it afterwards. Both apps did, slightly differently, which is
|
|
8
8
|
* how one of them ended up with no budget at all.
|
|
9
9
|
*
|
|
10
|
-
* The budget is charged from the action's own `
|
|
10
|
+
* The budget is charged from the action's own `cost` hook — the same hook a dry
|
|
11
11
|
* run uses. That is the point: preflight and execution cannot disagree about
|
|
12
12
|
* what an action costs, because one function answers for both. Mail's known
|
|
13
13
|
* defect was exactly that divergence, and here it is not a bug to fix but a
|
|
14
14
|
* shape that cannot occur.
|
|
15
15
|
*/
|
|
16
|
-
import
|
|
16
|
+
import { SQL } from "bun";
|
|
17
17
|
import { type WorkflowDependency, type WorkflowJsonValue, type WorkflowStepOutcome, workflowPathKey } from "../contracts";
|
|
18
18
|
import type { ErasedWorkflowAction, WorkflowActionContext } from "../definition";
|
|
19
19
|
import type { DefinedWorkflowModule } from "../module";
|
|
@@ -25,7 +25,16 @@ import type {
|
|
|
25
25
|
WorkflowExecuteActionPort,
|
|
26
26
|
} from "../runtime/ports";
|
|
27
27
|
import { budgetError, budgetRootRunId, chargeWorkflowEffectBudget } from "./budget";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
beginWorkflowEffect,
|
|
30
|
+
markWorkflowStepBudgetCharged,
|
|
31
|
+
readWorkflowEffect,
|
|
32
|
+
recordWorkflowEffect,
|
|
33
|
+
renewWorkflowRunLease,
|
|
34
|
+
settleWorkflowEffect,
|
|
35
|
+
WorkflowLeaseLostError,
|
|
36
|
+
workflowStepBudgetCharged,
|
|
37
|
+
} from "./runs";
|
|
29
38
|
import { withTransaction } from "./transaction";
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -99,8 +108,14 @@ const actionContext = (
|
|
|
99
108
|
resolveReference: (reference: string, ...path: Array<string | number>): Promise<WorkflowJsonValue | undefined> =>
|
|
100
109
|
ctx.resolveReference(reference, configPath(path)),
|
|
101
110
|
variableSnapshot: (): Record<string, WorkflowJsonValue> => ctx.variables.snapshot?.() ?? {},
|
|
102
|
-
heartbeat: async (): Promise<void> => {
|
|
111
|
+
heartbeat: async (transaction?: SQL): Promise<void> => {
|
|
112
|
+
if (!transaction) return ctx.heartbeat();
|
|
113
|
+
// The generation check and UPDATE lock live on the caller's transaction,
|
|
114
|
+
// so takeover/cancellation cannot interleave with its domain writes.
|
|
115
|
+
const lease = await renewWorkflowRunLease(ctx.run, { db: transaction });
|
|
116
|
+
if (lease.state === "active") return;
|
|
103
117
|
await ctx.heartbeat();
|
|
118
|
+
throw new WorkflowLeaseLostError();
|
|
104
119
|
},
|
|
105
120
|
};
|
|
106
121
|
};
|
|
@@ -170,7 +185,7 @@ export type WorkflowActionPortOptions = {
|
|
|
170
185
|
/**
|
|
171
186
|
* Runs one declared action, with everything its effect class implies.
|
|
172
187
|
*
|
|
173
|
-
* The order is the contract:
|
|
188
|
+
* The order is the contract: estimate costs, charge, mark, act, then
|
|
174
189
|
* settle. Charging before acting is the only ordering that cannot overspend,
|
|
175
190
|
* and marking before acting is the only one that leaves evidence when the
|
|
176
191
|
* process dies mid-effect.
|
|
@@ -186,20 +201,22 @@ const runDeclaredAction = async (
|
|
|
186
201
|
const effectKey = workflowEffectKey(ctx.run.runId, ctx.step.key);
|
|
187
202
|
const journal = options.journal ?? kernelEffectJournal(options.db);
|
|
188
203
|
|
|
189
|
-
/**
|
|
190
|
-
* Asks the action what it would do, and charges the run for it.
|
|
191
|
-
*
|
|
192
|
-
* Only reached when there is a budget to charge: the answer is otherwise
|
|
193
|
-
* discarded, and `plan` is allowed to be expensive — an HTTP action resolves
|
|
194
|
-
* its target to check it is safe to call.
|
|
195
|
-
*/
|
|
204
|
+
/** Charge declared costs without building a dry-run preview. */
|
|
196
205
|
const charge = async (db?: SQL): Promise<Extract<WorkflowStepOutcome, { state: "failed" }> | null> => {
|
|
197
|
-
if (options.budget === false || !action.
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
206
|
+
if (options.budget === false || !action.cost) return null;
|
|
207
|
+
const consumes = await action.cost(actionContext(ctx, step, effectKey, db), config as never);
|
|
208
|
+
if (Object.keys(consumes).length === 0) return null;
|
|
200
209
|
const handle = db ?? options.db;
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
// Once per step, not once per attempt: a step that parked on a dependency
|
|
211
|
+
// runs again to observe the effect it already paid for, and charging that
|
|
212
|
+
// resume refuses the automation it already performed.
|
|
213
|
+
const charged = await withTransaction(handle, async (tx) => {
|
|
214
|
+
if (await workflowStepBudgetCharged(tx, journalStep)) return { state: "ok" as const, used: {} };
|
|
215
|
+
const root = await budgetRootRunId(ctx.run.runId, { db: tx });
|
|
216
|
+
const outcome = await chargeWorkflowEffectBudget(root, consumes, { db: tx });
|
|
217
|
+
if (outcome.state === "ok") await markWorkflowStepBudgetCharged(tx, journalStep);
|
|
218
|
+
return outcome;
|
|
219
|
+
});
|
|
203
220
|
if (charged.state !== "exceeded") return null;
|
|
204
221
|
const error = budgetError(charged);
|
|
205
222
|
return {
|
|
@@ -282,6 +299,14 @@ const runDeclaredAction = async (
|
|
|
282
299
|
return { state: "waiting", dependency: error.dependency } satisfies WorkflowStepOutcome;
|
|
283
300
|
if (error instanceof WorkflowTransactionalFailure)
|
|
284
301
|
return { state: "failed", error: reportedError(error.failure) } satisfies WorkflowStepOutcome;
|
|
302
|
+
// These PostgreSQL errors guarantee that this transaction did not commit.
|
|
303
|
+
// Classify only after rollback; connection failures have an ambiguous
|
|
304
|
+
// commit outcome and must not enter this retry path.
|
|
305
|
+
if (error instanceof SQL.PostgresError && (error.errno === "40001" || error.errno === "40P01"))
|
|
306
|
+
return {
|
|
307
|
+
state: "failed",
|
|
308
|
+
error: asError("Transaction conflicted; retrying the step.", true, "WORKFLOW_TRANSACTION_CONFLICT"),
|
|
309
|
+
} satisfies WorkflowStepOutcome;
|
|
285
310
|
throw error;
|
|
286
311
|
});
|
|
287
312
|
}
|
|
@@ -405,13 +430,12 @@ export const createWorkflowDryRunPort = (module: DefinedWorkflowModule): Workflo
|
|
|
405
430
|
if (!action.plan) return { state: "unsupported" as const, reason: `${name} cannot be planned` };
|
|
406
431
|
|
|
407
432
|
const planned = await action.plan(context, config as never);
|
|
433
|
+
const consumes = await action.cost?.(context, config as never);
|
|
408
434
|
applySaveAs(ctx, step, (planned.output ?? undefined) as WorkflowJsonValue | undefined);
|
|
409
435
|
return {
|
|
410
436
|
state: "planned" as const,
|
|
411
437
|
output: (planned.output ?? null) as WorkflowJsonValue,
|
|
412
|
-
effects: [
|
|
413
|
-
{ action: name, summary: planned.summary, ...(planned.consumes ? { consumes: planned.consumes } : {}) } as WorkflowJsonValue,
|
|
414
|
-
],
|
|
438
|
+
effects: [{ action: name, summary: planned.summary, ...(consumes ? { consumes } : {}) } as WorkflowJsonValue],
|
|
415
439
|
// An issue names the step it belongs to, so the dry-run view can point
|
|
416
440
|
// at what could not be determined rather than listing loose strings.
|
|
417
441
|
...(planned.issues?.length
|
|
@@ -831,6 +831,42 @@ export const recordWorkflowEffect = async (
|
|
|
831
831
|
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
832
832
|
};
|
|
833
833
|
|
|
834
|
+
/**
|
|
835
|
+
* Whether this step's effect budget was already charged.
|
|
836
|
+
*
|
|
837
|
+
* A step is executed more than once by design: it parks on a dependency and
|
|
838
|
+
* runs again when that dependency fires, and the second attempt adopts the
|
|
839
|
+
* effect the first one performed instead of performing it again. Charging per
|
|
840
|
+
* attempt therefore refused exactly the automations whose allowance is sized
|
|
841
|
+
* per effect — `maxMoves: 1` was spent by the move, and the resume that only
|
|
842
|
+
* observed its result was rejected as overspending.
|
|
843
|
+
*
|
|
844
|
+
* A run whose journal lives outside this table — an app adopting declared
|
|
845
|
+
* actions before its runs do — has no row here and keeps the per-attempt
|
|
846
|
+
* behaviour rather than silently going uncharged.
|
|
847
|
+
*/
|
|
848
|
+
export const workflowStepBudgetCharged = async (db: SQL, step: { runId: string; key: string }): Promise<boolean> => {
|
|
849
|
+
const [row] = await db<{ budget_charged_at: Date | null }[]>`
|
|
850
|
+
SELECT budget_charged_at FROM workflows.step_outcome
|
|
851
|
+
WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key}
|
|
852
|
+
`;
|
|
853
|
+
return Boolean(row?.budget_charged_at);
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Records that this step paid for its effect.
|
|
858
|
+
*
|
|
859
|
+
* Written on the handle that charged, so a charge that unwinds — a
|
|
860
|
+
* transactional action that ends up waiting — takes this with it.
|
|
861
|
+
*/
|
|
862
|
+
export const markWorkflowStepBudgetCharged = async (db: SQL, step: { runId: string; key: string }): Promise<void> => {
|
|
863
|
+
await db`
|
|
864
|
+
UPDATE workflows.step_outcome
|
|
865
|
+
SET budget_charged_at = now(), updated_at = now()
|
|
866
|
+
WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key} AND budget_charged_at IS NULL
|
|
867
|
+
`;
|
|
868
|
+
};
|
|
869
|
+
|
|
834
870
|
/** Settles an effect once its fate is known. `ambiguous` is a real answer, not a failure. */
|
|
835
871
|
export const settleWorkflowEffect = async (
|
|
836
872
|
step: { runId: string; key: string; executionGeneration: number },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Bounded claim loops. A free slot never waits for another slot's action. */
|
|
2
|
+
export const createWorkflowWorkerPool = (options: {
|
|
3
|
+
concurrency: number;
|
|
4
|
+
/** True when a run was handled; false when no run was claimable. */
|
|
5
|
+
run(): Promise<boolean>;
|
|
6
|
+
onError(error: unknown): void;
|
|
7
|
+
}) => {
|
|
8
|
+
if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
|
|
9
|
+
throw new RangeError("Workflow concurrency must be a positive integer");
|
|
10
|
+
}
|
|
11
|
+
const tasks = new Set<Promise<void>>();
|
|
12
|
+
let accepting = false;
|
|
13
|
+
let generation = 0;
|
|
14
|
+
|
|
15
|
+
const wake = (): void => {
|
|
16
|
+
if (!accepting) return;
|
|
17
|
+
generation += 1;
|
|
18
|
+
while (tasks.size < options.concurrency) {
|
|
19
|
+
let observed = generation;
|
|
20
|
+
let failed = false;
|
|
21
|
+
const task = Promise.resolve().then(async () => {
|
|
22
|
+
try {
|
|
23
|
+
while (accepting) {
|
|
24
|
+
observed = generation;
|
|
25
|
+
const handled = await options.run();
|
|
26
|
+
// A wake received during an empty claim needs another attempt.
|
|
27
|
+
if (!handled && observed === generation) return;
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
failed = true;
|
|
31
|
+
options.onError(error);
|
|
32
|
+
// Recovery wakes us again; never spin on a failing database.
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
tasks.add(task);
|
|
36
|
+
const finished = () => {
|
|
37
|
+
tasks.delete(task);
|
|
38
|
+
if (!failed && observed !== generation) wake();
|
|
39
|
+
};
|
|
40
|
+
void task.then(finished, finished);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
start: (): void => {
|
|
46
|
+
accepting = true;
|
|
47
|
+
wake();
|
|
48
|
+
},
|
|
49
|
+
wake,
|
|
50
|
+
stop: async (): Promise<void> => {
|
|
51
|
+
accepting = false;
|
|
52
|
+
await Promise.allSettled([...tasks]);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { lazySync } from "../../_internal/process-sync";
|
|
2
|
+
import { logger } from "../../services/logging";
|
|
3
|
+
import { createRuntimeLifecycle, superviseRuntimeTask } from "../../services/runtime-lifecycle";
|
|
4
|
+
import { createWorkflowWorkerPool } from "./worker-pool";
|
|
5
|
+
|
|
6
|
+
const log = logger("workflows:worker");
|
|
7
|
+
const wakeTopic = lazySync((sync) =>
|
|
8
|
+
sync.topic<null>({
|
|
9
|
+
id: "cloud:workflow-wake",
|
|
10
|
+
owner: "cloud",
|
|
11
|
+
retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
|
|
12
|
+
maxPayloadBytes: 2000,
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
const localWorkers = new Map<string, Set<() => void>>();
|
|
16
|
+
|
|
17
|
+
/** Call after the application's transaction commits. Postgres remains the work queue. */
|
|
18
|
+
export const notifyWorkflowWorker = (appId: string): void => {
|
|
19
|
+
for (const wake of localWorkers.get(appId) ?? []) wake();
|
|
20
|
+
// Hints are best effort. Neither broker latency nor failure delays an accepted request.
|
|
21
|
+
void Promise.resolve()
|
|
22
|
+
.then(() => wakeTopic().publish({ tenantId: appId, data: null }))
|
|
23
|
+
.catch((error) => {
|
|
24
|
+
log.warn("Workflow wake notification failed", { appId, error: String(error) });
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Event-driven claims plus a recovery scan for lost hints, deadlines and expired leases. */
|
|
29
|
+
export const createWorkflowWorker = (options: {
|
|
30
|
+
appId: string;
|
|
31
|
+
concurrency: number;
|
|
32
|
+
run(): Promise<boolean>;
|
|
33
|
+
recover(): Promise<unknown>;
|
|
34
|
+
}) => {
|
|
35
|
+
const report = (error: unknown) => log.warn("Workflow worker failed", { appId: options.appId, error: String(error) });
|
|
36
|
+
const pool = createWorkflowWorkerPool({ ...options, onError: report });
|
|
37
|
+
let controller: AbortController | undefined;
|
|
38
|
+
let reader: Promise<void> | undefined;
|
|
39
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
40
|
+
let recovery: Promise<void> | undefined;
|
|
41
|
+
let requested = 0;
|
|
42
|
+
const wake = () => {
|
|
43
|
+
if (!controller || controller.signal.aborted) return;
|
|
44
|
+
pool.wake();
|
|
45
|
+
requested += 1;
|
|
46
|
+
recovery ??= Promise.resolve().then(async () => {
|
|
47
|
+
try {
|
|
48
|
+
let observed: number;
|
|
49
|
+
do {
|
|
50
|
+
observed = requested;
|
|
51
|
+
await options.recover();
|
|
52
|
+
pool.wake();
|
|
53
|
+
} while (!controller?.signal.aborted && observed !== requested);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
report(error);
|
|
56
|
+
} finally {
|
|
57
|
+
recovery = undefined;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
return createRuntimeLifecycle({
|
|
62
|
+
start: async () => {
|
|
63
|
+
controller = new AbortController();
|
|
64
|
+
const workers = localWorkers.get(options.appId) ?? new Set();
|
|
65
|
+
workers.add(wake);
|
|
66
|
+
localWorkers.set(options.appId, workers);
|
|
67
|
+
pool.start();
|
|
68
|
+
reader = superviseRuntimeTask({
|
|
69
|
+
name: "Workflow wake reader",
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
onError: ({ error }) => report(error),
|
|
72
|
+
run: async (signal) => {
|
|
73
|
+
for await (const _event of wakeTopic().live({ tenantId: options.appId, signal })) wake();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
// Keep the existing one-second recovery bound; execution does not wait for it.
|
|
77
|
+
timer = setInterval(wake, 1_000);
|
|
78
|
+
wake();
|
|
79
|
+
},
|
|
80
|
+
stop: async () => {
|
|
81
|
+
clearInterval(timer);
|
|
82
|
+
controller?.abort();
|
|
83
|
+
const workers = localWorkers.get(options.appId);
|
|
84
|
+
workers?.delete(wake);
|
|
85
|
+
if (workers?.size === 0) localWorkers.delete(options.appId);
|
|
86
|
+
await Promise.all([pool.stop(), recovery, reader]);
|
|
87
|
+
controller = undefined;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { renderAppFavicon, tablerIconName, writeAppFavicon } from "./app-favicon";
|
|
6
|
-
|
|
7
|
-
describe("app favicon", () => {
|
|
8
|
-
test("extracts the documented Tabler icon class", () => {
|
|
9
|
-
expect(tablerIconName("ti ti-mail")).toBe("mail");
|
|
10
|
-
expect(() => tablerIconName("mail")).toThrow('such as "ti ti-mail"');
|
|
11
|
-
expect(() => tablerIconName("ti ti-mail extra")).toThrow('such as "ti ti-mail"');
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test("renders a large icon with the Cloud gradient", () => {
|
|
15
|
-
const source = '<svg viewBox="0 0 24 24"><path d="M3 7h18" /></svg>';
|
|
16
|
-
|
|
17
|
-
const svg = renderAppFavicon(source);
|
|
18
|
-
expect(svg).toContain('<linearGradient id="cloud-icon"');
|
|
19
|
-
expect(svg).toContain(".start { stop-color: #3b82f6; }");
|
|
20
|
-
expect(svg).toContain(".end { stop-color: #1d4ed8; }");
|
|
21
|
-
expect(svg).toContain("@media (prefers-color-scheme: dark)");
|
|
22
|
-
expect(svg).toContain(".start { stop-color: #f0f6ff; }");
|
|
23
|
-
expect(svg).toContain(".end { stop-color: #1f8bff; }");
|
|
24
|
-
expect(svg).toContain('stroke="url(#cloud-icon)"');
|
|
25
|
-
expect(svg).toContain('stroke-width="2"');
|
|
26
|
-
expect(svg).not.toContain("<rect");
|
|
27
|
-
expect(svg).not.toContain("<filter");
|
|
28
|
-
expect(svg).toContain('<path d="M3 7h18" />');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("writes a declared Tabler icon and rejects an unknown one", async () => {
|
|
32
|
-
const publicDir = await mkdtemp(join(tmpdir(), "cloud-app-favicon-"));
|
|
33
|
-
try {
|
|
34
|
-
const target = await writeAppFavicon({ publicDir, appId: "mail", icon: "ti ti-mail" });
|
|
35
|
-
const svg = await readFile(target, "utf8");
|
|
36
|
-
expect(svg).toContain('stroke="url(#cloud-icon)"');
|
|
37
|
-
expect(svg).toContain('d="M3 7l9 6l9 -6"');
|
|
38
|
-
|
|
39
|
-
const tableTarget = await writeAppFavicon({ publicDir, appId: "grids", icon: "ti ti-table" });
|
|
40
|
-
expect(await readFile(tableTarget, "utf8")).toContain('d="M3 10h18"');
|
|
41
|
-
|
|
42
|
-
await expect(writeAppFavicon({ publicDir, appId: "missing", icon: "ti ti-definitely-not-real" })).rejects.toThrow(
|
|
43
|
-
'Unknown Tabler app icon "ti ti-definitely-not-real"',
|
|
44
|
-
);
|
|
45
|
-
} finally {
|
|
46
|
-
await rm(publicDir, { recursive: true, force: true });
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
});
|