Files
windmill/frontend/src/lib/components/raw_apps/utils.ts
T
GuilhemandClaude Opus 4.8 e20a27745a fix(frontend): strip raw-app post-deploy diff noise (raw_app/lock/data) (#9706)
* fix(frontend): strip raw-app post-deploy diff noise (raw_app/lock/data)

The raw-app editor's Diff drawer showed a spurious deployed-vs-current
diff immediately after deploy, even with no edits: `raw_app: true`, a
server-recomputed inline-script `lock`, and an empty `data` mismatch.

These come from comparing the deployed app row (from getAppByPath) against
the editor's current value, which differ on server-managed fields the
editor never carries, on inline-script locks (recomputed at deploy, cleared
on edit), and on `data` (the deployed row omits an empty `data` while the
editor always carries the default `{tables: []}`).

Add `stripRawAppDiffNoise` (strip server columns, null inline locks,
canonicalize data) and apply it symmetrically to both diff sides in the
editor header. For the session/compare draft diff, the draft is stored flat
(files/runnables/data top-level) while the deployed row nests under `value`,
so add `canonicalRawAppDiffValue` (= appSourceToDraftValue + stripRawAppDiffNoise)
and route both sides through it in getDraftDiffValues. Both diff surfaces now
share the same normalizer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): use canonicalized current value in deploy-drawer raw-app diff

The Deploy drawer's "Diff" action still built the current side inline from
raw editor state, bypassing stripRawAppDiffNoise — so inline-script `lock`
and data-shape noise could resurface via Deploy → Diff even though the
top-level Diff button was already fixed. Route it through `currentDiffValue`
(and strip the savedApp fallback) so both entry points behave identically.

Addresses Codex review finding on PR #9706.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:15:14 +02:00

300 lines
9.2 KiB
TypeScript

import type { ScriptLang } from '../../gen/types.gen'
import type { Schema } from '../../common'
import { schemaToTsType } from '../../schema'
import { isRunnableByName, isRunnableByPath, type RunnableWithFields } from '../apps/inputType'
import type { InlineScript } from '../apps/sharedTypes'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { appSourceToDraftValue, normalizeRawAppData } from './rawAppDraftValue'
// export type RunnableWithFields = any
type RunnableWithInlineScript = RunnableWithFields & {
inlineScript?: InlineScript & { language: ScriptLang }
}
export type Runnable = RunnableWithInlineScript | undefined
export type RawApp = {
files: string[]
}
// Server-managed columns the deployed app row (getAppByPath) carries but the
// editor's current value never does — leaving them in renders as spurious diff.
const RAW_APP_DEPLOYED_METADATA_KEYS = [
'raw_app',
'id',
'created_at',
'created_by',
'versions',
'extra_perms',
'is_draft'
] as const
/**
* Normalize a raw-app value before a deployed-vs-current diff (or unsaved-change
* comparison). Three sources of spurious post-deploy diff:
* - the deployed row carries server-managed columns (`raw_app`, timestamps, …)
* that the editor's current value lacks;
* - inline-script `lock`s are recomputed server-side at every deploy and the
* editor clears them on edit, so the editor value and the freshly deployed
* one always diverge on `lock` even though the user changed nothing there;
* - the deployed value omits an empty `data` while the editor always carries
* the default `{ tables: [] }`, so even an untouched app reads as changed.
* All three must be neutralized symmetrically on both sides. Returns a deep
* clone; never mutates the input (the current side is live editor state).
*/
export function stripRawAppDiffNoise<T extends Record<string, any>>(value: T): T {
const cloned = structuredClone(stateSnapshot(value)) as Record<string, any>
for (const key of RAW_APP_DEPLOYED_METADATA_KEYS) {
delete cloned[key]
}
// Runnables/data live under `.value` on a deployed row and on the editor's
// diff value alike, but a flat draft shape carries them top-level.
const source = cloned.value ?? cloned
const runnables = source.runnables
if (runnables && typeof runnables === 'object') {
for (const k of Object.keys(runnables)) {
const inlineScript = runnables[k]?.inlineScript
if (inlineScript && inlineScript.lock != undefined) {
inlineScript.lock = undefined
}
}
}
// Canonicalize `data` so an absent and a default-empty `data` compare equal.
source.data = normalizeRawAppData(source)
return cloned as T
}
/**
* Canonical raw-app value for diffing a *draft* against a *deployed* row. On top
* of the noise stripped by stripRawAppDiffNoise, the two also differ in shape: a
* deployed row nests its source under `value`, whereas a draft carries
* `files`/`runnables`/`data` at the top level. `appSourceToDraftValue` collapses
* both onto the same flat field set first. Use this for the session/compare
* draft diff so it matches the editor's Diff button (which shares
* stripRawAppDiffNoise).
*/
export function canonicalRawAppDiffValue(source: Record<string, any>) {
return stripRawAppDiffNoise(appSourceToDraftValue(source))
}
export type RawAppRuntimeLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
export type RawAppRuntimeLogEntry = {
level: RawAppRuntimeLogLevel
message: string
ts: number
}
export type RawAppRuntimeLogRequester = (
limit: number
) => Promise<RawAppRuntimeLogEntry[] | undefined>
const RAW_APP_RUNTIME_LOG_LEVELS = new Set<RawAppRuntimeLogLevel>([
'log',
'info',
'warn',
'error',
'debug'
])
function isRawAppRuntimeLogLevel(level: unknown): level is RawAppRuntimeLogLevel {
return typeof level === 'string' && RAW_APP_RUNTIME_LOG_LEVELS.has(level as RawAppRuntimeLogLevel)
}
function isValidRuntimeLogTimestamp(ts: unknown): ts is number {
return typeof ts === 'number' && Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime())
}
export function normalizeRawAppRuntimeLogs(logs: unknown): RawAppRuntimeLogEntry[] {
if (!Array.isArray(logs)) return []
return logs.flatMap((entry) => {
if (!entry || typeof entry !== 'object') return []
const { level, message, ts } = entry as Record<string, unknown>
if (
!isRawAppRuntimeLogLevel(level) ||
typeof message !== 'string' ||
!isValidRuntimeLogTimestamp(ts)
)
return []
return [{ level, message, ts }]
})
}
export function formatRuntimeLogsForChat(entries: RawAppRuntimeLogEntry[]): string {
const lines = entries.map((e) => {
const date = new Date(e.ts)
const time = Number.isNaN(date.getTime()) ? '--:--:--' : date.toISOString().slice(11, 23)
return `[${time}] ${e.level.toUpperCase()}: ${e.message}`
})
return lines.join('\n')
}
export type RawAppRunSummary = {
job_id: string
component: string
status: 'running' | 'completed'
created_at?: number
started_at?: number
duration_ms?: number
}
export type RawAppRunsProvider = () => RawAppRunSummary[] | undefined
export function formatAppRunsForChat(runs: RawAppRunSummary[]): string {
return JSON.stringify(runs, null, 2)
}
export function htmlContent(
workspace: string,
secret: string | undefined,
ctx: any,
baseUrl: string = '',
initialHash: string = ''
) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>App Preview</title>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
// Sync hash with parent window for shareable URLs
(function() {
// Set initial hash from parent URL
var initialHash = ${JSON.stringify(initialHash)};
if (initialHash && initialHash !== '#' && !window.location.hash) {
history.replaceState(null, '', initialHash);
}
// Notify parent when hash changes
function notifyParent() {
var hash = window.location.hash;
console.log('[HashSync] notifyParent called, hash:', hash);
if (window.parent !== window) {
window.parent.postMessage({
type: 'windmill:hashchange',
hash: hash
}, '*');
}
}
// Listen for hash changes
window.addEventListener('hashchange', function() {
console.log('[HashSync] hashchange event');
notifyParent();
});
// Also notify on pushState/replaceState
var originalPushState = history.pushState;
var originalReplaceState = history.replaceState;
history.pushState = function() {
console.log('[HashSync] pushState called with:', arguments[2]);
originalPushState.apply(this, arguments);
notifyParent();
};
history.replaceState = function() {
console.log('[HashSync] replaceState called with:', arguments[2]);
originalReplaceState.apply(this, arguments);
notifyParent();
};
// Notify parent of initial hash after load
setTimeout(notifyParent, 0);
})();
</script>
</head>
<body>
<div id="root"></div>
<script src="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.js"></script>
</body>
</html>`
}
function removeStaticFields(schema: Schema, fields: Record<string, { type: string }>): Schema {
const staticFields = Object.keys(fields).filter((k) => fields[k].type == 'static')
return {
...schema,
properties: {
...Object.fromEntries(
Object.entries(schema.properties ?? {}).filter(([k]) => !staticFields.includes(k))
)
}
}
}
function hiddenRunnableToTsType(runnable: Runnable) {
if (isRunnableByName(runnable)) {
if (runnable?.inlineScript?.schema) {
return schemaToTsType(
removeStaticFields(runnable?.inlineScript?.schema, runnable?.fields ?? {})
)
} else {
return '{}'
}
} else if (isRunnableByPath(runnable)) {
if (runnable?.schema) {
return schemaToTsType(removeStaticFields(runnable.schema as Schema, runnable?.fields ?? {}))
} else {
return '{}'
}
} else {
return '{}'
}
}
export function genWmillTs(runnables: Record<string, Runnable>) {
return `// THIS FILE IS READ-ONLY
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
export declare const backend: {
${Object.entries(runnables)
.map(([k, v]) => ` ${k}: (args: ${hiddenRunnableToTsType(v)}) => Promise<any>;`)
.join('\n')}
};
export declare const backendAsync: {
${Object.entries(runnables)
.map(([k, v]) => ` ${k}: (args: ${hiddenRunnableToTsType(v)}) => Promise<string>;`)
.join('\n')}
};
export type Job = {
type: "QueuedJob" | "CompletedJob";
id: string;
created_at: number;
started_at: number | undefined;
duration_ms: number;
success: boolean;
args: any;
result: any;
};
/**
* Execute a job and wait for it to complete and return the completed job
* @param id
*/
export declare function waitJob(id: string): Promise<Job>;
/**
* Get a job by id and return immediately with the current state of the job
* @param id
*/
export declare function getJob(id: string): Promise<Job>;
export type StreamUpdate = {
new_result_stream?: string;
stream_offset?: number;
};
/**
* Stream job results using SSE. Calls onUpdate for each stream update,
* and resolves with the final result when the job completes.
* @param id - The job ID to stream
* @param onUpdate - Optional callback for stream updates with new_result_stream data
* @returns Promise that resolves with the final job result
*/
export declare function streamJob(id: string, onUpdate?: (data: StreamUpdate) => void): Promise<any>;
`
}