mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
Merge remote-tracking branch 'origin/main' into feat/asset-graph-view
# Conflicts: # frontend/src/lib/components/CompareDrafts.svelte
This commit is contained in:
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [1.729.0](https://github.com/windmill-labs/windmill/compare/v1.728.1...v1.729.0) (2026-06-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ducklake schema support to the database manager ([#9633](https://github.com/windmill-labs/windmill/issues/9633)) ([3eeccaf](https://github.com/windmill-labs/windmill/commit/3eeccaf9682b7803fdf5be8dcbc4d243e0ba2e49))
|
||||
* **ai-chat:** self-hosted docs tools via windmill.dev llms.txt + ask benchmark ([#9578](https://github.com/windmill-labs/windmill/issues/9578)) ([f4425fc](https://github.com/windmill-labs/windmill/commit/f4425fca9fb0d02b845bd72888ade54905c5a30b))
|
||||
* **frontend:** View Diff and in-place Load for other users' drafts ([#9621](https://github.com/windmill-labs/windmill/issues/9621)) ([5508f1d](https://github.com/windmill-labs/windmill/commit/5508f1da9cd04c2583eb3f7ee6bce19d067f2227))
|
||||
* per-user draft review & deploy page (gating, badges, rename, raw-app deploy fixes) ([#9625](https://github.com/windmill-labs/windmill/issues/9625)) ([e09cd58](https://github.com/windmill-labs/windmill/commit/e09cd5862cb636e143027fe8d9a5be9c7097b031))
|
||||
* queue messages typed while ai chat is streaming ([#9525](https://github.com/windmill-labs/windmill/issues/9525)) ([51bd869](https://github.com/windmill-labs/windmill/commit/51bd8692a482850f7ac8b04dd16db5876336b5b9))
|
||||
* zero-setup oauth client credentials for registry providers ([#9559](https://github.com/windmill-labs/windmill/issues/9559)) ([e26a923](https://github.com/windmill-labs/windmill/commit/e26a9239a62a25abf90ef06ade4dde7f36e791bb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ai_evals:** adapt global eval harness to DB-backed user drafts ([#9641](https://github.com/windmill-labs/windmill/issues/9641)) ([e87ff79](https://github.com/windmill-labs/windmill/commit/e87ff79ecf6a6e0958916ed1b3756fb3addf719f))
|
||||
* **drafts:** preserve original timestamp when migrating localStorage drafts ([#9638](https://github.com/windmill-labs/windmill/issues/9638)) ([8021775](https://github.com/windmill-labs/windmill/commit/8021775f5f961ef6fd01b022639b85855326a1da))
|
||||
* **frontend:** don't save drafts on leave when auto-save is off, warn instead ([#9630](https://github.com/windmill-labs/windmill/issues/9630)) ([2523465](https://github.com/windmill-labs/windmill/commit/252346500945a9571af744c839ac0c7d6870504f))
|
||||
* **frontend:** render Modal2 dialogs above the AI chat panel ([#9636](https://github.com/windmill-labs/windmill/issues/9636)) ([b67c8cf](https://github.com/windmill-labs/windmill/commit/b67c8cf42b477575fc1bc448058ec0d3b7e54fee))
|
||||
* **frontend:** show AI sessions when AI unconfigured, with disabled chat ([#9644](https://github.com/windmill-labs/windmill/issues/9644)) ([ba69d81](https://github.com/windmill-labs/windmill/commit/ba69d8147b615e160cf3d2885fc65a0777b78b71))
|
||||
* **git-sync:** bump default sync script to hub/28719 (windmill-cli 1.728.1) for WAC modules ([#9649](https://github.com/windmill-labs/windmill/issues/9649)) ([3c0e38b](https://github.com/windmill-labs/windmill/commit/3c0e38b5890d77983cb5cf5f422a62a73e7a4f22))
|
||||
|
||||
## [1.728.1](https://github.com/windmill-labs/windmill/compare/v1.728.0...v1.728.1) (2026-06-17)
|
||||
|
||||
|
||||
|
||||
@@ -96,7 +96,12 @@ async function getModeRunner(
|
||||
}
|
||||
|
||||
function parseMode(value: string | undefined): FrontendBenchmarkMode {
|
||||
if (value === "flow" || value === "app" || value === "script" || value === "global") {
|
||||
if (
|
||||
value === "flow" ||
|
||||
value === "app" ||
|
||||
value === "script" ||
|
||||
value === "global"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
|
||||
import {
|
||||
clearGlobalDrafts,
|
||||
getGlobalDraft,
|
||||
listGlobalDrafts,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
@@ -18,6 +19,7 @@ import type { GlobalDraftState } from "../../../../core/validators";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
import {
|
||||
registerBenchmarkWorkspaceRunnables,
|
||||
seedBenchmarkDraft,
|
||||
unregisterBenchmarkWorkspaceRunnables,
|
||||
type BenchmarkWorkspaceRunnables,
|
||||
} from "../../mockBackend";
|
||||
@@ -94,7 +96,7 @@ export async function runGlobalEval(
|
||||
tools: getGlobalEvalTools(),
|
||||
helpers: {},
|
||||
apiKey,
|
||||
getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }),
|
||||
getOutput: () => collectGlobalDraftState(workspaceRoot),
|
||||
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
|
||||
@@ -130,6 +132,32 @@ export async function runGlobalEval(
|
||||
}
|
||||
}
|
||||
|
||||
// Build the harness output from the DB-backed drafts. `listGlobalDrafts` returns
|
||||
// metadata-only rows for backend drafts (the model's `write_script` etc. persist
|
||||
// straight to the backend with no in-tab editor cell), so re-read each such row
|
||||
// with `getGlobalDraft` to attach the full value the validators assert on. A row
|
||||
// that already carries a value (the production in-tab cell overlay) is kept as-is.
|
||||
async function collectGlobalDraftState(
|
||||
workspace: string,
|
||||
): Promise<GlobalDraftState> {
|
||||
const items = await listGlobalDrafts(workspace);
|
||||
const drafts = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (item.value !== undefined) {
|
||||
return item;
|
||||
}
|
||||
const full = await getGlobalDraft(
|
||||
workspace,
|
||||
item.type,
|
||||
item.path,
|
||||
item.triggerKind,
|
||||
);
|
||||
return full ?? item;
|
||||
}),
|
||||
);
|
||||
return { drafts: drafts as GlobalDraftState["drafts"] };
|
||||
}
|
||||
|
||||
function seedLiveEditorDrafts(
|
||||
workspace: string,
|
||||
fixtures: GlobalLiveEditorDraftFixture[],
|
||||
@@ -138,7 +166,9 @@ function seedLiveEditorDrafts(
|
||||
const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type];
|
||||
const storagePath = fixture.storagePath ?? fixture.effectivePath ?? "";
|
||||
if (fixture.value !== undefined) {
|
||||
UserDraft.save(itemKind, storagePath, fixture.value, { workspace });
|
||||
// Seed as a backend draft row, not an in-tab cell: a cell would shadow the
|
||||
// model's DB-backed edit when the output is read back via listGlobalDrafts.
|
||||
seedBenchmarkDraft(workspace, itemKind, storagePath, fixture.value);
|
||||
}
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
|
||||
@@ -38,8 +38,9 @@ export interface RunEvalParams<THelpers, TOutput> {
|
||||
helpers: THelpers;
|
||||
/** API key for the provider */
|
||||
apiKey: string;
|
||||
/** Function to get the current output state */
|
||||
getOutput: () => TOutput;
|
||||
/** Function to get the current output state. May be async — global mode reads
|
||||
* DB-backed drafts back through the (mocked) backend to build its output. */
|
||||
getOutput: () => TOutput | Promise<TOutput>;
|
||||
/** Model and Windmill backend configuration */
|
||||
options: EvalRunnerOptions;
|
||||
onAssistantMessageStart?: () => void;
|
||||
@@ -154,7 +155,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
if (result.hitMaxIterations) {
|
||||
return {
|
||||
success: false,
|
||||
output: getOutput(),
|
||||
output: (await getOutput()) as TOutput,
|
||||
error: `Reached max turns (${maxIterations})`,
|
||||
tokenUsage: result.tokenUsage,
|
||||
toolCallsCount,
|
||||
@@ -170,7 +171,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: getOutput(),
|
||||
output: (await getOutput()) as TOutput,
|
||||
tokenUsage: result.tokenUsage,
|
||||
toolCallsCount,
|
||||
toolsCalled,
|
||||
@@ -191,7 +192,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: getOutput(),
|
||||
output: (await getOutput()) as TOutput,
|
||||
error: errorMessage,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
toolCallsCount,
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { CompletedJob, Flow, Job, Script } from '../../../frontend/src/lib/
|
||||
import type {
|
||||
DataTableTables,
|
||||
DataTableTableSchema,
|
||||
ScriptLang
|
||||
GetDraftForUserResponse,
|
||||
ListDraftsResponse,
|
||||
ScriptLang,
|
||||
UpdateDraftResponse,
|
||||
UserDraftItemKind
|
||||
} from '../../../frontend/src/lib/gen/types.gen'
|
||||
import { buildScriptLintResult } from './core/script/preview'
|
||||
import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine'
|
||||
@@ -63,6 +67,7 @@ export function resetBenchmarkMockBackend(): void {
|
||||
benchmarkWorkspaces.clear()
|
||||
benchmarkWorkspaceRunnables.clear()
|
||||
benchmarkJobs.clear()
|
||||
benchmarkDrafts.clear()
|
||||
}
|
||||
|
||||
export function registerBenchmarkWorkspace(workspace: string): void {
|
||||
@@ -74,6 +79,8 @@ export function registerBenchmarkWorkspaceRunnables(
|
||||
runnables: BenchmarkWorkspaceRunnables
|
||||
): void {
|
||||
benchmarkWorkspaces.add(workspace)
|
||||
// Fresh case: drop any drafts left from a prior run on this workspace id.
|
||||
clearBenchmarkDrafts(workspace)
|
||||
// Datatables are mutated in place by exec_datatable_sql (a write must be visible
|
||||
// to later reads), so store an isolated deep copy — never mutate the caller's seed.
|
||||
benchmarkWorkspaceRunnables.set(workspace, {
|
||||
@@ -98,6 +105,7 @@ export function registerBenchmarkWorkspaceRunnables(
|
||||
export function unregisterBenchmarkWorkspace(workspace: string): void {
|
||||
benchmarkWorkspaces.delete(workspace)
|
||||
benchmarkWorkspaceRunnables.delete(workspace)
|
||||
clearBenchmarkDrafts(workspace)
|
||||
for (const [jobId, entry] of benchmarkJobs.entries()) {
|
||||
if (entry.workspace === workspace) {
|
||||
benchmarkJobs.delete(jobId)
|
||||
@@ -238,6 +246,110 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
|
||||
return job.logs ?? ''
|
||||
}
|
||||
|
||||
// ============= Drafts (per-user, DB-backed in production) =============
|
||||
|
||||
/**
|
||||
* In-memory stand-in for the per-user draft backend (`DraftService`). The global
|
||||
* AI chat now persists and reads drafts through the backend DB instead of an
|
||||
* in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it
|
||||
* exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the
|
||||
* saved values here, keyed by workspace + draft kind + storage path. Mirrors the
|
||||
* semantics of the production unit test's mock in
|
||||
* `frontend/src/lib/components/copilot/chat/global/core.test.ts`.
|
||||
*/
|
||||
const benchmarkDrafts = new Map<
|
||||
string,
|
||||
{ workspace: string; kind: UserDraftItemKind; path: string; value: unknown }
|
||||
>()
|
||||
|
||||
// Fixed timestamp so artifacts stay deterministic. No eval simulates a
|
||||
// concurrent writer, so every save is accepted and the conflict branch is
|
||||
// never taken — the syncer just records this as its `last_sync` baseline.
|
||||
const BENCHMARK_DRAFT_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
||||
|
||||
function benchmarkDraftKey(workspace: string, kind: string, path: string): string {
|
||||
return `${workspace}::${kind}::${path}`
|
||||
}
|
||||
|
||||
export function clearBenchmarkDrafts(workspace: string): void {
|
||||
for (const [key, entry] of benchmarkDrafts.entries()) {
|
||||
if (entry.workspace === workspace) {
|
||||
benchmarkDrafts.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a draft straight into the store — used by the eval's live-editor draft
|
||||
* fixtures, which model "the user already has this draft open/saved". Writing it
|
||||
* here (instead of through `UserDraft.save`) keeps it a backend draft row with no
|
||||
* shadowing in-tab cell, so a model edit that persists to the backend is what the
|
||||
* output read-back captures — not the stale seed.
|
||||
*/
|
||||
export function seedBenchmarkDraft(
|
||||
workspace: string,
|
||||
kind: UserDraftItemKind,
|
||||
path: string,
|
||||
value: unknown
|
||||
): void {
|
||||
benchmarkDrafts.set(benchmarkDraftKey(workspace, kind, path), {
|
||||
workspace,
|
||||
kind,
|
||||
path,
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
/** Mirror `DraftService.updateDraft`: a `null`/omitted value deletes the row. */
|
||||
export function updateBenchmarkDraft(input: {
|
||||
workspace: string
|
||||
kind: UserDraftItemKind
|
||||
path: string
|
||||
requestBody?: { value?: unknown }
|
||||
}): UpdateDraftResponse {
|
||||
const key = benchmarkDraftKey(input.workspace, input.kind, input.path)
|
||||
const value = input.requestBody?.value
|
||||
if (value == null) {
|
||||
benchmarkDrafts.delete(key)
|
||||
} else {
|
||||
benchmarkDrafts.set(key, {
|
||||
workspace: input.workspace,
|
||||
kind: input.kind,
|
||||
path: input.path,
|
||||
value
|
||||
})
|
||||
}
|
||||
return { status: 'saved', current_timestamp: BENCHMARK_DRAFT_TIMESTAMP }
|
||||
}
|
||||
|
||||
/** Mirror `DraftService.getDraftForUser`: 404-shaped throw when absent so the
|
||||
* adapter's narrowed catch treats it as "no draft" instead of re-throwing. */
|
||||
export function getBenchmarkDraftForUser(input: {
|
||||
workspace: string
|
||||
kind: UserDraftItemKind
|
||||
path: string
|
||||
}): GetDraftForUserResponse {
|
||||
const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path))
|
||||
if (!entry) {
|
||||
throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 })
|
||||
}
|
||||
return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP }
|
||||
}
|
||||
|
||||
/** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */
|
||||
export function listBenchmarkDrafts(workspace: string): ListDraftsResponse {
|
||||
return [...benchmarkDrafts.values()]
|
||||
.filter((entry) => entry.workspace === workspace)
|
||||
.map((entry) => ({
|
||||
kind: entry.kind,
|
||||
path: entry.path,
|
||||
summary: (entry.value as { summary?: string } | null)?.summary,
|
||||
draft_only: true,
|
||||
legacy_draft: false,
|
||||
created_at: BENCHMARK_DRAFT_TIMESTAMP
|
||||
}))
|
||||
}
|
||||
|
||||
// ============= Datatables (best-effort in-memory SQL) =============
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
clearBenchmarkDrafts,
|
||||
getBenchmarkDraftForUser,
|
||||
listBenchmarkDrafts,
|
||||
resetBenchmarkMockBackend,
|
||||
seedBenchmarkDraft,
|
||||
updateBenchmarkDraft
|
||||
} from './mockBackend'
|
||||
|
||||
const WORKSPACE = 'benchmark-drafts-ws'
|
||||
|
||||
// Drives the in-memory stand-in for the per-user draft backend (`DraftService`)
|
||||
// that the global AI-chat eval round-trips its drafts through. Mirrors the
|
||||
// production-unit-test mock in
|
||||
// `frontend/src/lib/components/copilot/chat/global/core.test.ts`.
|
||||
describe('mockBackend drafts', () => {
|
||||
beforeEach(() => resetBenchmarkMockBackend())
|
||||
afterEach(() => resetBenchmarkMockBackend())
|
||||
|
||||
it('round-trips a saved draft through update / get / list', () => {
|
||||
const value = { summary: 'Greet a user', content: 'export async function main() {}' }
|
||||
const res = updateBenchmarkDraft({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'script',
|
||||
path: 'f/evals/greet',
|
||||
requestBody: { value }
|
||||
})
|
||||
expect(res.status).toBe('saved')
|
||||
|
||||
expect(getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/greet' }).value).toEqual(
|
||||
value
|
||||
)
|
||||
|
||||
const rows = listBenchmarkDrafts(WORKSPACE)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ kind: 'script', path: 'f/evals/greet', summary: 'Greet a user', draft_only: true })
|
||||
})
|
||||
|
||||
it('treats a null value as a delete', () => {
|
||||
updateBenchmarkDraft({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'variable',
|
||||
path: 'f/evals/token',
|
||||
requestBody: { value: { summary: 'token' } }
|
||||
})
|
||||
updateBenchmarkDraft({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'variable',
|
||||
path: 'f/evals/token',
|
||||
requestBody: { value: null }
|
||||
})
|
||||
|
||||
expect(listBenchmarkDrafts(WORKSPACE)).toHaveLength(0)
|
||||
expect(() => getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'variable', path: 'f/evals/token' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws a 404-shaped error when no draft exists', () => {
|
||||
try {
|
||||
getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/missing' })
|
||||
throw new Error('expected a throw')
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(404)
|
||||
}
|
||||
})
|
||||
|
||||
it('seeds a draft as a backend row that a later edit overwrites', () => {
|
||||
seedBenchmarkDraft(WORKSPACE, 'script', 'f/evals/current', { content: 'seed' })
|
||||
expect(getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/current' }).value).toEqual({
|
||||
content: 'seed'
|
||||
})
|
||||
|
||||
// A model edit persists the same path and must win over the seed.
|
||||
updateBenchmarkDraft({
|
||||
workspace: WORKSPACE,
|
||||
kind: 'script',
|
||||
path: 'f/evals/current',
|
||||
requestBody: { value: { content: 'edited' } }
|
||||
})
|
||||
expect(getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/current' }).value).toEqual({
|
||||
content: 'edited'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears only the targeted workspace', () => {
|
||||
seedBenchmarkDraft(WORKSPACE, 'script', 'f/a', { content: 'a' })
|
||||
seedBenchmarkDraft('other-ws', 'script', 'f/b', { content: 'b' })
|
||||
|
||||
clearBenchmarkDrafts(WORKSPACE)
|
||||
|
||||
expect(listBenchmarkDrafts(WORKSPACE)).toHaveLength(0)
|
||||
expect(listBenchmarkDrafts('other-ws')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -36,12 +36,14 @@ vi.mock('$lib/gen', async () => {
|
||||
getBenchmarkCompletedJob,
|
||||
getBenchmarkCompletedJobResultMaybe,
|
||||
getBenchmarkDatatableSchema,
|
||||
getBenchmarkDraftForUser,
|
||||
getBenchmarkFlowByPath,
|
||||
getBenchmarkJobLogs,
|
||||
getBenchmarkScriptByHash,
|
||||
getBenchmarkScriptByPath,
|
||||
hasBenchmarkWorkspace,
|
||||
listBenchmarkDatatables,
|
||||
listBenchmarkDrafts,
|
||||
listBenchmarkFlows,
|
||||
listBenchmarkJobs,
|
||||
listBenchmarkScripts,
|
||||
@@ -50,7 +52,8 @@ vi.mock('$lib/gen', async () => {
|
||||
previewBenchmarkSchedule,
|
||||
runBenchmarkDatatableSql,
|
||||
runBenchmarkFlowByPath,
|
||||
runBenchmarkScriptPreview
|
||||
runBenchmarkScriptPreview,
|
||||
updateBenchmarkDraft
|
||||
} = await import('./mockBackend')
|
||||
|
||||
function wrapService<T extends object>(target: T, overrides: Record<string, unknown>): T {
|
||||
@@ -66,6 +69,25 @@ vi.mock('$lib/gen', async () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
DraftService: wrapService(actual.DraftService, {
|
||||
updateDraft: async (data: {
|
||||
workspace: string
|
||||
kind: any
|
||||
path: string
|
||||
requestBody?: { value?: unknown }
|
||||
}) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? updateBenchmarkDraft(data)
|
||||
: actual.DraftService.updateDraft(data),
|
||||
getDraftForUser: async (data: { workspace: string; kind: any; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? getBenchmarkDraftForUser(data)
|
||||
: actual.DraftService.getDraftForUser(data),
|
||||
listDrafts: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? listBenchmarkDrafts(data.workspace)
|
||||
: actual.DraftService.listDrafts(data)
|
||||
}),
|
||||
ScriptService: wrapService(actual.ScriptService, {
|
||||
listScripts: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
@@ -434,5 +456,6 @@ benchmarkIt(
|
||||
resetBenchmarkMockBackend()
|
||||
}
|
||||
},
|
||||
600_000
|
||||
// Full-suite runs (30+ cases at concurrency 2-3) routinely exceed 10 minutes.
|
||||
7_200_000
|
||||
)
|
||||
|
||||
@@ -870,3 +870,76 @@
|
||||
judgeChecklist:
|
||||
- fetches the logs for the requested job id
|
||||
- explains the failure from the returned logs (connection refused to the upstream API)
|
||||
|
||||
# --- Documentation search (search_docs) ---
|
||||
# Pure product-knowledge questions: the assistant should consult the docs via
|
||||
# search_docs and answer conversationally, not draft or mutate anything. No
|
||||
# draft is produced, so the global judge is skipped and we validate tool use.
|
||||
|
||||
- id: global-docs-ai-agent-step
|
||||
prompt: |-
|
||||
Does Windmill support a flow step where an LLM decides which of my scripts to call based on the input?
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- search_docs
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- write_flow
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
|
||||
- id: global-docs-retry-step
|
||||
prompt: |-
|
||||
How does automatic retry work for a flow step that calls a flaky API?
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- search_docs
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- write_flow
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
|
||||
- id: global-docs-key-value-store
|
||||
prompt: |-
|
||||
Can I use a Redis-style key-value store from my Windmill scripts, and how?
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- search_docs
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- write_flow
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
|
||||
- id: global-docs-cron-schedule-format
|
||||
prompt: |-
|
||||
How do Windmill's cron schedules work, and what format does the schedule expression use?
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- search_docs
|
||||
forbiddenToolsUsed:
|
||||
- write_script
|
||||
- write_flow
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
|
||||
@@ -246,6 +246,21 @@ describe("loadCases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads global docs-search cases as tool-use checks", async () => {
|
||||
const globalCases = await loadCases("global");
|
||||
const docsCases = globalCases.filter((entry) =>
|
||||
entry.id.startsWith("global-docs-"),
|
||||
);
|
||||
expect(docsCases.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Each docs case verifies the assistant reaches for search_docs and does not
|
||||
// draft anything; with no draft, the global judge is skipped.
|
||||
for (const entry of docsCases) {
|
||||
expect(entry.skipJudge).toBe(true);
|
||||
expect(entry.toolExpect?.requiredToolsUsed).toContain("search_docs");
|
||||
}
|
||||
});
|
||||
|
||||
it("loads tool expectations for workspace mutation cases", async () => {
|
||||
const scriptCases = await loadCases("script");
|
||||
const caseEntry = scriptCases.find(
|
||||
|
||||
@@ -225,7 +225,9 @@ async function runCaseAttempts<TInitial, TExpected, TActual>(input: {
|
||||
checklist: input.evalCase.judgeChecklist,
|
||||
initial,
|
||||
expected: input.modeRunner.mode === "cli" ? undefined : expected,
|
||||
actual: run.actual,
|
||||
actual: input.modeRunner.prepareJudgeActual
|
||||
? input.modeRunner.prepareJudgeActual(run.actual)
|
||||
: run.actual,
|
||||
model: input.judgeModel,
|
||||
});
|
||||
|
||||
|
||||
+10
-1
@@ -172,7 +172,10 @@ export interface ToolValidationSpec {
|
||||
toolCallArgs?: ToolCallArgumentRule[];
|
||||
}
|
||||
|
||||
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec;
|
||||
export type EvalValidationSpec =
|
||||
| FlowValidationSpec
|
||||
| AppValidationSpec
|
||||
| GlobalValidationSpec;
|
||||
|
||||
export interface EvalCase {
|
||||
id: string;
|
||||
@@ -294,6 +297,12 @@ export interface ModeRunner<TInitial, TExpected, TActual> {
|
||||
context: ModeRunContext;
|
||||
}): Promise<BackendValidationResult | null>;
|
||||
buildArtifacts?(actual: TActual): BenchmarkArtifactFile[];
|
||||
/**
|
||||
* Optional transform applied to `actual` before it is handed to the LLM judge.
|
||||
* Use it to strip fields the judge must stay blind to (e.g. which docs-tool
|
||||
* arm produced an answer). When omitted, the judge receives `actual` as-is.
|
||||
*/
|
||||
prepareJudgeActual?(actual: TActual): unknown;
|
||||
}
|
||||
|
||||
export interface BenchmarkAttemptResult {
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "one!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app",
|
||||
"raw_app",
|
||||
"resource",
|
||||
"variable",
|
||||
"trigger_schedule",
|
||||
"trigger_webhook",
|
||||
"trigger_default_email",
|
||||
"trigger_email",
|
||||
"trigger_http",
|
||||
"trigger_websocket",
|
||||
"trigger_postgres",
|
||||
"trigger_kafka",
|
||||
"trigger_nats",
|
||||
"trigger_mqtt",
|
||||
"trigger_sqs",
|
||||
"trigger_gcp",
|
||||
"trigger_azure",
|
||||
"trigger_poll",
|
||||
"trigger_cli",
|
||||
"trigger_nextcloud",
|
||||
"trigger_google",
|
||||
"trigger_github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "54ad5cc89563fdbbacdd6bfde9be6ecfcdec3d505d28cc65b5920dcf87c0014f"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,12 +49,13 @@
|
||||
},
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545"
|
||||
"hash": "c8fb2a1491f90951a6d2e4e9c56d288eb60f6c6644a73cdf949de0159215a7f7"
|
||||
}
|
||||
Generated
+93
-238
@@ -5066,16 +5066,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5800,7 +5798,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.7",
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5993,12 +5991,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -6506,12 +6498,6 @@ dependencies = [
|
||||
"spin 0.9.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "levenshtein_automata"
|
||||
version = "0.2.1"
|
||||
@@ -7273,9 +7259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.37.2"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b42ced54aa8ac97226486337973f9bc3956e24f03a23e88a6e18f640959d6e2"
|
||||
checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.0",
|
||||
@@ -9112,7 +9098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
@@ -9477,7 +9463,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12025,7 +12011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -13358,7 +13344,7 @@ version = "1.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
@@ -13460,16 +13446,7 @@ version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13575,28 +13552,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap 2.14.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
@@ -13620,18 +13575,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
"semver 1.0.28",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmtimer"
|
||||
version = "0.4.3"
|
||||
@@ -13680,9 +13623,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -13693,14 +13636,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.7",
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -13792,7 +13735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -13874,7 +13817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -13907,7 +13850,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -13920,7 +13863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14058,7 +14001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14081,7 +14024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14094,7 +14037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14120,7 +14063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -14130,7 +14073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14147,7 +14090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -14169,7 +14112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14192,7 +14135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14208,7 +14151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14229,7 +14172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14250,7 +14193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14264,7 +14207,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14299,7 +14242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14324,7 +14267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
@@ -14342,7 +14285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14364,7 +14307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14384,7 +14327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14420,7 +14363,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14448,7 +14391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -14460,7 +14403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -14485,7 +14428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14499,7 +14442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14532,7 +14475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -14546,7 +14489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14565,7 +14508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -14667,7 +14610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -14686,7 +14629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -14701,7 +14644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -14725,7 +14668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14742,7 +14685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -14758,7 +14701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14779,7 +14722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14810,7 +14753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -14835,7 +14778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -14869,7 +14812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14887,7 +14830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -14896,7 +14839,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14908,7 +14851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14920,7 +14863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -14932,7 +14875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14944,7 +14887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14956,7 +14899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -14967,7 +14910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14978,7 +14921,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14990,7 +14933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15001,7 +14944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15023,7 +14966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15035,7 +14978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15049,7 +14992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -15066,7 +15009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15079,7 +15022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15091,7 +15034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15109,7 +15052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -15125,7 +15068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15141,7 +15084,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15152,7 +15095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15190,7 +15133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -15229,7 +15172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -15240,7 +15183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15272,7 +15215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15296,7 +15239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15329,7 +15272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15362,7 +15305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15382,7 +15325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15416,7 +15359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15452,7 +15395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15475,7 +15418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15499,7 +15442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15523,7 +15466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15558,7 +15501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15586,7 +15529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15611,7 +15554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
@@ -15630,7 +15573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -15740,7 +15683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -16353,100 +16296,12 @@ version = "0.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap 2.14.0",
|
||||
"prettyplease",
|
||||
"syn 2.0.118",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap 2.14.0",
|
||||
"log",
|
||||
"semver 1.0.28",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
97b5cb2096d3a9b4818943c5abf181d914cb4e99
|
||||
136f4634aca61e74ccb045372358a1e3f6b23e75
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"bitbucket": {
|
||||
"auth_url": "https://bitbucket.org/site/oauth2/authorize",
|
||||
"token_url": "https://bitbucket.org/site/oauth2/access_token",
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"scopes": ["repository"]
|
||||
},
|
||||
"slack": {
|
||||
@@ -103,6 +104,7 @@
|
||||
"linkedin": {
|
||||
"auth_url": "https://www.linkedin.com/oauth/v2/authorization",
|
||||
"token_url": "https://www.linkedin.com/oauth/v2/accessToken",
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"scopes": ["w_member_social", "r_liteprofile", "r_emailaddress"],
|
||||
"req_body_auth": true
|
||||
},
|
||||
@@ -114,14 +116,31 @@
|
||||
"visma": {
|
||||
"auth_url": "https://connect.visma.com/connect/authorize",
|
||||
"token_url": "https://connect.visma.com/connect/token",
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"scopes": [
|
||||
"offline_access",
|
||||
"vismanet_erp_interactive_api:create",
|
||||
"vismanet_erp_interactive_api:delete",
|
||||
"vismanet_erp_interactive_api:read",
|
||||
"vismanet_erp_interactive_api:update"
|
||||
],
|
||||
"cc_scopes": [
|
||||
"vismanet_erp_service_api:create",
|
||||
"vismanet_erp_service_api:delete",
|
||||
"vismanet_erp_service_api:read",
|
||||
"vismanet_erp_service_api:update"
|
||||
]
|
||||
},
|
||||
"coupa": {
|
||||
"grant_types": ["client_credentials"],
|
||||
"connect_config_template": {
|
||||
"display_name": "Coupa",
|
||||
"label": "Coupa instance",
|
||||
"placeholder": "your-instance",
|
||||
"token_url": "https://{instance}.coupahost.com/oauth2/token",
|
||||
"strip_suffix": ".coupahost.com"
|
||||
}
|
||||
},
|
||||
"sage_intacct": {
|
||||
"auth_url": "https://api.intacct.com/ia/api/v1/oauth2/authorize",
|
||||
"token_url": "https://api.intacct.com/ia/api/v1/oauth2/token",
|
||||
@@ -130,6 +149,7 @@
|
||||
"spotify": {
|
||||
"auth_url": "https://accounts.spotify.com/authorize",
|
||||
"token_url": "https://accounts.spotify.com/api/token",
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"scopes": [
|
||||
"user-read-playback-state",
|
||||
"user-modify-playback-state",
|
||||
@@ -149,12 +169,16 @@
|
||||
"xero": {
|
||||
"auth_url": "https://login.xero.com/identity/connect/authorize",
|
||||
"token_url": "https://identity.xero.com/connect/token",
|
||||
"scopes": ["offline_access", "accounting.transactions"]
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"scopes": ["offline_access", "accounting.transactions"],
|
||||
"cc_scopes": ["accounting.transactions"]
|
||||
},
|
||||
"zoho": {
|
||||
"auth_url": "https://accounts.zoho.com/oauth/v2/auth",
|
||||
"token_url": "https://accounts.zoho.com/oauth/v2/token",
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"scopes": ["ZohoAssist.sessionapi.ALL"],
|
||||
"cc_scopes": ["ZohoAssist.sessionapi.ALL"],
|
||||
"extra_params": {
|
||||
"access_type": "offline"
|
||||
}
|
||||
@@ -197,6 +221,8 @@
|
||||
}
|
||||
},
|
||||
"servicenow": {
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"req_body_auth": true,
|
||||
"connect_config_template": {
|
||||
"display_name": "ServiceNow",
|
||||
"label": "ServiceNow Instance",
|
||||
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6272,7 +6272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6293,7 +6293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6305,7 +6305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6317,7 +6317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6329,7 +6329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6341,7 +6341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6353,7 +6353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6364,7 +6364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6375,7 +6375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6387,7 +6387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6398,7 +6398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6420,7 +6420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6432,7 +6432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6446,7 +6446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6463,7 +6463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6476,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6488,7 +6488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6506,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6522,7 +6522,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6538,7 +6538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6570,7 +6570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6581,7 +6581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.728.1"
|
||||
version = "1.729.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"version": "1.723.0",
|
||||
"version": "1.728.0",
|
||||
"title": "Windmill API",
|
||||
"contact": {
|
||||
"name": "Windmill Team",
|
||||
@@ -9700,9 +9700,9 @@
|
||||
"type": "string",
|
||||
"description": "OAuth client secret for resource-level credentials (client_credentials flow only)"
|
||||
},
|
||||
"cc_token_url": {
|
||||
"cc_instance": {
|
||||
"type": "string",
|
||||
"description": "OAuth token URL override for resource-level authentication (client_credentials flow only)"
|
||||
"description": "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side (client_credentials flow only). The token URL is never caller-supplied."
|
||||
},
|
||||
"mcp_server_url": {
|
||||
"type": "string",
|
||||
@@ -9739,7 +9739,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/oauth/connect_client_credentials/{client}": {
|
||||
"/w/{workspace}/oauth/connect_client_credentials/{client}": {
|
||||
"post": {
|
||||
"summary": "connect OAuth using client credentials",
|
||||
"operationId": "connectClientCredentials",
|
||||
@@ -9747,6 +9747,9 @@
|
||||
"oauth"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "client",
|
||||
"in": "path",
|
||||
@@ -9773,21 +9776,17 @@
|
||||
},
|
||||
"cc_client_id": {
|
||||
"type": "string",
|
||||
"description": "OAuth client ID for resource-level authentication"
|
||||
"description": "OAuth client ID. Omit to use the credentials configured on the provider's instance OAuth entry."
|
||||
},
|
||||
"cc_client_secret": {
|
||||
"type": "string",
|
||||
"description": "OAuth client secret for resource-level authentication"
|
||||
"description": "OAuth client secret. Omit to use the credentials configured on the provider's instance OAuth entry."
|
||||
},
|
||||
"cc_token_url": {
|
||||
"cc_instance": {
|
||||
"type": "string",
|
||||
"description": "OAuth token URL override for resource-level authentication"
|
||||
"description": "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side. The token URL is never caller-supplied."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cc_client_id",
|
||||
"cc_client_secret"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10000,7 +9999,23 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"supports_client_credentials": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_shared_credentials": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"supports_client_credentials",
|
||||
"has_shared_credentials"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10049,6 +10064,10 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"client_credentials_configured": {
|
||||
"type": "boolean",
|
||||
"description": "The instance OAuth entry carries shared client-credentials, so the connect dialog can skip the bring-your-own form and run the exchange server-side"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12191,10 +12210,18 @@
|
||||
"type": "string",
|
||||
"description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one."
|
||||
},
|
||||
"draft_path": {
|
||||
"type": "string",
|
||||
"description": "User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)."
|
||||
},
|
||||
"draft_only": {
|
||||
"type": "boolean",
|
||||
"description": "No deployed counterpart exists at this path — the draft is the whole item."
|
||||
},
|
||||
"legacy_draft": {
|
||||
"type": "boolean",
|
||||
"description": "The listed draft is a legacy workspace-level row (email NULL) predating the per-user drafts migration. Only true when no per-user draft exists at this path."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -12204,6 +12231,7 @@
|
||||
"kind",
|
||||
"path",
|
||||
"draft_only",
|
||||
"legacy_draft",
|
||||
"created_at"
|
||||
]
|
||||
}
|
||||
@@ -12316,6 +12344,10 @@
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "Skip the conflict check and overwrite the server copy."
|
||||
},
|
||||
"legacy": {
|
||||
"type": "boolean",
|
||||
"description": "Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12359,9 +12391,10 @@
|
||||
"description": "Creates a new script when the path does not already exist.\nCreates a new version of an existing script when called with the same path and the current `parent_hash`.\n",
|
||||
"operationId": "createScript",
|
||||
"x-mcp-tool": true,
|
||||
"x-mcp-instructions": "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed.",
|
||||
"x-mcp-instructions": "To create a NEW script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language, and leave parent_hash unset. For TypeScript, use 'bun' unless deno-specific APIs are needed. To UPDATE an existing script, do NOT delete and recreate it: call this tool with the same path and set parent_hash to the script's current hash, which you can read from the `hash` field returned by getScriptByPath. This creates a new version while preserving the script's history.",
|
||||
"x-mcp-tool-include-fields": [
|
||||
"path",
|
||||
"parent_hash",
|
||||
"content",
|
||||
"language",
|
||||
"summary",
|
||||
@@ -33609,8 +33642,16 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Workspace username of the draft owner. `null` represents\nthe legacy workspace-level (NULL-email) row. Emails never\nleave the server.\n"
|
||||
},
|
||||
"draft_saved_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this user's draft was last saved (`draft.created_at`),\nsurfaced in the fork modal as \"Last updated\".\n"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"draft_saved_at"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
version: 1.723.0
|
||||
version: 1.728.0
|
||||
title: Windmill API
|
||||
contact:
|
||||
name: Windmill Team
|
||||
@@ -7696,6 +7696,16 @@ paths:
|
||||
Emails never
|
||||
|
||||
leave the server.
|
||||
draft_saved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: >
|
||||
When this user's draft was last saved
|
||||
(`draft.created_at`),
|
||||
|
||||
surfaced in the fork modal as "Last updated".
|
||||
required:
|
||||
- draft_saved_at
|
||||
required: &ref_79
|
||||
- is_draft
|
||||
/w/{workspace}/variables/get_value/{path}:
|
||||
@@ -8900,11 +8910,14 @@ paths:
|
||||
description: >-
|
||||
OAuth client secret for resource-level credentials
|
||||
(client_credentials flow only)
|
||||
cc_token_url:
|
||||
cc_instance:
|
||||
type: string
|
||||
description: >-
|
||||
OAuth token URL override for resource-level authentication
|
||||
(client_credentials flow only)
|
||||
Instance name for built-in providers whose
|
||||
client-credentials token URL is instance-templated;
|
||||
substituted into the fixed-host registry template
|
||||
server-side (client_credentials flow only). The token URL is
|
||||
never caller-supplied.
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: MCP server URL for MCP OAuth token refresh
|
||||
@@ -8926,13 +8939,17 @@ paths:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/oauth/connect_client_credentials/{client}:
|
||||
/w/{workspace}/oauth/connect_client_credentials/{client}:
|
||||
post:
|
||||
summary: connect OAuth using client credentials
|
||||
operationId: connectClientCredentials
|
||||
tags:
|
||||
- oauth
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: client
|
||||
in: path
|
||||
description: OAuth client name
|
||||
@@ -8953,16 +8970,21 @@ paths:
|
||||
type: string
|
||||
cc_client_id:
|
||||
type: string
|
||||
description: OAuth client ID for resource-level authentication
|
||||
description: >-
|
||||
OAuth client ID. Omit to use the credentials configured on
|
||||
the provider's instance OAuth entry.
|
||||
cc_client_secret:
|
||||
type: string
|
||||
description: OAuth client secret for resource-level authentication
|
||||
cc_token_url:
|
||||
description: >-
|
||||
OAuth client secret. Omit to use the credentials configured
|
||||
on the provider's instance OAuth entry.
|
||||
cc_instance:
|
||||
type: string
|
||||
description: OAuth token URL override for resource-level authentication
|
||||
required:
|
||||
- cc_client_id
|
||||
- cc_client_secret
|
||||
description: >-
|
||||
Instance name for built-in providers whose
|
||||
client-credentials token URL is instance-templated;
|
||||
substituted into the fixed-host registry template
|
||||
server-side. The token URL is never caller-supplied.
|
||||
responses:
|
||||
'200':
|
||||
description: OAuth token response
|
||||
@@ -9113,7 +9135,18 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
supports_client_credentials:
|
||||
type: boolean
|
||||
has_shared_credentials:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
- supports_client_credentials
|
||||
- has_shared_credentials
|
||||
/oauth/get_connect/{client}:
|
||||
get:
|
||||
summary: get oauth connect
|
||||
@@ -9145,6 +9178,12 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
client_credentials_configured:
|
||||
type: boolean
|
||||
description: >-
|
||||
The instance OAuth entry carries shared
|
||||
client-credentials, so the connect dialog can skip the
|
||||
bring-your-own form and run the exchange server-side
|
||||
/teams/activities:
|
||||
post:
|
||||
summary: send update to Microsoft Teams activity
|
||||
@@ -12719,11 +12758,24 @@ paths:
|
||||
description: >-
|
||||
Best-effort, read from the draft JSON's `summary` field
|
||||
when the editor shape carries one.
|
||||
draft_path:
|
||||
type: string
|
||||
description: >-
|
||||
User-typed friendly path from the draft JSON's
|
||||
`draft_path`, when set and different from the storage
|
||||
path (e.g. a never-deployed item parked at
|
||||
`u/{user}/draft_{uuid}`).
|
||||
draft_only:
|
||||
type: boolean
|
||||
description: >-
|
||||
No deployed counterpart exists at this path — the draft
|
||||
is the whole item.
|
||||
legacy_draft:
|
||||
type: boolean
|
||||
description: >-
|
||||
The listed draft is a legacy workspace-level row (email
|
||||
NULL) predating the per-user drafts migration. Only true
|
||||
when no per-user draft exists at this path.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -12731,6 +12783,7 @@ paths:
|
||||
- kind
|
||||
- path
|
||||
- draft_only
|
||||
- legacy_draft
|
||||
- created_at
|
||||
/w/{workspace}/drafts/get/{kind}/{path}:
|
||||
get:
|
||||
@@ -12832,6 +12885,12 @@ paths:
|
||||
force:
|
||||
type: boolean
|
||||
description: Skip the conflict check and overwrite the server copy.
|
||||
legacy:
|
||||
type: boolean
|
||||
description: >-
|
||||
Delete-only. Target the legacy workspace-level row (email
|
||||
NULL) instead of the current user's row. Used to discard a
|
||||
legacy draft from the review page.
|
||||
responses:
|
||||
'200':
|
||||
description: save result
|
||||
@@ -12862,11 +12921,17 @@ paths:
|
||||
operationId: createScript
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: >-
|
||||
To create a script, specify the path (e.g., 'f/my_folder/my_script'),
|
||||
the content (source code), and the language. For TypeScript, use 'bun'
|
||||
unless deno-specific APIs are needed.
|
||||
To create a NEW script, specify the path (e.g.,
|
||||
'f/my_folder/my_script'), the content (source code), and the language,
|
||||
and leave parent_hash unset. For TypeScript, use 'bun' unless
|
||||
deno-specific APIs are needed. To UPDATE an existing script, do NOT
|
||||
delete and recreate it: call this tool with the same path and set
|
||||
parent_hash to the script's current hash, which you can read from the
|
||||
`hash` field returned by getScriptByPath. This creates a new version
|
||||
while preserving the script's history.
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- parent_hash
|
||||
- content
|
||||
- language
|
||||
- summary
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.728.1
|
||||
version: 1.729.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -6288,9 +6288,9 @@ paths:
|
||||
cc_client_secret:
|
||||
type: string
|
||||
description: "OAuth client secret for resource-level credentials (client_credentials flow only)"
|
||||
cc_token_url:
|
||||
cc_instance:
|
||||
type: string
|
||||
description: "OAuth token URL override for resource-level authentication (client_credentials flow only)"
|
||||
description: "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side (client_credentials flow only). The token URL is never caller-supplied."
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: "MCP server URL for MCP OAuth token refresh"
|
||||
@@ -6311,13 +6311,14 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/oauth/connect_client_credentials/{client}:
|
||||
/w/{workspace}/oauth/connect_client_credentials/{client}:
|
||||
post:
|
||||
summary: connect OAuth using client credentials
|
||||
operationId: connectClientCredentials
|
||||
tags:
|
||||
- oauth
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: client
|
||||
in: path
|
||||
description: OAuth client name
|
||||
@@ -6338,16 +6339,13 @@ paths:
|
||||
type: string
|
||||
cc_client_id:
|
||||
type: string
|
||||
description: "OAuth client ID for resource-level authentication"
|
||||
description: "OAuth client ID. Omit to use the credentials configured on the provider's instance OAuth entry."
|
||||
cc_client_secret:
|
||||
type: string
|
||||
description: "OAuth client secret for resource-level authentication"
|
||||
cc_token_url:
|
||||
description: "OAuth client secret. Omit to use the credentials configured on the provider's instance OAuth entry."
|
||||
cc_instance:
|
||||
type: string
|
||||
description: "OAuth token URL override for resource-level authentication"
|
||||
required:
|
||||
- cc_client_id
|
||||
- cc_client_secret
|
||||
description: "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side. The token URL is never caller-supplied."
|
||||
responses:
|
||||
"200":
|
||||
description: OAuth token response
|
||||
@@ -6481,7 +6479,18 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
supports_client_credentials:
|
||||
type: boolean
|
||||
has_shared_credentials:
|
||||
type: boolean
|
||||
required:
|
||||
- name
|
||||
- supports_client_credentials
|
||||
- has_shared_credentials
|
||||
|
||||
/oauth/get_connect/{client}:
|
||||
get:
|
||||
@@ -6514,6 +6523,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
client_credentials_configured:
|
||||
type: boolean
|
||||
description: "The instance OAuth entry carries shared client-credentials, so the connect dialog can skip the bring-your-own form and run the exchange server-side"
|
||||
|
||||
/teams/activities:
|
||||
post:
|
||||
@@ -7898,6 +7910,11 @@ paths:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: all_users
|
||||
in: query
|
||||
description: List every draft in the workspace (all users), not just the current user's own + legacy rows. Other users' rows come back with `mine=false` (view-only).
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: the user's drafts
|
||||
@@ -7927,7 +7944,25 @@ paths:
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [kind, path, draft_only, legacy_draft, created_at]
|
||||
can_write:
|
||||
type: boolean
|
||||
description: Whether the current user may deploy/discard this draft (same check the deploy/discard endpoints enforce).
|
||||
mine:
|
||||
type: boolean
|
||||
description: The row belongs to the current user (own draft or the legacy no-owner row) and is therefore actionable. Always true in the default listing; with `all_users=true`, other users' rows are false (view-only).
|
||||
draft_users:
|
||||
description: |
|
||||
Draft authors at this (path, kind) — the legacy NULL-email row surfaced as a null username.
|
||||
Populated only for the shared full-page-editor kinds (script/flow/app/raw_app); omitted for
|
||||
drawer kinds, which keep their drafts private. Feeds the Draft badge's owner-avatar circles.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
nullable: true
|
||||
required: [kind, path, draft_only, legacy_draft, created_at, can_write, mine]
|
||||
|
||||
/w/{workspace}/drafts/get/{kind}/{path}:
|
||||
get:
|
||||
@@ -8028,6 +8063,10 @@ paths:
|
||||
legacy:
|
||||
type: boolean
|
||||
description: Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Upsert-only override for the stored creation timestamp. Normal saves omit it (stamped server-side); the localStorage→DB migration passes the draft's original write time so migrated drafts keep their age.
|
||||
responses:
|
||||
"200":
|
||||
description: save result
|
||||
@@ -8044,6 +8083,41 @@ paths:
|
||||
format: date-time
|
||||
required: [status, current_timestamp]
|
||||
|
||||
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
|
||||
post:
|
||||
summary: resolve a legacy (workspace-level) draft (admin only)
|
||||
description: Delete a legacy draft (email NULL) or assign it to the authed admin as a per-user draft. Workspace admins / superadmins only.
|
||||
operationId: migrateLegacyDraft
|
||||
tags:
|
||||
- draft
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kind
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserDraftItemKind"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [delete, assign_to_self]
|
||||
description: delete the legacy draft, or take ownership of it.
|
||||
required: [action]
|
||||
responses:
|
||||
"200":
|
||||
description: migration result
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/scripts/create:
|
||||
post:
|
||||
summary: create script
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
extract::{Extension, Path, Query},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
variables::{build_crypt, encrypt},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get/{kind}/{*path}", get(get_draft_for_user))
|
||||
.route("/get_own/{kind}/{*path}", get(get_own_draft))
|
||||
.route("/update/{kind}/{*path}", post(update_draft))
|
||||
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
@@ -51,6 +52,30 @@ pub struct DraftListItem {
|
||||
/// row exists at this (path, kind) — the DISTINCT ON prefers an owned row.
|
||||
pub legacy_draft: bool,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// All draft authors at this `(path, kind)`, for the shared full-page-editor
|
||||
/// kinds (script/flow/app/raw_app) only — feeds the home-page-style owner
|
||||
/// circles on the review page. `None` for drawer kinds, which keep their
|
||||
/// drafts private.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_users: Option<sqlx::types::Json<Vec<DraftUserRef>>>,
|
||||
/// Whether the authed user may deploy/discard this draft — the same check
|
||||
/// the deploy/discard endpoints enforce. Computed per row after the query,
|
||||
/// so it defaults to `false` when read from the row.
|
||||
#[sqlx(default)]
|
||||
pub can_write: bool,
|
||||
/// The listed row belongs to the authed user (own draft or the legacy
|
||||
/// no-owner row) and is therefore actionable by them. Always `true` in the
|
||||
/// default (own-drafts) listing; only meaningful with `all_users=true`,
|
||||
/// where other users' rows surface as `false` (view-only — you can't deploy
|
||||
/// someone else's draft).
|
||||
pub mine: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListDraftsQuery {
|
||||
/// List every draft in the workspace (all users), not just the authed
|
||||
/// user's own + legacy rows. Other users' rows come back with `mine=false`.
|
||||
pub all_users: Option<bool>,
|
||||
}
|
||||
|
||||
/// Every draft the authed user has in this workspace, across all kinds — the
|
||||
@@ -60,7 +85,9 @@ pub struct DraftListItem {
|
||||
async fn list_drafts(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(query): Query<ListDraftsQuery>,
|
||||
) -> Result<Json<Vec<DraftListItem>>> {
|
||||
// Operators have no drafts of their own (they can't write any, see
|
||||
// `require_can_write_path`), so this list is always empty for them. They
|
||||
@@ -68,20 +95,58 @@ async fn list_drafts(
|
||||
if authed.is_operator {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query())
|
||||
let all_users = query.all_users.unwrap_or(false);
|
||||
let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query(all_users))
|
||||
.bind(&w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
// Per-row permission gating:
|
||||
// - own drafts (incl. legacy no-owner rows, `mine = true`): the actionable
|
||||
// gate is write permission — run the exact check deploy/discard enforce so
|
||||
// the UI never offers an action that would 403.
|
||||
// - other users' drafts (only present with `all_users`, `mine = false`): the
|
||||
// UI never lets you act on them (`isSelectable` requires `mine`), so skip
|
||||
// the write probe (`can_write = false`) and instead require READ access —
|
||||
// otherwise the broadened listing would disclose the path/summary/authors
|
||||
// of items the caller can't see. Unreadable rows are dropped, mirroring the
|
||||
// `require_can_read_path` gate on `/drafts/get`.
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for mut row in rows {
|
||||
if row.mine {
|
||||
row.can_write =
|
||||
match require_can_write_path(&authed, &db, &user_db, &w_id, row.kind, &row.path)
|
||||
.await
|
||||
{
|
||||
Ok(()) => true,
|
||||
Err(Error::NotAuthorized(_)) => false,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
out.push(row);
|
||||
} else {
|
||||
// `require_can_read_path` denies with `NotFound` (it hides existence)
|
||||
// and, for some paths, `NotAuthorized` — both mean "not visible to the
|
||||
// caller", so drop the row. Any other error is a real failure.
|
||||
match require_can_read_path(&authed, &user_db, &w_id, row.kind, &row.path).await {
|
||||
Ok(()) => {
|
||||
row.can_write = false;
|
||||
out.push(row);
|
||||
}
|
||||
Err(Error::NotFound(_)) | Err(Error::NotAuthorized(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// Build the `list_drafts` SQL, generating the `draft_only` CASE from
|
||||
/// `deployed_table()` (shared single source — can't drift from the access
|
||||
/// check). Table names come from the closed enum, never user input. Kinds
|
||||
/// with no path-keyed table get no arm and fall to `ELSE true`.
|
||||
/// `$1` = workspace_id, `$2` = email.
|
||||
fn list_drafts_query() -> String {
|
||||
/// `$1` = workspace_id, `$2` = email. With `all_users` the owner filter is
|
||||
/// dropped so every workspace draft is listed (others' rows get `mine=false`).
|
||||
fn list_drafts_query(all_users: bool) -> String {
|
||||
let mut case = String::from("CASE d.typ::text\n");
|
||||
for kind in UserDraftItemKind::ALL {
|
||||
let Some(table) = kind.deployed_table() else {
|
||||
@@ -102,15 +167,35 @@ fn list_drafts_query() -> String {
|
||||
));
|
||||
}
|
||||
case.push_str(" ELSE true\nEND");
|
||||
// `(d.email = $2 OR d.email IS NULL)` lists the user's own drafts AND the
|
||||
// legacy NULL-email rows; `DISTINCT ON (d.path, d.typ)` with `email IS NULL`
|
||||
// last collapses a (path, kind) that has both to the owned row.
|
||||
// Owner circles, mirroring the home-page list subquery (see apps.rs): every
|
||||
// draft author at this (path, kind), legacy NULL-email row surfaced as a
|
||||
// null username. Restricted to the shared full-page-editor kinds — drawer
|
||||
// kinds keep their drafts private, so we never reveal their authors.
|
||||
let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN (
|
||||
SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END))
|
||||
ORDER BY COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END) NULLS LAST)
|
||||
FROM draft du
|
||||
LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email
|
||||
WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ
|
||||
) ELSE NULL END"#;
|
||||
// Default lists the user's own drafts AND the legacy NULL-email rows; with
|
||||
// `all_users` the filter is dropped to list every workspace draft.
|
||||
let owner_filter = if all_users {
|
||||
""
|
||||
} else {
|
||||
" AND (d.email = $2 OR d.email IS NULL)"
|
||||
};
|
||||
// `DISTINCT ON (d.path, d.typ)` keeps one row per item; the ORDER BY
|
||||
// priority below picks the user's own row first, then the legacy NULL row,
|
||||
// then (only with `all_users`) another user's row. `mine`/`legacy_draft`
|
||||
// describe that kept row.
|
||||
format!(
|
||||
r#"SELECT DISTINCT ON (d.path, d.typ)
|
||||
d.path,
|
||||
d.typ AS kind,
|
||||
d.created_at,
|
||||
d.value ->> 'summary' AS summary,
|
||||
{draft_users} AS draft_users,
|
||||
-- Friendly typed path, by kind (mirrors the home-page list
|
||||
-- endpoints): scripts bind the Path widget to `script.path`,
|
||||
-- so it round-trips through the draft JSON's own `path`;
|
||||
@@ -125,10 +210,12 @@ fn list_drafts_query() -> String {
|
||||
d.path
|
||||
) AS draft_path,
|
||||
(d.email IS NULL) AS legacy_draft,
|
||||
(d.email = $2 OR d.email IS NULL) AS mine,
|
||||
{case} AS draft_only
|
||||
FROM draft d
|
||||
WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL)
|
||||
ORDER BY d.path, d.typ, (d.email IS NULL)"#
|
||||
WHERE d.workspace_id = $1{owner_filter}
|
||||
ORDER BY d.path, d.typ,
|
||||
CASE WHEN d.email = $2 THEN 0 WHEN d.email IS NULL THEN 1 ELSE 2 END"#
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,6 +240,12 @@ pub struct SaveDraftRequest {
|
||||
/// the email-scoped delete otherwise can't reach.
|
||||
#[serde(default)]
|
||||
pub legacy: bool,
|
||||
/// Upsert-only override for the stored `created_at`. Normal saves omit it
|
||||
/// and the row is stamped `now()`; the localStorage→DB migration passes the
|
||||
/// draft's original write time (or epoch 0 when unknown) so migrated drafts
|
||||
/// keep their age instead of all resurfacing to the top as freshly created.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -195,11 +288,13 @@ async fn update_draft(
|
||||
};
|
||||
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
|
||||
// when the row is newer than `last_sync`, RETURNING yields nothing.
|
||||
// `created_at` defaults to `now()` but the migration overrides it ($8)
|
||||
// so a migrated draft keeps its original age instead of jumping to top.
|
||||
sqlx::query_scalar!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, now())
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
@@ -211,6 +306,7 @@ async fn update_draft(
|
||||
serialized,
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.created_at,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
@@ -282,6 +378,81 @@ async fn update_draft(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MigrateLegacyDraftAction {
|
||||
/// Discard the legacy row entirely.
|
||||
Delete,
|
||||
/// Move the legacy row's content onto the authed admin's own row, then
|
||||
/// drop the legacy row — so it becomes a normal per-user draft.
|
||||
AssignToSelf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MigrateLegacyDraftRequest {
|
||||
pub action: MigrateLegacyDraftAction,
|
||||
}
|
||||
|
||||
/// Resolve a LEGACY (workspace-level, `email IS NULL`) draft. These predate the
|
||||
/// per-user drafts migration and have no owner, so only workspace admins (and
|
||||
/// superadmins, which carry `is_admin` in a workspace) may delete one or claim
|
||||
/// it as their own.
|
||||
async fn migrate_legacy_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
|
||||
Json(req): Json<MigrateLegacyDraftRequest>,
|
||||
) -> Result<String> {
|
||||
if !authed.is_admin {
|
||||
return Err(Error::NotAuthorized(
|
||||
"only workspace admins can migrate legacy drafts".to_string(),
|
||||
));
|
||||
}
|
||||
let path = path.to_path();
|
||||
match req.action {
|
||||
MigrateLegacyDraftAction::Delete => {
|
||||
sqlx::query!(
|
||||
r#"DELETE FROM draft
|
||||
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL"#,
|
||||
&w_id,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
Ok(format!("Deleted legacy draft at {path}"))
|
||||
}
|
||||
MigrateLegacyDraftAction::AssignToSelf => {
|
||||
// Take ownership: move the legacy value onto the admin's own row
|
||||
// (replacing any existing own draft) and drop the legacy row, in one
|
||||
// statement. `ON CONFLICT` matches the partial unique index that
|
||||
// covers `email IS NOT NULL`.
|
||||
let moved = sqlx::query_scalar!(
|
||||
r#"WITH legacy AS (
|
||||
DELETE FROM draft
|
||||
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
|
||||
RETURNING value
|
||||
)
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
SELECT $1, $4, $2, $3, value, now() FROM legacy
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
RETURNING 1 as "one!""#,
|
||||
&w_id,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
&authed.email,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if moved.is_none() {
|
||||
return Err(Error::NotFound(format!("no legacy draft at {path}")));
|
||||
}
|
||||
Ok(format!("Assigned legacy draft at {path} to you"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For variable-kind drafts with `variable.is_secret == true`, encrypt
|
||||
/// `variable.value` with the workspace crypt key and mark it
|
||||
/// `$encrypted:<base64>` so the secret never persists in plaintext at rest.
|
||||
|
||||
@@ -1739,7 +1739,7 @@ struct PrimaryKeyConstraintPayload {
|
||||
fn db_supports_schemas(db_type: DbType) -> bool {
|
||||
matches!(
|
||||
db_type,
|
||||
DbType::Postgresql | DbType::Snowflake | DbType::Bigquery
|
||||
DbType::Postgresql | DbType::Snowflake | DbType::Bigquery | DbType::Duckdb
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2410,8 +2410,15 @@ fn make_load_table_metadata_query(
|
||||
) -> Result<String, String> {
|
||||
match db_type {
|
||||
DbType::Duckdb => {
|
||||
// For ducklake, the ducklake ATTACH is handled by the ducklake wrapper.
|
||||
let mut q = String::from(
|
||||
// For ducklake, the ducklake ATTACH is handled by the ducklake wrapper, so the
|
||||
// ducklake catalog is the current database. information_schema spans every attached
|
||||
// catalog, so we always scope to current_database() to stay within the ducklake.
|
||||
let extra_col = if table.is_none() {
|
||||
",\n TABLE_SCHEMA as schema_name"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let mut q = format!(
|
||||
"SELECT
|
||||
COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
@@ -2420,12 +2427,20 @@ fn make_load_table_metadata_query(
|
||||
false as IsIdentity,
|
||||
CASE WHEN IS_NULLABLE = true THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
false as IsEnum,
|
||||
TABLE_NAME as table_name
|
||||
TABLE_NAME as table_name{}
|
||||
FROM information_schema.columns c
|
||||
WHERE table_schema = current_schema()",
|
||||
WHERE table_catalog = current_database()",
|
||||
extra_col
|
||||
);
|
||||
if let Some(t) = table {
|
||||
q.push_str(&format!(" AND TABLE_NAME = '{}'", escape_sql_literal(t)));
|
||||
let parts: Vec<&str> = t.split('.').collect();
|
||||
let tname = parts[parts.len() - 1];
|
||||
let schema = if parts.len() > 1 { parts[0] } else { "main" };
|
||||
q.push_str(&format!(
|
||||
" AND TABLE_NAME = '{}' AND TABLE_SCHEMA = '{}'",
|
||||
escape_sql_literal(tname),
|
||||
escape_sql_literal(schema)
|
||||
));
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
@@ -3722,9 +3737,10 @@ mod tests {
|
||||
table_ref("users", Some("myschema"), DbType::Mysql),
|
||||
"`users`"
|
||||
);
|
||||
// DuckDB (ducklake) supports schemas
|
||||
assert_eq!(
|
||||
table_ref("users", Some("myschema"), DbType::Duckdb),
|
||||
r#""users""#
|
||||
r#""myschema"."users""#
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3856,6 +3872,13 @@ mod tests {
|
||||
assert!(sql.contains("DROP TABLE \"users\";"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_drop_table_ducklake_with_schema() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"events","schema":"analytics","ducklake":"my_lake"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
||||
assert!(sql.contains("DROP TABLE \"analytics\".\"events\";"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CREATE SCHEMA / DROP SCHEMA
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -4355,7 +4378,27 @@ mod tests {
|
||||
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users","ducklake":"lake"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
||||
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
||||
assert!(sql.contains("TABLE_NAME = 'users'"));
|
||||
assert!(sql.contains("table_catalog = current_database()"));
|
||||
// Unqualified table defaults to the "main" schema.
|
||||
assert!(sql.contains("TABLE_NAME = 'users' AND TABLE_SCHEMA = 'main'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_load_table_metadata_ducklake_qualified_schema() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"analytics.events","ducklake":"lake"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
||||
assert!(sql.contains("TABLE_NAME = 'events' AND TABLE_SCHEMA = 'analytics'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_load_table_metadata_ducklake_all_tables() {
|
||||
let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"ducklake":"lake"}"#;
|
||||
let sql = expand_code(marker, &ScriptLang::DuckDb);
|
||||
assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n"));
|
||||
// All-tables listing scopes to the ducklake catalog and exposes the schema per table.
|
||||
assert!(sql.contains("table_catalog = current_database()"));
|
||||
assert!(sql.contains("TABLE_SCHEMA as schema_name"));
|
||||
assert!(!sql.contains("TABLE_NAME = '"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -165,7 +165,7 @@ pub enum ObjectType {
|
||||
WorkspaceDependencies,
|
||||
}
|
||||
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28261/sync-script-to-git-repo-windmill";
|
||||
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28719/sync-script-to-git-repo-windmill";
|
||||
|
||||
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
|
||||
/// fork of another workspace.
|
||||
|
||||
@@ -67,6 +67,13 @@ pub struct ClientWithScopes {
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub userinfo_url: Option<String>,
|
||||
pub grant_types: Vec<String>,
|
||||
/// Resolved token endpoint, exposed so the connect dialog can prefill and
|
||||
/// persist it on client-credentials accounts.
|
||||
pub token_url: String,
|
||||
/// Whether the instance entry carries shared credentials (non-empty id +
|
||||
/// secret). Providers without them are bring-your-own only — the connect
|
||||
/// dialog lists them under "Others", not "Instance-configured".
|
||||
pub has_shared_credentials: bool,
|
||||
}
|
||||
|
||||
/// Map of OAuth client names to their configurations
|
||||
@@ -81,6 +88,13 @@ pub struct OAuthConfig {
|
||||
pub token_url: String,
|
||||
pub userinfo_url: Option<String>,
|
||||
pub scopes: Option<Vec<String>>,
|
||||
/// Default scopes for the client-credentials (2-legged) flow. These differ
|
||||
/// from the authorization-code `scopes` for most providers (member/consent
|
||||
/// scopes are invalid in a 2-legged token request), so CC never defaults to
|
||||
/// `scopes`. Absent means no default scope — the caller supplies any
|
||||
/// provider-specific scopes themselves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cc_scopes: Option<Vec<String>>,
|
||||
pub extra_params: Option<HashMap<String, String>>,
|
||||
pub extra_params_callback: Option<HashMap<String, String>>,
|
||||
pub req_body_auth: Option<bool>,
|
||||
@@ -91,10 +105,12 @@ pub struct OAuthConfig {
|
||||
/// entry, `build_oauth_clients` registers a second client under that key.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<OAuthSandboxOverride>,
|
||||
/// Frontend-only metadata for per-instance OAuth providers (Snowflake,
|
||||
/// ServiceNow, …) whose authorize/token URLs are derived from an
|
||||
/// admin-entered instance name. Ignored by the backend, which only ever
|
||||
/// sees the resulting concrete `connect_config`.
|
||||
/// Metadata for per-instance OAuth providers (Snowflake, ServiceNow, Coupa,
|
||||
/// …) whose authorize/token URLs carry an `{instance}` placeholder filled
|
||||
/// from an instance name. The instance-settings UI uses it to build the
|
||||
/// per-client `connect_config` for the authorization-code flow; the
|
||||
/// client-credentials flow reads its `token_url`/`strip_suffix`/`label`
|
||||
/// directly to host-pin the exchange.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connect_config_template: Option<ConnectConfigTemplate>,
|
||||
}
|
||||
@@ -111,11 +127,13 @@ pub struct OAuthSandboxOverride {
|
||||
pub userinfo_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Frontend metadata for a per-instance OAuth provider. The instance-settings
|
||||
/// UI renders one generic instance-name input and substitutes `{instance}` into
|
||||
/// Metadata for a per-instance OAuth provider. The instance-settings UI renders
|
||||
/// one generic instance-name input and substitutes `{instance}` into
|
||||
/// `auth_url`/`token_url` to build the per-client `connect_config`. Adding a new
|
||||
/// per-instance provider needs only a registry entry carrying this template —
|
||||
/// no frontend code change. The backend never reads it.
|
||||
/// no frontend code change. The client-credentials flow additionally reads
|
||||
/// `token_url`, `strip_suffix`, and `label` from it server-side (see
|
||||
/// `resolve_cc_token_url_input`) to host-pin the token exchange.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ConnectConfigTemplate {
|
||||
/// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow");
|
||||
@@ -126,7 +144,10 @@ pub struct ConnectConfigTemplate {
|
||||
pub placeholder: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub help_url: Option<String>,
|
||||
pub auth_url: String,
|
||||
/// Authorize endpoint (with `{instance}`). Absent for client-credentials-only
|
||||
/// providers (e.g. Coupa) that have no browser sign-in flow.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_url: Option<String>,
|
||||
pub token_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub req_body_auth: Option<bool>,
|
||||
@@ -237,8 +258,12 @@ fn empty_string() -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
/// Placeholder authorize URL for providers that only support the
|
||||
/// client-credentials grant (the authorize endpoint is never used by it).
|
||||
pub const MISSING_AUTH_URL: &str = "https://missing-auth-url";
|
||||
|
||||
fn empty_auth() -> String {
|
||||
"https://missing-auth-url".to_string()
|
||||
MISSING_AUTH_URL.to_string()
|
||||
}
|
||||
|
||||
fn default_grant_types() -> Vec<String> {
|
||||
@@ -348,77 +373,349 @@ pub async fn build_slack_client(
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Build OAuth client for client credentials flow with resource-level credentials
|
||||
/// Build OAuth client for client credentials flow with resource-level credentials.
|
||||
///
|
||||
/// No instance-level entry is required: the provider endpoint config resolves
|
||||
/// from the instance `oauths` entry when one exists, else from the static
|
||||
/// registry, else is synthesized from the token URL override alone. Returns the
|
||||
/// built client together with the resolved [`OAuthConfig`] so callers can reuse
|
||||
/// its scopes / `extra_params_callback`.
|
||||
pub async fn build_client_credentials_oauth_client(
|
||||
db: &DB,
|
||||
client_name: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
cc_token_url_override: Option<&str>,
|
||||
resolved_token_url: Option<&str>,
|
||||
connect_configs_json: &str,
|
||||
) -> error::Result<(OClient, OAuthClient)> {
|
||||
) -> error::Result<(OClient, OAuthConfig)> {
|
||||
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
|
||||
|
||||
let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?;
|
||||
let oauths = oauths.unwrap_or_default();
|
||||
let oauth_config = oauths
|
||||
.get(client_name)
|
||||
.ok_or_else(|| error::Error::BadRequest("OAuth configuration not found".to_string()))?;
|
||||
let instance_entry: Option<OAuthClient> = oauths
|
||||
.as_ref()
|
||||
.and_then(|o| o.get(client_name))
|
||||
.and_then(|v| match serde_json::from_value(v.clone()) {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
client = %client_name,
|
||||
"Invalid instance OAuth entry, falling back to static registry: {e}"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone())
|
||||
.map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?;
|
||||
|
||||
let parse_static_configs = || {
|
||||
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e))
|
||||
})
|
||||
let resolve_from_registry = |client_name: &str| -> error::Result<Option<OAuthConfig>> {
|
||||
let static_configs =
|
||||
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(
|
||||
|e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)),
|
||||
)?;
|
||||
Ok(resolve_registry_config(&static_configs, client_name))
|
||||
};
|
||||
let resolve_from_registry = |client_name: &str| -> error::Result<OAuthConfig> {
|
||||
let static_configs = parse_static_configs()?;
|
||||
resolve_registry_config(&static_configs, client_name).ok_or_else(|| {
|
||||
|
||||
// A token URL alone is enough for client credentials: providers that only
|
||||
// support this grant have no authorize endpoint to configure.
|
||||
let instance_connect_config = instance_entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.connect_config.clone())
|
||||
.filter(|c| !c.token_url.is_empty())
|
||||
.map(|mut c| {
|
||||
if c.auth_url.is_empty() {
|
||||
c.auth_url = empty_auth();
|
||||
}
|
||||
c
|
||||
});
|
||||
|
||||
let from_instance = instance_connect_config.is_some();
|
||||
let mut connect_config = match instance_connect_config {
|
||||
Some(config) => config,
|
||||
None => resolve_from_registry(client_name)?.ok_or_else(|| {
|
||||
error::Error::BadRequest(format!(
|
||||
"OAuth configuration not found for '{}' in either global settings or static config",
|
||||
"No token URL available for '{}': not found in instance OAuth settings or static \
|
||||
config",
|
||||
client_name
|
||||
))
|
||||
})
|
||||
})?,
|
||||
};
|
||||
|
||||
let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config {
|
||||
if !config.auth_url.is_empty() && !config.token_url.is_empty() {
|
||||
config.clone()
|
||||
} else {
|
||||
resolve_from_registry(client_name)?
|
||||
}
|
||||
} else {
|
||||
resolve_from_registry(client_name)?
|
||||
};
|
||||
|
||||
if let Some(override_url) = cc_token_url_override {
|
||||
connect_config.token_url = override_url.to_string();
|
||||
// Registry providers default their client-credentials scopes from `cc_scopes`,
|
||||
// never the authorization-code `scopes` (which several providers reject for a
|
||||
// 2-legged token request). Instance-configured entries keep their admin-set
|
||||
// scopes untouched.
|
||||
if !from_instance {
|
||||
connect_config.scopes = connect_config.cc_scopes.clone();
|
||||
}
|
||||
|
||||
let caller_supplied_creds = !client_id.is_empty() && !client_secret.is_empty();
|
||||
|
||||
// Apply the server-resolved concrete token URL. Instance-templated providers
|
||||
// (e.g. Coupa) carry an empty or `{instance}`-templated token URL in their
|
||||
// registry config; the resolved value (host-pinned for bring-your-own,
|
||||
// persisted on the row for refresh) is what completes it. The caller never
|
||||
// supplies a free-form token URL: this value always comes from
|
||||
// `resolve_cc_token_url_input` or a previously-resolved persisted URL.
|
||||
if let Some(url) = resolved_token_url {
|
||||
connect_config.token_url = url.to_string();
|
||||
}
|
||||
if connect_config.token_url.is_empty() {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"No token URL configured for '{}'",
|
||||
client_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Fall back to the instance entry's own credentials when the caller supplies
|
||||
// none: the shared instance-level client-credentials setup, where an admin
|
||||
// configures one service-account client for everyone and the secret never
|
||||
// leaves the server. Only entries that explicitly enable the
|
||||
// client_credentials grant qualify, so an authorization-code-only client's
|
||||
// secret is never reused for this flow.
|
||||
let instance_cc_creds = instance_entry.as_ref().filter(|e| {
|
||||
e.grant_types.iter().any(|g| g == "client_credentials")
|
||||
&& !e.id.is_empty()
|
||||
&& !e.secret.is_empty()
|
||||
});
|
||||
// All-or-nothing: use the caller's credentials only when both id and secret
|
||||
// are present, otherwise fall back entirely to the instance entry. Never mix
|
||||
// a caller-supplied id with the admin secret (or vice versa).
|
||||
let (resolved_client_id, resolved_client_secret) = if caller_supplied_creds {
|
||||
(client_id.to_string(), client_secret.to_string())
|
||||
} else {
|
||||
instance_cc_creds
|
||||
.map(|e| (e.id.clone(), e.secret.clone()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let resource_oauth_client = OAuthClient {
|
||||
id: client_id.to_string(),
|
||||
secret: client_secret.to_string(),
|
||||
allowed_domains: oauth_client_config.allowed_domains.clone(),
|
||||
id: resolved_client_id,
|
||||
secret: resolved_client_secret,
|
||||
allowed_domains: instance_entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.allowed_domains.clone()),
|
||||
connect_config: Some(connect_config.clone()),
|
||||
login_config: oauth_client_config.login_config.clone(),
|
||||
display_name: oauth_client_config.display_name.clone(),
|
||||
grant_types: oauth_client_config.grant_types.clone(),
|
||||
tenant: oauth_client_config.tenant.clone(),
|
||||
login_config: instance_entry.as_ref().and_then(|e| e.login_config.clone()),
|
||||
display_name: instance_entry.as_ref().and_then(|e| e.display_name.clone()),
|
||||
grant_types: instance_entry
|
||||
.as_ref()
|
||||
.map(|e| e.grant_types.clone())
|
||||
.unwrap_or_else(default_grant_types),
|
||||
tenant: instance_entry.as_ref().and_then(|e| e.tenant.clone()),
|
||||
};
|
||||
|
||||
let base_url = (**BASE_URL.load()).clone();
|
||||
let (_, client) = build_basic_client(
|
||||
client_name.to_string(),
|
||||
connect_config,
|
||||
connect_config.clone(),
|
||||
resource_oauth_client,
|
||||
false,
|
||||
&base_url,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok((client, oauth_client_config))
|
||||
Ok((client, connect_config))
|
||||
}
|
||||
|
||||
/// Shared instance-level client-credentials for `client_name`: the `(id, secret,
|
||||
/// token_url)` from its instance `oauths` entry, but only when that entry both
|
||||
/// declares the `client_credentials` grant and carries non-empty credentials.
|
||||
/// Lets the connect flow use one admin-configured service-account client instead
|
||||
/// of asking each user for their own.
|
||||
///
|
||||
/// # Authorization
|
||||
/// Returns the admin's shared service-account secret, so callers MUST first
|
||||
/// verify the caller's authorization to use it (workspace membership plus
|
||||
/// read-write access — operators and read-only tokens are excluded). This helper
|
||||
/// performs no authorization itself.
|
||||
pub async fn resolve_instance_cc_credentials(
|
||||
db: &DB,
|
||||
client_name: &str,
|
||||
) -> error::Result<Option<(String, String, Option<String>)>> {
|
||||
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
|
||||
|
||||
let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?;
|
||||
let entry: Option<OAuthClient> = oauths
|
||||
.as_ref()
|
||||
.and_then(|o| o.get(client_name))
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
Ok(entry.and_then(|e| {
|
||||
let cc_grant = e.grant_types.iter().any(|g| g == "client_credentials");
|
||||
if cc_grant && !e.id.is_empty() && !e.secret.is_empty() {
|
||||
// Token URL from the entry's connect_config (built by instance settings
|
||||
// from the connect_config_template), so the account row is
|
||||
// self-contained for refresh.
|
||||
let token_url = e
|
||||
.connect_config
|
||||
.as_ref()
|
||||
.map(|c| c.token_url.clone())
|
||||
.filter(|u| !u.is_empty());
|
||||
Some((e.id, e.secret, token_url))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve the concrete client-credentials token URL for a bring-your-own
|
||||
/// connection. The caller never supplies a token URL: it always comes from the
|
||||
/// built-in registry, so the exchange host can never be redirected.
|
||||
///
|
||||
/// Supported only for registry providers. For one whose CC token URL carries an
|
||||
/// `{instance}` placeholder (Coupa, ServiceNow, …) — declared in its
|
||||
/// `connect_config_template` — the caller supplies only an instance name,
|
||||
/// validated as a bare hostname label and substituted into the fixed-host
|
||||
/// template. A fixed-host registry provider uses its registry token URL directly.
|
||||
/// A custom resource type (no registry entry) is rejected: there is no known host
|
||||
/// to send credentials to.
|
||||
pub fn resolve_cc_token_url_input(
|
||||
connect_configs_json: &str,
|
||||
client_name: &str,
|
||||
caller_instance: Option<&str>,
|
||||
) -> error::Result<String> {
|
||||
let Some(cfg) = serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
|
||||
.ok()
|
||||
.and_then(|m| resolve_registry_config(&m, client_name))
|
||||
else {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"Client credentials with your own credentials are only supported for built-in OAuth \
|
||||
providers, not '{client_name}'. Configure shared credentials on the instance OAuth \
|
||||
entry instead."
|
||||
)));
|
||||
};
|
||||
|
||||
// Instance-templated providers carry the `{instance}` token URL (and its
|
||||
// label/strip_suffix) in `connect_config_template`; fixed-host providers use
|
||||
// the plain `token_url`.
|
||||
let tmpl = cfg.connect_config_template.as_ref();
|
||||
let template = tmpl
|
||||
.map(|t| t.token_url.clone())
|
||||
.filter(|u| !u.is_empty())
|
||||
.or_else(|| Some(cfg.token_url.clone()).filter(|u| !u.is_empty()))
|
||||
.ok_or_else(|| {
|
||||
error::Error::BadRequest(format!("No token URL is configured for '{client_name}'"))
|
||||
})?;
|
||||
|
||||
if !template.contains("{instance}") {
|
||||
// Fixed-host registry provider: its registry token URL is authoritative.
|
||||
return Ok(template);
|
||||
}
|
||||
|
||||
// Structural host-pinning guard: only substitute when `{instance}` is the
|
||||
// leftmost host label of a fixed-host template (`scheme://{instance}.fixed-host/…`).
|
||||
// The hostname-label validation below keeps the value clean, but only this
|
||||
// check guarantees the substituted value can never change the registrable
|
||||
// domain — so a malformed template (e.g. `https://{instance}/token`) can't turn
|
||||
// the caller's instance name into a full attacker-controlled host (SSRF /
|
||||
// credential exfiltration). The template is a code-reviewed registry file, so a
|
||||
// violation is a programming error.
|
||||
let placeholder = "{instance}";
|
||||
let idx = template.find(placeholder).unwrap();
|
||||
let after = &template[idx + placeholder.len()..];
|
||||
if !template[..idx].ends_with("://") || !after.starts_with('.') {
|
||||
return Err(error::Error::InternalErr(format!(
|
||||
"Invalid instance-templated token URL for '{client_name}': {{instance}} must be the \
|
||||
leftmost host label (scheme://{{instance}}.fixed-host/…)"
|
||||
)));
|
||||
}
|
||||
|
||||
let raw = caller_instance
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
error::Error::BadRequest(format!(
|
||||
"{} is required for {client_name}",
|
||||
tmpl.map(|t| t.label.as_str()).unwrap_or("An instance name")
|
||||
))
|
||||
})?;
|
||||
// Strip an optional known host suffix so the user can paste a full host or a
|
||||
// bare name, then accept only a hostname label — never any character that
|
||||
// could move the host out of the template's domain.
|
||||
let value = tmpl
|
||||
.and_then(|t| t.strip_suffix.as_deref())
|
||||
.and_then(|sfx| raw.strip_suffix(sfx))
|
||||
.unwrap_or(raw)
|
||||
.trim_end_matches('.');
|
||||
let valid = !value.is_empty()
|
||||
&& !value.starts_with(['-', '.'])
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.');
|
||||
if !valid {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"invalid instance name '{raw}' for {client_name}"
|
||||
)));
|
||||
}
|
||||
Ok(template.replace("{instance}", value))
|
||||
}
|
||||
|
||||
/// Resolve the concrete bring-your-own client-credentials token URL for any
|
||||
/// provider, never from a caller-supplied URL:
|
||||
/// - **Built-in registry providers** resolve from the registry via
|
||||
/// [`resolve_cc_token_url_input`] (host-pinned from the caller's instance name
|
||||
/// for instance-templated ones).
|
||||
/// - **Custom providers configured at the instance level** use the admin's
|
||||
/// `connect_config.token_url`. The caller has no instance template to fill, so
|
||||
/// an instance name is rejected.
|
||||
///
|
||||
/// This is the single entry point the connect/account-creation handlers should
|
||||
/// use so both resolve identically.
|
||||
pub async fn resolve_cc_token_url(
|
||||
db: &DB,
|
||||
client_name: &str,
|
||||
caller_instance: Option<&str>,
|
||||
connect_configs_json: &str,
|
||||
) -> error::Result<String> {
|
||||
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
|
||||
|
||||
let supports_cc =
|
||||
|grant_types: &[String]| grant_types.iter().any(|g| g == "client_credentials");
|
||||
|
||||
let registry_cfg = serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
|
||||
.ok()
|
||||
.and_then(|m| resolve_registry_config(&m, client_name));
|
||||
if let Some(cfg) = registry_cfg {
|
||||
// Built-in provider: only honor it for client credentials if it actually
|
||||
// declares that grant, so an authorization-code-only provider can't be
|
||||
// driven through the CC API.
|
||||
if !supports_cc(&cfg.grant_types) {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"'{client_name}' is not enabled for the client_credentials grant"
|
||||
)));
|
||||
}
|
||||
return resolve_cc_token_url_input(connect_configs_json, client_name, caller_instance);
|
||||
}
|
||||
|
||||
// Custom (non-registry) provider: the token URL comes from the admin's
|
||||
// instance connect_config (an admin-configured, trusted host), never the
|
||||
// caller. The instance entry must also enable the client-credentials grant.
|
||||
let entry: Option<OAuthClient> = load_value_from_global_settings(db, OAUTH_SETTING)
|
||||
.await?
|
||||
.as_ref()
|
||||
.and_then(|o| o.get(client_name))
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let instance_token_url = entry
|
||||
.as_ref()
|
||||
.filter(|e| supports_cc(&e.grant_types))
|
||||
.and_then(|e| e.connect_config.clone())
|
||||
.map(|c| c.token_url)
|
||||
.filter(|u| !u.is_empty());
|
||||
match instance_token_url {
|
||||
Some(_)
|
||||
if caller_instance
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
Err(error::Error::BadRequest(format!(
|
||||
"An instance name only applies to built-in instance-templated providers, not \
|
||||
'{client_name}'"
|
||||
)))
|
||||
}
|
||||
Some(url) => Ok(url),
|
||||
None => Err(error::Error::BadRequest(format!(
|
||||
"Client credentials with your own credentials require '{client_name}' to be a built-in \
|
||||
OAuth provider or an instance entry that enables the client_credentials grant"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange authorization code for tokens
|
||||
@@ -462,7 +759,7 @@ pub async fn exchange_token(
|
||||
client: OClient,
|
||||
refresh_token: &str,
|
||||
grant_type: &str,
|
||||
oauth_client_info: Option<&ClientWithScopes>,
|
||||
extra_params_callback: Option<&HashMap<String, String>>,
|
||||
http_client: &reqwest::Client,
|
||||
scopes: Option<&[String]>,
|
||||
) -> Result<TokenResponse, Error> {
|
||||
@@ -483,11 +780,9 @@ pub async fn exchange_token(
|
||||
"client_credentials" => {
|
||||
let mut token_request = client.exchange_client_credentials();
|
||||
|
||||
if let Some(oauth_info) = oauth_client_info {
|
||||
if let Some(extra_params) = oauth_info.extra_params_callback.as_ref() {
|
||||
for (key, value) in extra_params.iter() {
|
||||
token_request = token_request.param(key.clone(), value.clone());
|
||||
}
|
||||
if let Some(extra_params) = extra_params_callback {
|
||||
for (key, value) in extra_params.iter() {
|
||||
token_request = token_request.param(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,49 +874,78 @@ pub async fn refresh_token_for_account<'c>(
|
||||
http_client: &reqwest::Client,
|
||||
connect_configs_json: &str,
|
||||
) -> error::Result<String> {
|
||||
let oauth_client_info = oauth_clients
|
||||
.connects
|
||||
.get(&account.client)
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
|
||||
.clone();
|
||||
// Instance-configured client: required for authorization_code (the refresh
|
||||
// token exchange uses the instance app's credentials). For client_credentials
|
||||
// it is resolved inside `build_client_credentials_oauth_client` instead.
|
||||
let oauth_client_info = oauth_clients.connects.get(&account.client).cloned();
|
||||
|
||||
let mut client = if account.grant_type == "client_credentials" {
|
||||
match (&account.cc_client_id, &account.cc_client_secret) {
|
||||
(Some(client_id), Some(client_secret)) => {
|
||||
let (client, _) = build_client_credentials_oauth_client(
|
||||
db,
|
||||
&account.client,
|
||||
client_id,
|
||||
client_secret,
|
||||
account.cc_token_url.as_deref(),
|
||||
connect_configs_json,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
}
|
||||
_ => {
|
||||
return Err(error::Error::BadRequest(
|
||||
"client_credentials flow requires cc_client_id and cc_client_secret to be stored in account".to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
let is_client_credentials = account.grant_type == "client_credentials";
|
||||
|
||||
let (mut client, cc_config) = if is_client_credentials {
|
||||
// Bring-your-own accounts store their own credentials (and resolved token
|
||||
// URL) on the row. Shared instance accounts store none: passing empty
|
||||
// credentials makes the builder re-resolve the admin's service-account
|
||||
// credentials and token URL from the instance entry on every refresh, so a
|
||||
// rotated or removed shared secret takes effect immediately (mirrors the
|
||||
// authorization-code model, where the row never holds the app secret).
|
||||
let (client_id, client_secret) = match (&account.cc_client_id, &account.cc_client_secret) {
|
||||
(Some(id), Some(secret)) => (id.as_str(), secret.as_str()),
|
||||
_ => ("", ""),
|
||||
};
|
||||
let (client, config) = build_client_credentials_oauth_client(
|
||||
db,
|
||||
&account.client,
|
||||
client_id,
|
||||
client_secret,
|
||||
account.cc_token_url.as_deref(),
|
||||
connect_configs_json,
|
||||
)
|
||||
.await?;
|
||||
(client, Some(config))
|
||||
} else {
|
||||
oauth_client_info.client.to_owned()
|
||||
let info = oauth_client_info
|
||||
.as_ref()
|
||||
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?;
|
||||
(info.client.to_owned(), None)
|
||||
};
|
||||
|
||||
// Account-level scopes override instance-level scopes
|
||||
// Account-level scopes (when stored) override these defaults. Client-credentials
|
||||
// accounts default to the resolved CC config's scopes (`cc_scopes` for registry
|
||||
// providers, the admin's instance scopes for custom ones) — never the instance
|
||||
// client's authorization-code scopes, which are invalid in a 2-legged request.
|
||||
// Authorization-code accounts default to the instance client's scopes.
|
||||
let fallback_scopes = if is_client_credentials {
|
||||
cc_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.scopes.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
oauth_client_info
|
||||
.as_ref()
|
||||
.map(|i| i.scopes.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let effective_scopes = account
|
||||
.scopes
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&oauth_client_info.scopes);
|
||||
.unwrap_or(&fallback_scopes);
|
||||
|
||||
if account.grant_type == "client_credentials" {
|
||||
if is_client_credentials {
|
||||
for scope in effective_scopes.iter() {
|
||||
client.add_scope(scope);
|
||||
}
|
||||
}
|
||||
|
||||
let extra_params_callback = oauth_client_info
|
||||
.as_ref()
|
||||
.and_then(|i| i.extra_params_callback.clone())
|
||||
.or_else(|| {
|
||||
cc_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.extra_params_callback.clone())
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
grant_type = %account.grant_type,
|
||||
client = %account.client,
|
||||
@@ -634,7 +958,7 @@ pub async fn refresh_token_for_account<'c>(
|
||||
client,
|
||||
&account.refresh_token,
|
||||
&account.grant_type,
|
||||
Some(&oauth_client_info),
|
||||
extra_params_callback.as_ref(),
|
||||
http_client,
|
||||
Some(effective_scopes),
|
||||
)
|
||||
@@ -846,6 +1170,7 @@ mod tests {
|
||||
token_url: "https://account.example.com/oauth/token".to_string(),
|
||||
userinfo_url: Some("https://account.example.com/userinfo".to_string()),
|
||||
scopes: Some(vec!["signature".to_string()]),
|
||||
cc_scopes: None,
|
||||
extra_params: None,
|
||||
extra_params_callback: None,
|
||||
req_body_auth: None,
|
||||
@@ -927,4 +1252,98 @@ mod tests {
|
||||
registry.insert("docusign".to_string(), sample_oauth_config(false));
|
||||
assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none());
|
||||
}
|
||||
|
||||
const CC_REGISTRY: &str = r#"{
|
||||
"coupa": {
|
||||
"grant_types": ["client_credentials"],
|
||||
"connect_config_template": {
|
||||
"label": "Coupa instance",
|
||||
"placeholder": "x",
|
||||
"token_url": "https://{instance}.coupahost.com/oauth2/token",
|
||||
"strip_suffix": ".coupahost.com"
|
||||
}
|
||||
},
|
||||
"servicenow": {
|
||||
"grant_types": ["authorization_code", "client_credentials"],
|
||||
"connect_config_template": {
|
||||
"label": "ServiceNow instance",
|
||||
"placeholder": "dev12345",
|
||||
"auth_url": "https://{instance}.service-now.com/oauth_auth.do",
|
||||
"token_url": "https://{instance}.service-now.com/oauth_token.do",
|
||||
"strip_suffix": ".service-now.com"
|
||||
}
|
||||
},
|
||||
"visma": {
|
||||
"auth_url": "https://connect.visma.com/connect/authorize",
|
||||
"token_url": "https://connect.visma.com/connect/token",
|
||||
"grant_types": ["authorization_code", "client_credentials"]
|
||||
},
|
||||
"bad_host_tpl": {
|
||||
"grant_types": ["client_credentials"],
|
||||
"connect_config_template": {
|
||||
"label": "x", "placeholder": "x",
|
||||
"token_url": "https://{instance}/token"
|
||||
}
|
||||
},
|
||||
"bad_mid_tpl": {
|
||||
"grant_types": ["client_credentials"],
|
||||
"connect_config_template": {
|
||||
"label": "x", "placeholder": "x",
|
||||
"token_url": "https://api.{instance}.evil.com/token"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_templated_substitutes_instance() {
|
||||
let url = resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("acme")).unwrap();
|
||||
assert_eq!(url, "https://acme.coupahost.com/oauth2/token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_templated_from_connect_config_template() {
|
||||
// ServiceNow's CC token URL comes from its connect_config_template.
|
||||
let url = resolve_cc_token_url_input(CC_REGISTRY, "servicenow", Some("dev99")).unwrap();
|
||||
assert_eq!(url, "https://dev99.service-now.com/oauth_token.do");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_strips_known_host_suffix() {
|
||||
let url =
|
||||
resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("acme.coupahost.com")).unwrap();
|
||||
assert_eq!(url, "https://acme.coupahost.com/oauth2/token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_rejects_instance_that_escapes_the_host() {
|
||||
// A '/' (or any non-hostname char) must not let the caller move the host
|
||||
// out of the template's domain.
|
||||
assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("evil.com/oauth")).is_err());
|
||||
assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("a@b")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_requires_instance_when_templated() {
|
||||
assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_fixed_host_uses_registry_url() {
|
||||
let url = resolve_cc_token_url_input(CC_REGISTRY, "visma", None).unwrap();
|
||||
assert_eq!(url, "https://connect.visma.com/connect/token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_rejects_custom_provider() {
|
||||
// No registry entry: bring-your-own client credentials are not allowed.
|
||||
assert!(resolve_cc_token_url_input(CC_REGISTRY, "my_custom_thing", Some("acme")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_token_url_rejects_template_not_in_subdomain_position() {
|
||||
// `{instance}` must be the leftmost host label of a fixed-host template, so
|
||||
// a malformed template can't let the instance value control the host.
|
||||
assert!(resolve_cc_token_url_input(CC_REGISTRY, "bad_host_tpl", Some("evil.com")).is_err());
|
||||
assert!(resolve_cc_token_url_input(CC_REGISTRY, "bad_mid_tpl", Some("evil")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.728.1";
|
||||
export const VERSION = "v1.729.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -799,7 +799,7 @@ async function rehashCommand(
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("Generate metadata (locks, schemas) for all scripts, flows, and apps")
|
||||
.description("Regenerate stale local locks and script schemas and refresh wmill-lock.yaml content hashes (scripts, flows, apps). Writes local files only, not a deploy. Run it after edits that add or remove imports or change a script's arguments, so the lock, the auto-generated UI schema, and wmill-lock.yaml stay in sync.")
|
||||
.arguments("[folder:string]")
|
||||
.option("--yes", "Skip confirmation prompt")
|
||||
.option("--dry-run", "Show what would be updated without making changes")
|
||||
@@ -823,9 +823,7 @@ const command = new Command()
|
||||
"rehash",
|
||||
new Command()
|
||||
.description(
|
||||
"Trust on-disk content; rewrite wmill-lock.yaml hashes without backend " +
|
||||
"trips or yaml/lock rewrites. Useful for bootstrapping missing lockfile " +
|
||||
"entries or recovering from older-CLI hash drift."
|
||||
"Refresh wmill-lock.yaml content hashes from the on-disk .lock and .script.yaml without re-resolving dependencies or hitting the backend. Use when those files are already correct and only the hashes need updating: bootstrapping missing entries or recovering from hash drift."
|
||||
)
|
||||
.arguments("[folder:string]")
|
||||
.option("--skip-scripts", "Skip processing scripts")
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.728.1";
|
||||
export const VERSION = "1.729.0";
|
||||
|
||||
@@ -117,6 +117,20 @@ Local previews exist for every entity type and don't deploy:
|
||||
|
||||
Argument shapes and per-language details live in the \`write-script-<lang>\`, \`write-flow\`, and \`raw-app\` skills.
|
||||
|
||||
## Keeping metadata in sync
|
||||
|
||||
After editing a script, flow inline script, or app runnable, its generated metadata can go stale. \`wmill-lock.yaml\` stores a content hash per item, so a change that **adds or removes an import** or **changes a script's arguments** invalidates that hash and leaves the \`.lock\` (resolved dependencies) and \`.script.yaml\` (the input schema that drives the auto-generated args UI) out of date. \`wmill generate-metadata\` regenerates them and refreshes the hashes. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files — it is **not** a deploy — but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default **offer it and run it once the user agrees**, rather than running it silently after every edit. YOU run the command (never tell the user to run it); the choice is only whether to confirm first.
|
||||
|
||||
After running it, diff the regenerated lockfiles (e.g. \`git diff\` the \`.lock\` / \`.script.lock\` files): if any dependency versions changed, tell the user what bumped (e.g. \`requests 2.31.0 → 2.32.0\`) so they can catch an unwanted change before deploying. Do this even under \`Metadata: auto\` — it is information, not a confirmation gate. Pin a version in code to keep it fixed.
|
||||
|
||||
With no path argument it regenerates only the items whose metadata is actually stale (content hash drifted), workspace-wide — not everything. The set can be larger than the file you edited for two reasons: imports propagate (editing a script that others import marks every importer stale too, so their locks regenerate against the new code — by design, since a lock must reflect the imported code), and any pre-existing drift is swept in. If it touches items you didn't expect, run \`wmill generate-metadata --dry-run\` first — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) and changes nothing, so you can see why each is in scope. To narrow it, pass a folder or file path (\`wmill generate-metadata f/foo\`); add \`--strict-folder-boundaries\` to touch only items literally inside that folder (it warns about stale importers outside the folder that it skipped — they resurface as stale on the next unscoped run).
|
||||
|
||||
**Save the preference so you don't ask every session.** If the user wants metadata regenerated automatically after edits (or always confirmed first), record it in the **project-specific instructions** section of \`AGENTS.md\` (user-owned — never overwritten by \`wmill refresh prompts\`), e.g. a line like \`Metadata: auto (run wmill generate-metadata after edits)\` or \`Metadata: ask first\`. Read that line first on later sessions and follow it.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
## Deploying
|
||||
|
||||
There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit.
|
||||
|
||||
+266
-52
@@ -51,7 +51,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -66,13 +66,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -141,7 +151,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -156,13 +166,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -224,7 +244,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -239,13 +259,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -916,7 +946,7 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
* @param name - DuckDB database name (default: "main")
|
||||
* @param name - DuckDB database name, optionally with a schema as \`name:schema\` (default: "main")
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.ducklake()
|
||||
@@ -926,6 +956,9 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Target a specific schema within the ducklake
|
||||
* let sql = wmill.ducklake("my_lake:analytics")
|
||||
*/
|
||||
ducklake(name: string = "main"): SqlTemplateFunction
|
||||
`,
|
||||
@@ -942,7 +975,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -957,13 +990,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -1634,7 +1677,7 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
* @param name - DuckDB database name (default: "main")
|
||||
* @param name - DuckDB database name, optionally with a schema as \`name:schema\` (default: "main")
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.ducklake()
|
||||
@@ -1644,6 +1687,9 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Target a specific schema within the ducklake
|
||||
* let sql = wmill.ducklake("my_lake:analytics")
|
||||
*/
|
||||
ducklake(name: string = "main"): SqlTemplateFunction
|
||||
`,
|
||||
@@ -1660,7 +1706,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -1675,13 +1721,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -1742,7 +1798,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -1757,13 +1813,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -2434,7 +2500,7 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
|
||||
/**
|
||||
* Create a SQL template function for DuckDB/ducklake queries
|
||||
* @param name - DuckDB database name (default: "main")
|
||||
* @param name - DuckDB database name, optionally with a schema as \`name:schema\` (default: "main")
|
||||
* @returns SQL template function for building parameterized queries
|
||||
* @example
|
||||
* let sql = wmill.ducklake()
|
||||
@@ -2444,6 +2510,9 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction
|
||||
* SELECT * FROM friends
|
||||
* WHERE name = \${name} AND age = \${age}
|
||||
* \`.fetch()
|
||||
* @example
|
||||
* // Target a specific schema within the ducklake
|
||||
* let sql = wmill.ducklake("my_lake:analytics")
|
||||
*/
|
||||
ducklake(name: string = "main"): SqlTemplateFunction
|
||||
`,
|
||||
@@ -2460,7 +2529,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -2475,13 +2544,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -2576,7 +2655,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -2591,13 +2670,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -2675,7 +2764,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -2690,13 +2779,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -2761,7 +2860,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -2776,13 +2875,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -2840,7 +2949,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -2855,13 +2964,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -2922,7 +3041,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -2937,13 +3056,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -3005,7 +3134,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -3020,13 +3149,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -3103,7 +3242,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -3118,13 +3257,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -3184,7 +3333,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -3199,13 +3348,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -3280,7 +3439,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -3295,13 +3454,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -4146,7 +4315,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -4161,13 +4330,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -4272,7 +4451,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -4287,13 +4466,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -4388,7 +4577,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -4403,13 +4592,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -4504,11 +4703,11 @@ Once the flow has real content, **offer** to open the visual preview as a one-se
|
||||
|
||||
## CLI Commands — running, previewing, deploying
|
||||
|
||||
After writing, act on the user's intent instead of just listing commands. Run the safe, non-deploying command yourself when it fits (\`wmill flow preview\` — see "After writing — offer to run, don't wait passively" below); only *name* the commands that deploy or rewrite files (\`wmill sync push\`, \`wmill generate-metadata\`) so the user can approve them. The options:
|
||||
After writing, act on the user's intent instead of just listing commands. Run \`wmill flow preview\` yourself when it fits (see "After writing — offer to run, don't wait passively" below). \`wmill generate-metadata\` regenerates local lock/hash files (not a deploy) but re-resolves deps — offer it and run on agreement, unless the project's \`AGENTS.md\` opts into running metadata automatically. Only *name* \`wmill sync push\` (the deploy) so the user can approve it. The options:
|
||||
|
||||
- \`wmill flow preview <flow_path>\` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. Add \`--step <step_id>\` to run only one module in isolation (see "Single-step vs whole-flow preview" below).
|
||||
- \`wmill flow run <path>\` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — regenerate stale \`.lock\` and \`.script.yaml\` files. By default it scans **scripts, flows, and apps** across the workspace; pass \`--skip-flows --skip-apps\` (or run from a subdirectory) to limit the scope when you only care about the flow you edited.
|
||||
- \`wmill generate-metadata\` — regenerate stale local \`.lock\` files for the flow and its inline scripts and refresh their content hashes in \`wmill-lock.yaml\`. Writes local files only (not a deploy). Run it after editing inline scripts whose imports or arguments changed, so \`wmill-lock.yaml\` doesn't drift and add noise to git-sync/CI. By default it scans **scripts, flows, and apps** across the workspace but only regenerates stale ones; pass the flow's folder as an argument (or run from that subdirectory) to limit the scope to the flow you edited. Note a flow (or script) that imports a changed shared script is pulled in too — run \`wmill generate-metadata --dry-run\` to see exactly what is stale and why (\`content changed\` vs \`depends on <path>\`) before applying.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -4537,7 +4736,7 @@ If the user hasn't already told you to run/test the flow, offer it as a one-sent
|
||||
|
||||
If the user already asked to test/run/try the flow in their original request, skip the offer and just execute \`wmill flow preview <path> -d '<args>'\` directly — pick plausible args from the flow's input schema.
|
||||
|
||||
\`wmill flow preview\` is safe to run yourself (it does not deploy). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill flow preview\` is safe to run yourself (it does not deploy). \`wmill generate-metadata\` does not deploy either (it only writes local lock/hash files) but re-resolves deps — offer it and run on agreement, unless the project's \`AGENTS.md\` opts into automatic metadata. After running it, check the regenerated \`.lock\` diff and tell the user which inline-script dependency versions changed, so they can catch an unwanted bump before deploying. Only \`wmill sync push\` deploys; run it only when the user explicitly asks.
|
||||
|
||||
### Visual preview
|
||||
|
||||
@@ -4970,10 +5169,11 @@ The runnable ID is the filename without extension. For example, \`get_user.ts\`
|
||||
| C# | \`.cs\` | \`myFunc.cs\` |
|
||||
| Java | \`.java\` | \`myFunc.java\` |
|
||||
|
||||
After creating a runnable, offer to generate its lock files as a one-sentence next step (e.g. "Want me to generate the lock files?") and run it yourself once they agree — don't just name the command and wait. If the user already asked you to finish/lock the app, run it directly. It writes local lock files (not a deploy), so offer rather than running silently:
|
||||
After creating or editing a backend runnable — especially when its imports or arguments changed — its local lock and \`wmill-lock.yaml\` go stale. Offer to run \`wmill generate-metadata\` and run it once the user agrees (or automatically if the project's \`AGENTS.md\` opts into that) — YOU run it, don't just name it and wait. It writes local files only (not a deploy), and keeping the lock current avoids noise in git-sync/CI:
|
||||
\`\`\`bash
|
||||
wmill generate-metadata
|
||||
\`\`\`
|
||||
After it runs, check the regenerated \`.lock\` diff and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying.
|
||||
|
||||
### Optional YAML configuration
|
||||
|
||||
@@ -5063,7 +5263,7 @@ data:
|
||||
|
||||
Two commands you run yourself, not the user:
|
||||
- \`wmill app new\` — run it with flags, per the "Creating a Raw App" section above.
|
||||
- \`wmill generate-metadata\` — generates local lock files; offer it and run it on consent, per "After creating a runnable" above (it writes local lock files, not a deploy).
|
||||
- \`wmill generate-metadata\` — (re)generates local lock files and refreshes \`wmill-lock.yaml\` content hashes; writes local files only (not a deploy). After adding or editing a runnable, offer it and run it on agreement — or automatically if the project's \`AGENTS.md\` opts into that (see "After creating a runnable" above).
|
||||
|
||||
For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time:
|
||||
|
||||
@@ -5603,7 +5803,7 @@ After writing, tell the user which command fits what they want to do:
|
||||
|
||||
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
|
||||
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
|
||||
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
|
||||
- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below.
|
||||
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
|
||||
|
||||
### Preview vs run — choose by intent, not habit
|
||||
@@ -5618,13 +5818,23 @@ Only use \`sync push\` when:
|
||||
- The user explicitly asks to deploy, publish, push, or ship.
|
||||
- The preview has already validated the change and the user wants it in the workspace.
|
||||
|
||||
### Keep metadata in sync after editing
|
||||
|
||||
\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI.
|
||||
|
||||
This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed.
|
||||
|
||||
With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on <path>\`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`.
|
||||
|
||||
If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes.
|
||||
|
||||
### After writing — offer to test, don't wait passively
|
||||
|
||||
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
|
||||
|
||||
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
|
||||
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
|
||||
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push.
|
||||
|
||||
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
|
||||
|
||||
@@ -6229,7 +6439,7 @@ folder related commands
|
||||
|
||||
### generate-metadata
|
||||
|
||||
Generate metadata (locks, schemas) for all scripts, flows, and apps
|
||||
Regenerate stale local locks and script schemas and refresh wmill-lock.yaml content hashes (scripts, flows, apps). Writes local files only, not a deploy. Run it after edits that add or remove imports or change a script's arguments, so the lock, the auto-generated UI schema, and wmill-lock.yaml stay in sync.
|
||||
|
||||
**Arguments:** \`[folder:string]\`
|
||||
|
||||
@@ -6248,7 +6458,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`generate-metadata rehash [folder:string]\`
|
||||
- \`generate-metadata rehash [folder:string]\` - Refresh wmill-lock.yaml content hashes from the on-disk .lock and .script.yaml without re-resolving dependencies or hitting the backend. Use when those files are already correct and only the hashes need updating: bootstrapping missing entries or recovering from hash drift.
|
||||
- \`--skip-scripts\` - Skip processing scripts
|
||||
- \`--skip-flows\` - Skip processing flows
|
||||
- \`--skip-apps\` - Skip processing apps
|
||||
@@ -6356,7 +6566,7 @@ sync local with a remote instance or the opposite (push or pull)
|
||||
- \`-o, --output-file <file:string>\` - Write YAML to a file instead of stdout
|
||||
- \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting
|
||||
- \`--instance <instance:string>\` - Name of the instance, override the active instance
|
||||
- \`instance connect-slack\`
|
||||
- \`instance connect-slack\` - Non-interactively connect Slack at the instance level using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: global_settings 'slack' row + encrypted f/slack_bot/global_bot_token variable and resource in the admins workspace.
|
||||
- \`--bot-token <bot_token:string>\` - Slack bot token (xoxb-...)
|
||||
- \`--team-id <team_id:string>\` - Slack team id
|
||||
- \`--team-name <team_name:string>\` - Slack team name
|
||||
@@ -6410,6 +6620,8 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory
|
||||
|
||||
### object-storage
|
||||
|
||||
Object storage (S3) related commands. Operates on the workspace's default object storage; use --storage to target a configured secondary storage.
|
||||
|
||||
**Alias:** \`s3\`
|
||||
|
||||
**Subcommands:**
|
||||
@@ -6457,6 +6669,8 @@ inspect asset-driven pipelines (scripts marked \`// pipeline\`, wired by \`// on
|
||||
|
||||
### protection-rules
|
||||
|
||||
Sync workspace protection rules between protection-rules.yaml and Windmill. The file is keyed by workspace name; keys must match wmill.yaml 'workspaces'.
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`protection-rules pull [workspace:string]\` - Pull protection rules from Windmill into protection-rules.yaml for a workspace
|
||||
@@ -6797,7 +7011,7 @@ workspace related commands
|
||||
- \`--bot-token <bot_token:string>\` - Slack bot token (xoxb-...)
|
||||
- \`--team-id <team_id:string>\` - Slack team id
|
||||
- \`--team-name <team_name:string>\` - Slack team name
|
||||
- \`workspace disconnect-slack\`
|
||||
- \`workspace disconnect-slack\` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push.
|
||||
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.728.1",
|
||||
"version": "1.729.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.728.1",
|
||||
"version": "1.729.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.728.1",
|
||||
"version": "1.729.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
|
||||
@@ -47,8 +47,9 @@
|
||||
|
||||
async function isSupabaseAvailable() {
|
||||
try {
|
||||
supabaseWizard =
|
||||
((await OauthService.listOauthConnects()) ?? {})['supabase_wizard'] != undefined
|
||||
supabaseWizard = ((await OauthService.listOauthConnects()) ?? []).some(
|
||||
(c) => c.name === 'supabase_wizard'
|
||||
)
|
||||
} catch (error) {}
|
||||
}
|
||||
async function loadSchema() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import oauthConnectRegistry from '$oauth_connect_registry'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
import Path from './Path.svelte'
|
||||
import { Button, Skeleton } from './common'
|
||||
import { Button, RadioCard, Skeleton } from './common'
|
||||
import ApiConnectForm from './ApiConnectForm.svelte'
|
||||
import SearchItems from './SearchItems.svelte'
|
||||
import WhitelistIp from './WhitelistIp.svelte'
|
||||
@@ -27,11 +27,10 @@
|
||||
import { base } from '$lib/base'
|
||||
import Required from './Required.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { Pen } from 'lucide-svelte'
|
||||
import { Pen, Search } from 'lucide-svelte'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect'
|
||||
import type { SchemaProperty } from '$lib/common'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import { sameTopDomainOrigin } from '$lib/cookies'
|
||||
import SyncResourceTypes from './SyncResourceTypes.svelte'
|
||||
@@ -75,6 +74,18 @@
|
||||
let value: string = $state('')
|
||||
let valueToken: TokenResponse | undefined = undefined
|
||||
let connects: string[] | undefined = $state(undefined)
|
||||
/** Per-provider instance-entry metadata, keyed by provider name. */
|
||||
let connectsInfo: Record<
|
||||
string,
|
||||
{ supports_client_credentials: boolean; has_shared_credentials: boolean }
|
||||
> = $state({})
|
||||
|
||||
/** An instance entry with shared credentials (admin id+secret): connect with
|
||||
* no input. Shown under "Instance-configured"; bring-your-own-only providers
|
||||
* (no shared creds) are shown under "Others" instead. */
|
||||
function isSharedConnect(key: string): boolean {
|
||||
return connectsInfo[key]?.has_shared_credentials ?? false
|
||||
}
|
||||
|
||||
const SANDBOX_SUFFIX = '_sandbox'
|
||||
function stripSandboxSuffix(name: string): string {
|
||||
@@ -119,6 +130,9 @@
|
||||
}
|
||||
|
||||
let scopes: string[] = $state([])
|
||||
/** The authorization-code default scopes (instance entry / registry), kept so
|
||||
* toggling back from client-credentials can restore them. */
|
||||
let instanceScopes: string[] = $state([])
|
||||
let extra_params: [string, string][] = []
|
||||
let responseExtra: Record<string, string> = $state({})
|
||||
let path: string = $state('')
|
||||
@@ -147,11 +161,132 @@
|
||||
*/
|
||||
let clientId = $state('')
|
||||
let clientSecret = $state('')
|
||||
let tokenUrl = $state('')
|
||||
let ccInstance = $state('')
|
||||
|
||||
let resourceTypeInfo: ResourceType | undefined = $state(undefined)
|
||||
let resourceTypeNotFound = $state(false)
|
||||
|
||||
function registryEntry(): any {
|
||||
const reg = oauthConnectRegistry as Record<string, any>
|
||||
// Resolve `_sandbox` clients to their parent registry entry (e.g.
|
||||
// salesforce_sandbox -> salesforce) so sandbox connections see CC metadata.
|
||||
return reg[stripSandboxSuffix(connectClient)] ?? reg[stripSandboxSuffix(resourceType)]
|
||||
}
|
||||
|
||||
/** The static registry declares this provider supports client credentials */
|
||||
function registryCcCapable(): boolean {
|
||||
return registryEntry()?.grant_types?.includes('client_credentials') ?? false
|
||||
}
|
||||
|
||||
/** Instance-name metadata for providers whose token URL is instance-templated
|
||||
* (carried in `connect_config_template`): the user enters an instance name
|
||||
* instead of a full token URL, and the backend substitutes it into the
|
||||
* fixed-host template so the exchange host stays pinned. */
|
||||
let ccInstanceMeta = $derived(
|
||||
registryEntry()?.connect_config_template as
|
||||
| { label: string; placeholder: string; help_url?: string }
|
||||
| undefined
|
||||
)
|
||||
|
||||
/** Instance entry declares client credentials but not authorization_code
|
||||
* (custom provider configured with only a token URL) */
|
||||
let authCodeUnavailable = $state(false)
|
||||
|
||||
/** Instance entry carries shared client-credentials (id + secret); the user
|
||||
* doesn't enter their own — the exchange runs server-side with those creds */
|
||||
let ccInstanceConfigured = $state(false)
|
||||
|
||||
/** The user wants their own credentials (picked the provider from the "Others"
|
||||
* section) — overrides the shared instance credentials for this connection */
|
||||
let ccBringYourOwn = $state(false)
|
||||
|
||||
/** Connect with the shared instance credentials (no form) rather than the
|
||||
* bring-your-own form */
|
||||
let useSharedInstanceCreds = $derived(ccInstanceConfigured && !ccBringYourOwn)
|
||||
|
||||
/** Connectable via client credentials only: registry-declared provider with
|
||||
* no instance OAuth client, or instance provider without an authorize URL */
|
||||
let ccOnly = $derived.by(
|
||||
() =>
|
||||
authCodeUnavailable ||
|
||||
(registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false))
|
||||
)
|
||||
|
||||
/** Clear CC inputs and scopes so a previous selection never leaks into a new one */
|
||||
function resetClientCredentialsState() {
|
||||
supportsClientCredentials = false
|
||||
useClientCredentials = false
|
||||
authCodeUnavailable = false
|
||||
ccInstanceConfigured = false
|
||||
ccBringYourOwn = false
|
||||
clientId = ''
|
||||
clientSecret = ''
|
||||
ccInstance = ''
|
||||
scopes = []
|
||||
}
|
||||
|
||||
/** Default scopes for the client-credentials grant. Registry providers use
|
||||
* their `cc_scopes` (auth-code scopes are invalid in a 2-legged request);
|
||||
* custom (non-registry) providers configured at the instance level have no
|
||||
* registry entry, so they keep their admin-configured scopes (`instanceScopes`)
|
||||
* instead of being zeroed. */
|
||||
function defaultCcScopes(): string[] {
|
||||
const entry = registryEntry()
|
||||
return entry ? (entry.cc_scopes ?? []) : instanceScopes
|
||||
}
|
||||
|
||||
function enableClientCredentials() {
|
||||
manual = false
|
||||
supportsClientCredentials = true
|
||||
if (!useClientCredentials) {
|
||||
// Switching into client-credentials: default to the CC scopes (never the
|
||||
// authorization-code scopes — most providers reject member/consent scopes
|
||||
// in a 2-legged request). Only reset on the transition so edits made while
|
||||
// already in CC mode are preserved.
|
||||
scopes = defaultCcScopes()
|
||||
}
|
||||
useClientCredentials = true
|
||||
}
|
||||
|
||||
/** Switch to the browser sign-in (authorization-code) grant, restoring its
|
||||
* default scopes when coming from the client-credentials grant. */
|
||||
function selectAuthCodeGrant() {
|
||||
if (useClientCredentials) {
|
||||
scopes = instanceScopes
|
||||
}
|
||||
useClientCredentials = false
|
||||
}
|
||||
|
||||
/** Static registry declares client-credentials support for `key`. */
|
||||
function isCcCapable(key: string): boolean {
|
||||
return (
|
||||
(oauthConnectRegistry as Record<string, any>)[stripSandboxSuffix(key)]?.grant_types?.includes(
|
||||
'client_credentials'
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
/** Step-1 "Others" selection: CC-capable resource types open the client-
|
||||
* credentials form with the user's own credentials — even when the instance
|
||||
* has shared ones (the "Instance-configured OAuth APIs" section is the entry
|
||||
* point for those). Every other type opens the raw manual form. */
|
||||
function selectFromOthers(key: string) {
|
||||
connectClient = key
|
||||
resourceType = key
|
||||
resetClientCredentialsState()
|
||||
// Registry CC providers and instance-configured providers that declare the
|
||||
// client-credentials grant (incl. custom providers set up with only a token
|
||||
// URL and no shared creds) open the bring-your-own form. Everything else is
|
||||
// a manual resource.
|
||||
if (isCcCapable(key) || (connectsInfo[key]?.supports_client_credentials ?? false)) {
|
||||
ccBringYourOwn = true
|
||||
enableClientCredentials()
|
||||
} else {
|
||||
manual = true
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
let pathError = $state('')
|
||||
|
||||
export async function open(rt?: string) {
|
||||
@@ -168,22 +303,33 @@
|
||||
resourceType = stripSandboxSuffix(rawRt)
|
||||
valueToken = undefined
|
||||
|
||||
// Reset client credentials state
|
||||
supportsClientCredentials = false
|
||||
useClientCredentials = false
|
||||
clientId = ''
|
||||
clientSecret = ''
|
||||
tokenUrl = ''
|
||||
resetClientCredentialsState()
|
||||
|
||||
await loadConnects()
|
||||
manual = !connects?.includes(connectClient)
|
||||
const inConnects = connects?.includes(connectClient) ?? false
|
||||
// Registry-declared client-credentials providers are connectable even
|
||||
// without an instance OAuth client
|
||||
manual = !inConnects && !(rt && registryCcCapable())
|
||||
if (manual && express) {
|
||||
dispatch('error', 'Express OAuth setup is not available for non OAuth resource types')
|
||||
return
|
||||
}
|
||||
if (!inConnects && !manual && express) {
|
||||
// Client-credentials connections need interactive credential entry
|
||||
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
|
||||
return
|
||||
}
|
||||
if (!inConnects && !manual) {
|
||||
enableClientCredentials()
|
||||
}
|
||||
if (rt) {
|
||||
if (!manual && express) {
|
||||
await getScopesAndParams()
|
||||
if (authCodeUnavailable) {
|
||||
// No popup flow to drive express setup with
|
||||
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
|
||||
return
|
||||
}
|
||||
step = 2
|
||||
}
|
||||
next()
|
||||
@@ -193,18 +339,19 @@
|
||||
async function loadConnects() {
|
||||
if (!connects) {
|
||||
try {
|
||||
connects = (await OauthService.listOauthConnects())
|
||||
.filter((x) => x != 'supabase_wizard')
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
const list = (await OauthService.listOauthConnects())
|
||||
.filter((x) => x.name != 'supabase_wizard')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
connects = list.map((x) => x.name)
|
||||
connectsInfo = Object.fromEntries(list.map((x) => [x.name, x]))
|
||||
} catch (e) {
|
||||
connects = []
|
||||
connectsInfo = {}
|
||||
console.error('Error loading OAuth connects', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectAndManual = ['gitlab']
|
||||
|
||||
run(() => {
|
||||
isGoogleSignin =
|
||||
step == 1 &&
|
||||
@@ -227,7 +374,11 @@
|
||||
args['api_key'] == '' &&
|
||||
args['key'] == '' &&
|
||||
linkedSecrets.length > 0
|
||||
: false)) ||
|
||||
: useClientCredentials &&
|
||||
!useSharedInstanceCreds &&
|
||||
(clientId.trim() == '' ||
|
||||
clientSecret.trim() == '' ||
|
||||
(!!ccInstanceMeta && ccInstance.trim() == '')))) ||
|
||||
step == 3 ||
|
||||
(step == 4 && pathError != '') ||
|
||||
!isValid
|
||||
@@ -241,8 +392,11 @@
|
||||
workspace: effectiveWorkspace
|
||||
})
|
||||
|
||||
// "Others" lists every resource type — including instance-configured OAuth
|
||||
// providers — so any of them can also be connected with the user's own
|
||||
// credentials or manually, not only via the shared instance setup (same as
|
||||
// the authorization-code behavior).
|
||||
connectsManual = availableRts
|
||||
.filter((x) => connectAndManual.includes(x) || !Object.keys(connects ?? {}).includes(x))
|
||||
.map(
|
||||
(x) =>
|
||||
({
|
||||
@@ -339,15 +493,39 @@
|
||||
}
|
||||
|
||||
async function getScopesAndParams() {
|
||||
if (!connects?.includes(connectClient)) {
|
||||
// No instance OAuth client (registry-declared CC-only provider):
|
||||
// defaults come from the static registry instead.
|
||||
instanceScopes = registryEntry()?.scopes ?? []
|
||||
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
|
||||
extra_params = []
|
||||
supportsClientCredentials = registryCcCapable()
|
||||
return
|
||||
}
|
||||
const connect = await OauthService.getOauthConnect({ client: connectClient })
|
||||
scopes = connect.scopes ?? []
|
||||
instanceScopes = connect.scopes ?? []
|
||||
extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][]
|
||||
|
||||
/**
|
||||
* Check if the OAuth provider supports client_credentials grant type
|
||||
* This determines whether to show the OAuth flow selection UI
|
||||
* The CC flow is offered when the static registry declares it for the
|
||||
* provider, or the admin enabled it on the instance entry (custom
|
||||
* providers)
|
||||
*/
|
||||
supportsClientCredentials = connect.grant_types?.includes('client_credentials') ?? false
|
||||
supportsClientCredentials =
|
||||
registryCcCapable() || (connect.grant_types?.includes('client_credentials') ?? false)
|
||||
// Shared instance credentials: the user connects without entering any creds
|
||||
ccInstanceConfigured = connect.client_credentials_configured ?? false
|
||||
// Custom provider configured with only a token URL: no popup flow possible
|
||||
authCodeUnavailable =
|
||||
supportsClientCredentials && !(connect.grant_types?.includes('authorization_code') ?? true)
|
||||
if (authCodeUnavailable) {
|
||||
useClientCredentials = true
|
||||
}
|
||||
// Default scopes to the active grant: client-credentials uses the registry's
|
||||
// cc_scopes (auth-code scopes are invalid in a 2-legged request), every other
|
||||
// path keeps the instance entry's scopes. Applies to shared instance creds,
|
||||
// not just bring-your-own. Switching grants resets to these defaults.
|
||||
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
|
||||
}
|
||||
|
||||
async function getResourceTypeInfo() {
|
||||
@@ -386,37 +564,46 @@
|
||||
if (useClientCredentials) {
|
||||
/**
|
||||
* Client credentials flow: Direct API call to backend
|
||||
* No popup window or user interaction required
|
||||
* Uses instance-level OAuth credentials for server-to-server auth
|
||||
* No popup window or user interaction required — the resource-level
|
||||
* credentials are exchanged directly against the token URL
|
||||
*/
|
||||
try {
|
||||
// Trim whitespace from credentials to avoid false negatives
|
||||
const trimmedClientId = clientId.trim()
|
||||
const trimmedClientSecret = clientSecret.trim()
|
||||
const trimmedInstance = ccInstance.trim()
|
||||
// Instance-templated providers collect an instance name; the backend
|
||||
// builds the host-pinned token URL from it. Other registry providers
|
||||
// need no URL input (the token URL comes from the registry).
|
||||
const needsInstance = !!ccInstanceMeta
|
||||
|
||||
// Validate required fields
|
||||
if (!trimmedClientId || !trimmedClientSecret) {
|
||||
// Bring-your-own credentials are required unless the provider has
|
||||
// shared instance credentials, in which case the exchange runs
|
||||
// server-side with those and no input is collected here.
|
||||
if (
|
||||
!useSharedInstanceCreds &&
|
||||
(!trimmedClientId || !trimmedClientSecret || (needsInstance && !trimmedInstance))
|
||||
) {
|
||||
sendUserToast(
|
||||
'Client ID and Client Secret are required for client credentials flow',
|
||||
needsInstance
|
||||
? `Client ID, Client Secret and ${ccInstanceMeta?.label} are required for client credentials flow`
|
||||
: 'Client ID and Client Secret are required for client credentials flow',
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody: any = {
|
||||
scopes: scopes,
|
||||
cc_client_id: trimmedClientId,
|
||||
cc_client_secret: trimmedClientSecret
|
||||
}
|
||||
|
||||
// Add token URL override if provided
|
||||
if (tokenUrl.trim()) {
|
||||
requestBody.cc_token_url = tokenUrl.trim()
|
||||
}
|
||||
|
||||
const tokenResponse = await OauthService.connectClientCredentials({
|
||||
workspace: effectiveWorkspace,
|
||||
client: connectClient,
|
||||
requestBody
|
||||
requestBody: useSharedInstanceCreds
|
||||
? { scopes: scopes }
|
||||
: {
|
||||
scopes: scopes,
|
||||
cc_client_id: trimmedClientId,
|
||||
cc_client_secret: trimmedClientSecret,
|
||||
...(needsInstance ? { cc_instance: trimmedInstance } : {})
|
||||
}
|
||||
})
|
||||
|
||||
// Process the token response like in popup flow
|
||||
@@ -490,21 +677,34 @@
|
||||
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
|
||||
}
|
||||
|
||||
// Per-instance OAuth providers (Snowflake, ServiceNow, …): copy the
|
||||
// admin-configured instance from the OAuth client's extra_params into the
|
||||
// resource args, per the registry template's resource_mapping (e.g.
|
||||
// ServiceNow -> instance_url: https://{instance}.service-now.com). Generic
|
||||
// so a new per-instance provider needs only a registry entry.
|
||||
// Per-instance OAuth providers (Snowflake, ServiceNow, …): fill the
|
||||
// resource args from the connection's instance, per the registry
|
||||
// template's resource_mapping (e.g. ServiceNow -> instance_url:
|
||||
// https://{instance}.service-now.com). Bring-your-own carries the instance
|
||||
// the user entered in `ccInstance` (raw, possibly a full host); the shared
|
||||
// path carries it (already normalized) in the connect entry's extra_params.
|
||||
// Prefer the user-entered one so the saved resource matches the exchange.
|
||||
const connectTemplate = (oauthConnectRegistry as Record<string, any>)[resourceType]
|
||||
?.connect_config_template
|
||||
if (connectTemplate?.resource_mapping) {
|
||||
const instanceKey = connectTemplate.extra_params_key ?? 'instance'
|
||||
const found = extra_params.find(([key, _]) => key === instanceKey)
|
||||
if (found) {
|
||||
let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? ''
|
||||
if (ccInstance.trim()) {
|
||||
const stripSuffix = connectTemplate.strip_suffix as string | undefined
|
||||
let v = ccInstance
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '')
|
||||
if (stripSuffix && v.endsWith(stripSuffix)) {
|
||||
v = v.slice(0, -stripSuffix.length)
|
||||
}
|
||||
instanceValue = v.replace(/\.+$/, '')
|
||||
}
|
||||
if (instanceValue) {
|
||||
for (const [argField, valueTemplate] of Object.entries(
|
||||
connectTemplate.resource_mapping as Record<string, string>
|
||||
)) {
|
||||
args[argField] = valueTemplate.replaceAll('{instance}', found[1])
|
||||
args[argField] = valueTemplate.replaceAll('{instance}', instanceValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,13 +726,18 @@
|
||||
accountData.scopes = scopes
|
||||
}
|
||||
|
||||
// Add client credentials if using client_credentials flow
|
||||
if (useClientCredentials) {
|
||||
// Client-credentials accounts are self-contained: the refresh worker
|
||||
// re-exchanges using only what is stored on the account row. With
|
||||
// shared instance credentials the backend copies them onto the row,
|
||||
// so nothing is sent from here.
|
||||
if (useClientCredentials && !useSharedInstanceCreds) {
|
||||
accountData.cc_client_id = clientId.trim()
|
||||
accountData.cc_client_secret = clientSecret.trim()
|
||||
// Add token URL override if provided
|
||||
if (tokenUrl.trim()) {
|
||||
accountData.cc_token_url = tokenUrl.trim()
|
||||
// Instance-templated providers send an instance name; the backend
|
||||
// resolves and stores the host-pinned token URL. Other registry
|
||||
// providers need nothing more (token URL comes from the registry).
|
||||
if (ccInstanceMeta) {
|
||||
accountData.cc_instance = ccInstance.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,7 +862,7 @@
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={connects
|
||||
? connects.map((key) => ({
|
||||
? connects.filter(isSharedConnect).map((key) => ({
|
||||
key
|
||||
}))
|
||||
: undefined}
|
||||
@@ -671,17 +876,18 @@
|
||||
f={(x) => x.key}
|
||||
/>
|
||||
{#if step == 1}
|
||||
<div class="w-12/12 pb-2 flex flex-row my-1 gap-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search resource type"
|
||||
bind:value={filter}
|
||||
class="text-2xl grow"
|
||||
id="search-resource-type"
|
||||
/>
|
||||
<div class="pb-2 my-1">
|
||||
<div class="relative w-full">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
|
||||
<TextInput
|
||||
inputProps={{ placeholder: 'Search resource type', id: 'search-resource-type' }}
|
||||
bind:value={filter}
|
||||
class="pl-7 text-xs w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-4 text-sm font-semibold text-emphasis">OAuth APIs</h2>
|
||||
<h2 class="mb-4 text-sm font-semibold text-emphasis">Instance-configured OAuth APIs</h2>
|
||||
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center">
|
||||
{#if filteredConnects}
|
||||
{#each filteredConnects as { key }}
|
||||
@@ -693,6 +899,7 @@
|
||||
manual = false
|
||||
connectClient = key
|
||||
resourceType = stripSandboxSuffix(key)
|
||||
resetClientCredentialsState()
|
||||
next()
|
||||
}}
|
||||
>
|
||||
@@ -705,10 +912,10 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if connects && connects.length == 0}
|
||||
{#if connects && connects.filter(isSharedConnect).length == 0}
|
||||
<div class="text-secondary text-xs w-full"
|
||||
>No OAuth APIs has been setup on the instance. To add oauth APIs, first sync the resource
|
||||
types with the hub, then add oauth configuration. See <a
|
||||
>No OAuth APIs have been set up on this instance. To add OAuth APIs, first sync the resource
|
||||
types with the hub, then add OAuth configuration. See <a
|
||||
href="https://www.windmill.dev/docs/misc/setup_oauth">documentation</a
|
||||
>
|
||||
</div>
|
||||
@@ -718,7 +925,7 @@
|
||||
|
||||
{#if connectsManual && connectsManual?.length < 10}
|
||||
<div class="text-secondary text-xs p-2">
|
||||
Resource Types have not been synced with the hub
|
||||
Resource types have not been synced with the hub
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -730,12 +937,7 @@
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
selected={key === resourceType}
|
||||
on:click={() => {
|
||||
manual = true
|
||||
connectClient = key
|
||||
resourceType = key
|
||||
next()
|
||||
}}
|
||||
on:click={() => selectFromOthers(key)}
|
||||
>
|
||||
<IconedResourceType name={key} after={true} width="20px" height="20px" />
|
||||
</Button>
|
||||
@@ -749,16 +951,10 @@
|
||||
<Button
|
||||
aiId={`app-connect-inner-${key}`}
|
||||
aiDescription={`Connect to ${key}`}
|
||||
size="sm"
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
color={key === resourceType ? 'blue' : 'light'}
|
||||
btnClasses={key === resourceType ? '!border-2' : 'm-[1px]'}
|
||||
on:click={() => {
|
||||
manual = true
|
||||
connectClient = key
|
||||
resourceType = key
|
||||
next()
|
||||
}}
|
||||
selected={key === resourceType}
|
||||
on:click={() => selectFromOthers(key)}
|
||||
>
|
||||
<IconedResourceType name={key} after={true} width="20px" height="20px" />
|
||||
</Button>
|
||||
@@ -867,6 +1063,14 @@
|
||||
<SyncResourceTypes onSynced={getResourceTypeInfo} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if registryCcCapable()}
|
||||
<button
|
||||
onclick={() => enableClientCredentials()}
|
||||
class="text-xs font-normal text-accent w-fit -mt-4"
|
||||
>
|
||||
Acquire the token automatically via client credentials instead
|
||||
</button>
|
||||
{/if}
|
||||
{#key resourceTypeInfo}
|
||||
<ApiConnectForm
|
||||
bind:linkedSecrets
|
||||
@@ -889,12 +1093,17 @@
|
||||
>Create a resource backed by an OAuth connection, whose token is fetched from the
|
||||
external services and refreshed automatically if needed before expiration.</div
|
||||
>
|
||||
<button
|
||||
onclick={() => (manual = true)}
|
||||
class="text-xs font-normal text-accent w-fit mt-2"
|
||||
>
|
||||
Create resource manually instead
|
||||
</button>
|
||||
{#if ccBringYourOwn}
|
||||
<button
|
||||
onclick={() => {
|
||||
manual = true
|
||||
useClientCredentials = false
|
||||
}}
|
||||
class="text-xs font-normal text-accent w-fit mt-2"
|
||||
>
|
||||
Create resource manually instead
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if resourceTypeInfo?.description}
|
||||
@@ -909,26 +1118,40 @@
|
||||
<LabelsInput bind:labels class="-mt-5" />
|
||||
|
||||
{#if supportsClientCredentials}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication Method</h3>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
style="width: 16px; height: 16px; margin: 0;"
|
||||
bind:checked={useClientCredentials}
|
||||
id="useClienCrediential"
|
||||
/>
|
||||
<label for="useClienCrediential" class="text-xs font-semibold text-emphasis"
|
||||
>Use Client Credentials Flow</label
|
||||
>
|
||||
<Tooltip>
|
||||
Server-to-server authentication without user interaction.
|
||||
<br /><br />
|
||||
Provide your own OAuth client credentials for this resource.
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3>
|
||||
{#if ccOnly || ccBringYourOwn}
|
||||
<div class="text-xs text-secondary font-normal mb-2">
|
||||
{#if useSharedInstanceCreds}
|
||||
{resourceType} connects server-to-server using the credentials configured for this
|
||||
instance. The token is acquired and refreshed automatically.
|
||||
{:else}
|
||||
{resourceType} connects server-to-server. Enter a client ID and secret; the token is
|
||||
acquired and refreshed automatically.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 mb-2">
|
||||
<RadioCard
|
||||
label={`Sign in through ${resourceType}`}
|
||||
description="Opens a browser window to log in and authorize. Connects as you."
|
||||
selected={!useClientCredentials}
|
||||
onSelect={selectAuthCodeGrant}
|
||||
/>
|
||||
<RadioCard
|
||||
label={useSharedInstanceCreds
|
||||
? 'Use the configured instance credentials'
|
||||
: 'Use a client ID and secret'}
|
||||
description={useSharedInstanceCreds
|
||||
? "Runs server-to-server with this instance's credentials. No input needed."
|
||||
: 'Runs server-to-server. Best for automation or service accounts.'}
|
||||
selected={useClientCredentials}
|
||||
onSelect={() => enableClientCredentials()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if useClientCredentials}
|
||||
{#if useClientCredentials && !useSharedInstanceCreds}
|
||||
<form class="flex flex-col gap-6">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Client ID</span>
|
||||
@@ -938,7 +1161,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Client Secret</span>
|
||||
<span class="text-xs font-semibold text-emphasis">Client secret</span>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'password',
|
||||
@@ -948,22 +1171,19 @@
|
||||
bind:value={clientSecret}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis"
|
||||
>Token URL Override (Optional)</span
|
||||
>
|
||||
<div class="text-xs text-primary font-normal">
|
||||
Override the instance-level token URL for this resource
|
||||
</div>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
type: 'url',
|
||||
placeholder: 'Custom token endpoint URL',
|
||||
required: false
|
||||
}}
|
||||
bind:value={tokenUrl}
|
||||
/>
|
||||
</label>
|
||||
{#if ccInstanceMeta}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">{ccInstanceMeta.label}</span>
|
||||
<div class="text-xs text-secondary font-normal">
|
||||
Used to build this provider's token endpoint, stored with the connection for
|
||||
automatic token refresh
|
||||
</div>
|
||||
<TextInput
|
||||
inputProps={{ placeholder: ccInstanceMeta.placeholder, required: true }}
|
||||
bind:value={ccInstance}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
import { untrack } from 'svelte'
|
||||
import AppEditor from './apps/editor/AppEditor.svelte'
|
||||
import type { AppEditorProps } from './apps/types'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
let { app: oldApp, ...props }: AppEditorProps = $props()
|
||||
|
||||
let app = $state(untrack(() => oldApp))
|
||||
</script>
|
||||
|
||||
<AppEditor {app} {...props} />
|
||||
<!-- Gate on a resolved workspace: AppEditor acquires its UserDraft handle at init
|
||||
from the (non-reactive) workspace, so mounting it before one exists would
|
||||
leave autosave permanently detached. Embedders set the workspace before
|
||||
rendering; the test_dev header sets it on mount. -->
|
||||
{#if $workspaceStore}
|
||||
<AppEditor {app} {...props} />
|
||||
{/if}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import { capitalize, type Item } from '$lib/utils'
|
||||
import ClipboardPanel from './details/ClipboardPanel.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import { APP_TO_ICON_COMPONENT } from './icons'
|
||||
import { ExternalLink, Plus, Circle, X } from 'lucide-svelte'
|
||||
@@ -100,6 +102,10 @@
|
||||
// carry a `connect_config_template`. Derived from the registry so adding a
|
||||
// new one needs only a JSON entry — they get a builtin tile + the generic
|
||||
// instance-name input below, with no frontend change.
|
||||
// Every per-instance templated provider gets a settings tile + instance input:
|
||||
// authorization-code ones (ServiceNow) provide an `auth_url`, client-credentials-only
|
||||
// ones (Coupa) provide only a `token_url`. The admin enters their instance host so
|
||||
// the shared credentials point at the right endpoint.
|
||||
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
|
||||
Object.entries(oauthConnectRegistry)
|
||||
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
|
||||
@@ -112,6 +118,55 @@
|
||||
...windmillBuiltinsTemplated
|
||||
]
|
||||
|
||||
/** Resolve a `<name>_sandbox` key to its parent registry entry (sandbox
|
||||
* variants inherit the parent's grant_types), matching the connect dialog. */
|
||||
function canonicalRegistryKey(name: string): string {
|
||||
return name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
|
||||
}
|
||||
|
||||
/** The static registry declares client credentials for this provider */
|
||||
function registryCcCapable(name: string): boolean {
|
||||
return (
|
||||
(oauthConnectRegistry as Record<string, any>)[
|
||||
canonicalRegistryKey(name)
|
||||
]?.grant_types?.includes('client_credentials') ?? false
|
||||
)
|
||||
}
|
||||
|
||||
/** The static registry supports authorization code for this provider. A
|
||||
* provider with no explicit grant_types defaults to authorization code. */
|
||||
function registryAuthCodeCapable(name: string): boolean {
|
||||
const reg = (oauthConnectRegistry as Record<string, any>)[canonicalRegistryKey(name)]
|
||||
if (!reg) return false
|
||||
return reg.grant_types ? reg.grant_types.includes('authorization_code') : true
|
||||
}
|
||||
|
||||
/** Built-in provider that only supports client credentials (e.g. Coupa): no
|
||||
* authorization-code flow to choose, so the grant is fixed. */
|
||||
function registryCcOnly(name: string): boolean {
|
||||
return registryCcCapable(name) && !registryAuthCodeCapable(name)
|
||||
}
|
||||
|
||||
/** Map the entry's grant_types to the single-select choice (so the segmented
|
||||
* control always has exactly one selected and can never be empty) */
|
||||
function grantChoice(name: string): string {
|
||||
const gts = oauths?.[name]?.['grant_types'] ?? ['authorization_code']
|
||||
const cc = gts.includes('client_credentials')
|
||||
const ac = gts.includes('authorization_code')
|
||||
if (cc && ac) return 'both'
|
||||
if (cc) return 'client_credentials'
|
||||
return 'authorization_code'
|
||||
}
|
||||
|
||||
/** Set the grant types from the segmented choice. The instance credentials are
|
||||
* then used for every selected grant — authorization-code popup and/or
|
||||
* server-to-server. */
|
||||
function setGrantChoice(name: string, choice: string) {
|
||||
if (!oauths || !oauths[name]) return
|
||||
oauths[name]['grant_types'] =
|
||||
choice === 'both' ? ['authorization_code', 'client_credentials'] : [choice]
|
||||
}
|
||||
|
||||
let showCustomOAuthForm = $state(false)
|
||||
let customOAuthName = $state('')
|
||||
let customNameInput = $state<HTMLInputElement>()
|
||||
@@ -125,7 +180,11 @@
|
||||
if (oauths && name) {
|
||||
// Create a new object to ensure the new item is added at the end
|
||||
const newOauths = { ...oauths }
|
||||
newOauths[name] = { id: '', secret: '', grant_types: ['authorization_code'] }
|
||||
newOauths[name] = {
|
||||
id: '',
|
||||
secret: '',
|
||||
grant_types: registryCcOnly(name) ? ['client_credentials'] : ['authorization_code']
|
||||
}
|
||||
oauths = newOauths
|
||||
dropdownOpen = false
|
||||
}
|
||||
@@ -463,49 +522,51 @@
|
||||
bind:password={oauths[k]['secret']}
|
||||
/>
|
||||
</label>
|
||||
{#if k === 'visma' || !windmillBuiltins.includes(k)}
|
||||
<div class="mb-8">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input
|
||||
type="checkbox"
|
||||
style="width: 16px; height: 16px; margin: 0;"
|
||||
checked={oauths?.[k]?.['grant_types']?.includes('client_credentials') ??
|
||||
false}
|
||||
onchange={(e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (oauths && oauths[k]) {
|
||||
if (!oauths[k]['grant_types']) {
|
||||
oauths[k]['grant_types'] = ['authorization_code']
|
||||
}
|
||||
if (target.checked) {
|
||||
if (!oauths[k]['grant_types'].includes('client_credentials')) {
|
||||
oauths[k]['grant_types'] = [
|
||||
...oauths[k]['grant_types'],
|
||||
'client_credentials'
|
||||
]
|
||||
}
|
||||
} else {
|
||||
oauths[k]['grant_types'] = oauths[k]['grant_types'].filter(
|
||||
(gt: string) => gt !== 'client_credentials'
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="text-xs font-semibold text-emphasis"
|
||||
>Support Client Credentials Flow</span
|
||||
<div class="flex flex-col gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-emphasis">These credentials are for</span>
|
||||
{#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))}
|
||||
<ToggleButtonGroup
|
||||
selected={grantChoice(k)}
|
||||
onSelected={(v) => setGrantChoice(k, v)}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="authorization_code"
|
||||
label="Authorization code"
|
||||
showTooltipIcon
|
||||
tooltip="Users sign in through a browser popup using this app's Client ID and Secret."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="client_credentials"
|
||||
label="Client credentials"
|
||||
showTooltipIcon
|
||||
tooltip={`Server-to-server. Fill Client ID and Secret to share one service account for every connection, or leave them empty so each user brings their own.${!windmillBuiltins.includes(k) ? ' A Token URL is required below.' : ''}`}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="both"
|
||||
label="Both"
|
||||
showTooltipIcon
|
||||
tooltip="Offer both flows; the same Client ID and Secret are used for each selected grant."
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{:else if registryCcCapable(k)}
|
||||
<span class="text-xs text-secondary font-normal flex items-center gap-1">
|
||||
Client credentials (server-to-server)
|
||||
<Tooltip
|
||||
>Fill Client ID and Secret to share one service account, or leave them empty
|
||||
so each user brings their own.</Tooltip
|
||||
>
|
||||
<Tooltip>
|
||||
Enables server-to-server authentication without user interaction. Use for
|
||||
automated scripts and background jobs.
|
||||
<br /><br />
|
||||
When enabled, users can provide their own client credentials at the resource
|
||||
level. The Client ID and Secret configured above are only used for the traditional
|
||||
OAuth flow (popup window).
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Authorization code (browser sign-in)</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if k === 'azure_oauth'}
|
||||
<AzureOauthSettings bind:connect_config={oauths[k]['connect_config']} />
|
||||
{:else if !windmillBuiltins.includes(k) && k != 'slack'}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { CloudCheck, CloudOff, RefreshCcw, RotateCcw, Users } from 'lucide-svelte'
|
||||
import { CloudCheck, CloudOff, RefreshCcw, RotateCcw, Users, Eye } from 'lucide-svelte'
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { runResetToDeployed } from '$lib/userDraftToast'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
@@ -188,6 +189,13 @@
|
||||
)
|
||||
const labelIsError = $derived(syncState === 'failed')
|
||||
|
||||
// Overlay mode: editing another user's loaded draft. Autosave is hard-locked,
|
||||
// so the toggle is meaningless; offer "Reset to draft" (restore our own) instead.
|
||||
const editingOtherUserDraft = $derived(OtherUserDraftLoad.isActive(workspace, itemKind, path))
|
||||
const otherDraftOwnerLabel = $derived(
|
||||
OtherUserDraftLoad.getSession(workspace, itemKind, path)?.ownerLabel
|
||||
)
|
||||
|
||||
const showResetAction = $derived(!draftOnly && hasDraft && !!onResetToDeployed)
|
||||
|
||||
// "Enable auto-save" preference — browser-wide, persisted by the syncer.
|
||||
@@ -213,6 +221,18 @@
|
||||
popoverOpen = false
|
||||
onOpenOthersDrafts?.()
|
||||
}
|
||||
|
||||
let resettingToOwnDraft = $state(false)
|
||||
async function resetToOwnDraft() {
|
||||
if (resettingToOwnDraft) return
|
||||
resettingToOwnDraft = true
|
||||
try {
|
||||
await OtherUserDraftLoad.resetToOwnDraft(workspace, itemKind, path)
|
||||
} finally {
|
||||
resettingToOwnDraft = false
|
||||
popoverOpen = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -239,7 +259,10 @@
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div class="relative rounded-md p-1.5 hover:bg-surface-hover cursor-pointer">
|
||||
{#if syncState === 'saving' || syncState === 'pending'}
|
||||
{#if editingOtherUserDraft}
|
||||
<!-- Viewing another user's draft: not saved, distinct from the saved check-mark. -->
|
||||
<Eye size={16} class="text-blue-500" />
|
||||
{:else if syncState === 'saving' || syncState === 'pending'}
|
||||
<RefreshCcw size={14} class="animate-spin" />
|
||||
{:else if syncState === 'failed'}
|
||||
<CloudOff size={16} class="text-red-500" />
|
||||
@@ -254,61 +277,77 @@
|
||||
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-3 text-sm w-72 p-3">
|
||||
{#if syncState === 'failed'}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-red-500 font-semibold text-xs">Save failed</p>
|
||||
{#if failureMessage}
|
||||
<pre
|
||||
class="text-red-500 text-xs whitespace-pre-wrap break-words font-mono max-h-40 overflow-y-auto"
|
||||
>{failureMessage}</pre
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if autosaveEnabled}
|
||||
{#if editingOtherUserDraft}
|
||||
<p class="text-primary text-xs">
|
||||
All changes are saved as a draft on the server. The draft is per-user — your teammates'
|
||||
editors keep their own.
|
||||
You're editing {otherDraftOwnerLabel ?? 'another user'}'s draft. Auto-save is paused —
|
||||
your own draft is untouched. Editing prompts before overwriting it.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-primary text-xs">
|
||||
Auto-save is off — changes only persist when you press Ctrl/Cmd+S. The draft is per-user
|
||||
— your teammates' editors keep their own.
|
||||
</p>
|
||||
{/if}
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={autosaveEnabled}
|
||||
options={{ right: 'Enable auto-save' }}
|
||||
on:change={(e) => {
|
||||
UserDraftDbSyncer.autosaveEnabled = e.detail
|
||||
}}
|
||||
/>
|
||||
{#if othersDraftsCount > 0}
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<p class="text-primary text-xs">
|
||||
Other users are working on this {kindLabel}.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Users }}
|
||||
on:click={openOthersDrafts}
|
||||
>
|
||||
See others' drafts
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showResetAction}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
loading={resetting}
|
||||
loading={resettingToOwnDraft}
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
on:click={() => void resetToDeployed()}
|
||||
on:click={() => void resetToOwnDraft()}
|
||||
>
|
||||
Reset to deployed
|
||||
Reset to draft
|
||||
</Button>
|
||||
{:else}
|
||||
{#if syncState === 'failed'}
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-red-500 font-semibold text-xs">Save failed</p>
|
||||
{#if failureMessage}
|
||||
<pre
|
||||
class="text-red-500 text-xs whitespace-pre-wrap break-words font-mono max-h-40 overflow-y-auto"
|
||||
>{failureMessage}</pre
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if autosaveEnabled}
|
||||
<p class="text-primary text-xs">
|
||||
All changes are saved as a draft on the server. The draft is per-user — your
|
||||
teammates' editors keep their own.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-primary text-xs">
|
||||
Auto-save is off — changes only persist when you press Ctrl/Cmd+S. The draft is
|
||||
per-user — your teammates' editors keep their own.
|
||||
</p>
|
||||
{/if}
|
||||
<Toggle
|
||||
size="xs"
|
||||
checked={autosaveEnabled}
|
||||
options={{ right: 'Enable auto-save' }}
|
||||
on:change={(e) => {
|
||||
UserDraftDbSyncer.autosaveEnabled = e.detail
|
||||
}}
|
||||
/>
|
||||
{#if othersDraftsCount > 0}
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<p class="text-primary text-xs">
|
||||
Other users are working on this {kindLabel}.
|
||||
</p>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Users }}
|
||||
on:click={openOthersDrafts}
|
||||
>
|
||||
See others' drafts
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showResetAction}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
loading={resetting}
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
on:click={() => void resetToDeployed()}
|
||||
>
|
||||
Reset to deployed
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
|
||||
import DiffDrawer from './DiffDrawer.svelte'
|
||||
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
|
||||
import DraftBadge from './DraftBadge.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import { Badge } from './common'
|
||||
import Tooltip from './meltComponents/Tooltip.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte'
|
||||
import { AlertTriangle, ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
|
||||
import { editUrlFor } from './sessions/forkEditUrl'
|
||||
import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy'
|
||||
import { type DraftItem } from '$lib/workspaceDrafts.svelte'
|
||||
import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
import type { Kind as LayoutKind } from '$lib/utils_deployable'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
currentWorkspaceId: string
|
||||
@@ -68,6 +71,12 @@
|
||||
legacy_draft: boolean
|
||||
raw_app: boolean
|
||||
key: string
|
||||
can_write: boolean
|
||||
draft_users?: { username?: string | null }[]
|
||||
/** The row is my own draft (or the legacy no-owner row) — only then is it
|
||||
* actionable. Other users' rows (shown when "Show all drafts" is on) are
|
||||
* view-only: you can't deploy/discard someone else's draft. */
|
||||
mine: boolean
|
||||
}
|
||||
function getItemKey(kind: string, path: string): string {
|
||||
return `${kind}:${path}`
|
||||
@@ -98,12 +107,25 @@
|
||||
return kind as LayoutKind
|
||||
}
|
||||
|
||||
// The list (and the Draft Count) come from the shared Workspace Drafts module,
|
||||
// owned by the page and passed in via `draftItems`; deploy/discard invalidate
|
||||
// that resource, so the list refetches and deployed items drop off without a
|
||||
// manual reload here.
|
||||
// "Show all drafts" widens the list from my own (+ legacy) to every user's
|
||||
// drafts in the workspace. Off by default. The default view reuses the page's
|
||||
// shared Workspace Drafts resource (passed in via `draftItems`); the "all
|
||||
// users" superset is fetched lazily here via its own resource — only while the
|
||||
// toggle is on (workspace() is undefined otherwise, so no fetch) — and shares
|
||||
// the same invalidation, so a deploy/discard refetches both.
|
||||
let showAll = $state(false)
|
||||
const allDrafts = useWorkspaceDrafts(
|
||||
() => (showAll ? currentWorkspaceId : undefined),
|
||||
() => true
|
||||
)
|
||||
const sourceItems = $derived(showAll ? allDrafts.items : draftItems)
|
||||
const loading = $derived(showAll ? allDrafts.loading : draftsLoading)
|
||||
|
||||
// The list (and, in the default view, the Draft Count) come from the Workspace
|
||||
// Drafts module; deploy/discard invalidate the resource, so the list refetches
|
||||
// and deployed items drop off without a manual reload here.
|
||||
const items: Row[] = $derived(
|
||||
draftItems.map((d) => ({
|
||||
sourceItems.map((d) => ({
|
||||
...d,
|
||||
key: getItemKey(d.kind, d.path),
|
||||
kind: toLayoutKind(d.kind),
|
||||
@@ -113,13 +135,53 @@
|
||||
}))
|
||||
)
|
||||
|
||||
const currentUsername = $derived($userStore?.username)
|
||||
|
||||
// Other real users (not me, not the legacy NULL-email row) who also drafted
|
||||
// this path. Only the shared full-page-editor kinds carry draft_users, so this
|
||||
// is naturally empty for drawer kinds. Deploying only deploys my own draft, so
|
||||
// a non-empty list warrants the triangle warning.
|
||||
function otherDraftUsers(row: Row): string[] {
|
||||
return (row.draft_users ?? [])
|
||||
.map((u) => u.username)
|
||||
.filter((u): u is string => !!u && u !== currentUsername)
|
||||
}
|
||||
|
||||
// The backend already returns exactly the rows for the current view (own +
|
||||
// legacy, or every user's with "Show all drafts"), so there's no client-side
|
||||
// filtering — `visibleItems` is just the mapped list.
|
||||
const visibleItems = $derived(items)
|
||||
|
||||
// A row is actionable when it isn't already deployed this session, the user has
|
||||
// write permission, AND it's their own draft (you can't deploy someone else's
|
||||
// draft — those show view-only in the "all drafts" view). The server enforces
|
||||
// the same; this keeps the UI honest. A data-pipeline bundle is never deployable
|
||||
// from this page — its scripts deploy individually inside the pipeline view — so
|
||||
// it's excluded from every selection path.
|
||||
function isSelectable(item: Row): boolean {
|
||||
return (
|
||||
deploymentStatus[item.key]?.status !== 'deployed' &&
|
||||
item.can_write &&
|
||||
item.mine &&
|
||||
item.draftKind !== 'data_pipeline'
|
||||
)
|
||||
}
|
||||
|
||||
// Why a row can't be deployed/discarded (drives the disabled-checkbox tooltip
|
||||
// and the Discard button's title). `undefined` ⇒ actionable.
|
||||
function blockedReason(item: Row): string | undefined {
|
||||
if (!item.mine) return 'This draft belongs to another user'
|
||||
if (!item.can_write) return "You don't have write permission on this path"
|
||||
return undefined
|
||||
}
|
||||
|
||||
// The Draft Items list only carries the *deployed* summary, so the draft's
|
||||
// (new) display name isn't known yet. Fetch each item's draft blob once and
|
||||
// cache both names — mirrors CompareWorkspaces' fetchSummaries (eager on load,
|
||||
// keyed by row key) so the rename rendering is shared and consistent. Only
|
||||
// non-`draft_only` items can show a rename: a `draft_only` item has no deployed
|
||||
// side to diff the name against. Raw apps live on a separate route and aren't
|
||||
// fetchable here, so they're skipped (no rename shown, same as before).
|
||||
// side to diff the name against. Raw apps are fetched via the apps endpoint too
|
||||
// (it auto-detects raw from the deployed row and overlays the raw_app draft).
|
||||
const summaryCache = $state<
|
||||
Record<string, { deployed?: string; draft?: string; loading?: boolean }>
|
||||
>({})
|
||||
@@ -161,9 +223,9 @@
|
||||
untrack(() => {
|
||||
for (const item of current) {
|
||||
if (
|
||||
item.mine &&
|
||||
!item.draft_only &&
|
||||
!item.raw_app &&
|
||||
['script', 'flow', 'app'].includes(item.draftKind) &&
|
||||
(['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app) &&
|
||||
!summaryCache[item.key]
|
||||
) {
|
||||
void fetchDraftSummary(item)
|
||||
@@ -172,13 +234,6 @@
|
||||
})
|
||||
})
|
||||
|
||||
// A data-pipeline bundle isn't deployable from this page — deploy happens
|
||||
// per-script inside the pipeline view. Exclude it from every selection path
|
||||
// so the bulk "Deploy N drafts" never tries to deploy a bundle.
|
||||
function isRowDeployable(i: { key: string; draftKind: Row['draftKind'] }): boolean {
|
||||
return deploymentStatus[i.key]?.status !== 'deployed' && i.draftKind !== 'data_pipeline'
|
||||
}
|
||||
|
||||
let selectedItems = $state<string[]>([])
|
||||
let deploying = $state(false)
|
||||
// Select all on the first non-empty load (deploy-all is the common intent);
|
||||
@@ -204,23 +259,23 @@
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!hasAutoSelected && items.length > 0) {
|
||||
selectedItems = items.filter(isRowDeployable).map((i) => i.key)
|
||||
if (!hasAutoSelected && visibleItems.length > 0) {
|
||||
selectedItems = visibleItems.filter(isSelectable).map((i) => i.key)
|
||||
hasAutoSelected = true
|
||||
}
|
||||
})
|
||||
|
||||
// Selected items still in the live list and deployable. Derived (not a pruning
|
||||
// effect) so the "Deploy N drafts" button stays reactive to the Workspace
|
||||
// Drafts resource: deploy/discard drop items, and stale keys left in
|
||||
// Selected items still in the visible list and deployable. Derived (not a
|
||||
// pruning effect) so the "Deploy N drafts" button stays reactive to the
|
||||
// Workspace Drafts resource: deploy/discard drop items, and stale keys left in
|
||||
// selectedItems are simply ignored here (and by deploySelected).
|
||||
let selectedCount = $derived(
|
||||
items.filter((i) => selectedItems.includes(i.key) && isRowDeployable(i)).length
|
||||
visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length
|
||||
)
|
||||
|
||||
let allSelected = $derived(
|
||||
items.length > 0 &&
|
||||
items.filter(isRowDeployable).every((i) => selectedItems.includes(i.key))
|
||||
visibleItems.filter(isSelectable).length > 0 &&
|
||||
visibleItems.filter(isSelectable).every((i) => selectedItems.includes(i.key))
|
||||
)
|
||||
|
||||
function toggleItem(item: { key: string }) {
|
||||
@@ -232,7 +287,7 @@
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedItems = items.filter(isRowDeployable).map((i) => i.key)
|
||||
selectedItems = visibleItems.filter(isSelectable).map((i) => i.key)
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
@@ -271,8 +326,9 @@
|
||||
async function deploySelected() {
|
||||
deploying = true
|
||||
// Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts
|
||||
// resource, so `items` can change mid-loop — iterate a stable copy.
|
||||
const toDeploy = items.filter((i) => selectedItems.includes(i.key))
|
||||
// resource, so `items` can change mid-loop — iterate a stable copy. Guard on
|
||||
// isSelectable so a non-writable row can never be deployed via a stale key.
|
||||
const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i))
|
||||
let deployedAny = false
|
||||
for (const item of toDeploy) {
|
||||
deploymentStatus[item.key] = { status: 'loading' }
|
||||
@@ -300,12 +356,34 @@
|
||||
}
|
||||
|
||||
// --- Discard ---
|
||||
// Only one discard is destructive: removing the last draft of a never-deployed
|
||||
// item (draft_only, and no other user still holds a draft) permanently deletes
|
||||
// the item, so it gets a confirmation. Every other discard just reverts to the
|
||||
// deployed version or removes your own copy while another draft remains — those
|
||||
// run immediately (the row already carries the ⚠️ for the multi-user case).
|
||||
let discardTarget = $state<Row | undefined>(undefined)
|
||||
|
||||
async function confirmDiscard() {
|
||||
const item = discardTarget
|
||||
discardTarget = undefined
|
||||
if (!item) return
|
||||
function isDestructiveDiscard(item: Row): boolean {
|
||||
// A deployed counterpart exists → discard just reverts, never deletes.
|
||||
if (!item.draft_only) return false
|
||||
// draft_only → discarding deletes the item, UNLESS another real user still
|
||||
// holds a draft of it. Guard on `currentUsername`: if we don't yet know who
|
||||
// "me" is, `otherDraftUsers` would count my own row as someone else's, so
|
||||
// fall back to treating it as a delete (confirm) rather than risk a silent
|
||||
// deletion.
|
||||
if (!currentUsername) return true
|
||||
return otherDraftUsers(item).length === 0
|
||||
}
|
||||
|
||||
function onDiscardClick(item: Row) {
|
||||
if (isDestructiveDiscard(item)) {
|
||||
discardTarget = item
|
||||
} else {
|
||||
void doDiscard(item)
|
||||
}
|
||||
}
|
||||
|
||||
async function doDiscard(item: Row) {
|
||||
const res = await discardDraft(
|
||||
item.draftKind,
|
||||
item.path,
|
||||
@@ -322,6 +400,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDiscard() {
|
||||
const item = discardTarget
|
||||
discardTarget = undefined
|
||||
if (item) void doDiscard(item)
|
||||
}
|
||||
|
||||
// Editor URL for a draft item, scoped to the current workspace. Raw apps live
|
||||
// under a different editor route, so map their kind accordingly. Kinds whose
|
||||
// editor is a drawer on a list page (variables, resources, schedules,
|
||||
@@ -406,16 +490,33 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="bg-surface-tertiary p-4 rounded-md border">
|
||||
<WorkspaceDeployLayout
|
||||
{items}
|
||||
items={visibleItems}
|
||||
{selectedItems}
|
||||
{deploymentStatus}
|
||||
{allSelected}
|
||||
selectablePredicate={(item) => isRowDeployable(item as unknown as Row)}
|
||||
selectablePredicate={(item) => isSelectable(item as unknown as Row)}
|
||||
selectBlockedReason={(item) => blockedReason(item as unknown as Row)}
|
||||
onToggleItem={toggleItem}
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'}
|
||||
emptyMessage={loading
|
||||
? 'Loading drafts…'
|
||||
: showAll
|
||||
? 'No drafts in this workspace'
|
||||
: 'No drafts you authored in this workspace'}
|
||||
>
|
||||
{#snippet selectAllActions()}
|
||||
<Toggle
|
||||
bind:checked={showAll}
|
||||
size="xs"
|
||||
options={{
|
||||
right: 'Show all drafts',
|
||||
rightTooltip:
|
||||
"Show every user's drafts in this workspace, not just yours. Others' drafts are view-only — you can only deploy or discard your own."
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet header()}
|
||||
{#if isFork}
|
||||
<div class="flex flex-wrap gap-1 items-center bg-surface-tertiary pb-4">
|
||||
@@ -461,26 +562,55 @@
|
||||
{oldSummary}
|
||||
{newSummary}
|
||||
renamed={!draftItem.draft_only &&
|
||||
oldSummary != null &&
|
||||
newSummary != null &&
|
||||
!!oldSummary &&
|
||||
!!newSummary &&
|
||||
oldSummary !== newSummary}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemPath(item)}
|
||||
{@const draftItem = item as unknown as Row}
|
||||
{#if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'}
|
||||
<!-- drawer-only items show their path as the title; keep this line empty -->
|
||||
{:else if !draftItem.draft_only && draftItem.draft_path && draftItem.draft_path !== draftItem.path}
|
||||
<!-- Path rename: a *deployed* item's draft moves it to a new path. Strike
|
||||
the deployed path and show the draft's target path. Draft-only items are
|
||||
excluded — their storage path is an auto-generated `draft_{uuid}` and
|
||||
`draft_path` is just the pretty name, not a rename. -->
|
||||
<span class="line-through">{draftItem.path}</span>
|
||||
{draftItem.draft_path}
|
||||
{:else}
|
||||
{draftItem.draft_path ?? draftItem.path}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet itemActions(item)}
|
||||
{@const draftItem = item as unknown as Row}
|
||||
{@const others = otherDraftUsers(draftItem)}
|
||||
<Badge color="gray" size="xs">{kindLabel(draftItem.draftKind)}</Badge>
|
||||
{#if draftItem.draft_only}
|
||||
<Badge color="indigo" size="xs">New</Badge>
|
||||
{/if}
|
||||
{#if draftItem.legacy_draft}
|
||||
<Tooltip>
|
||||
<Badge color="yellow" size="xs">Legacy draft</Badge>
|
||||
{#snippet text()}
|
||||
A legacy draft predates the per-user drafts migration: it isn't tied to any user
|
||||
(workspace-level, email NULL), so everyone with access to this path sees it.
|
||||
<DraftBadge
|
||||
is_draft={true}
|
||||
draft_only={draftItem.draft_only}
|
||||
draft_users={draftItem.draft_users ?? []}
|
||||
{currentUsername}
|
||||
workspace={currentWorkspaceId}
|
||||
itemKind={draftItem.draftKind}
|
||||
path={draftItem.path}
|
||||
allowFork={false}
|
||||
/>
|
||||
{#if draftItem.mine && others.length > 0}
|
||||
<Popover openOnHover debounceDelay={50}>
|
||||
{#snippet trigger()}
|
||||
<AlertTriangle size={16} class="text-yellow-500" />
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{#snippet content()}
|
||||
<div class="text-xs p-3 max-w-xs text-primary">
|
||||
{others.length} other {others.length === 1 ? 'user' : 'users'} ({others.join(', ')})
|
||||
{others.length === 1 ? 'has' : 'have'} a draft of this item. Deploying only deploys your
|
||||
draft; theirs are left untouched.
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if deploymentStatus[draftItem.key]?.status !== 'deployed'}
|
||||
{#if draftItem.draftKind === 'data_pipeline'}
|
||||
@@ -488,29 +618,42 @@
|
||||
individually inside the pipeline view. -->
|
||||
{@const openUrl = draftEditUrl(draftItem)}
|
||||
{#if openUrl}
|
||||
<Button unifiedSize="xs" variant="subtle" startIcon={{ icon: ArrowRight }} href={openUrl}>
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: ArrowRight }}
|
||||
href={openUrl}
|
||||
>
|
||||
Open pipeline
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const discardBlock = blockedReason(draftItem)}
|
||||
<!-- Show diff fetches the *current user's* draft overlay, so it's only
|
||||
meaningful for your own/legacy rows. Another user's draft (view-only,
|
||||
`mine=false`) would diff against the wrong draft or 404 — hide it. -->
|
||||
{#if draftItem.mine}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
onClick={() => showDiff(draftItem)}
|
||||
>
|
||||
Show diff
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: DiffIcon }}
|
||||
onClick={() => showDiff(draftItem)}
|
||||
destructive
|
||||
disabled={!!discardBlock}
|
||||
title={discardBlock}
|
||||
startIcon={{ icon: Undo2 }}
|
||||
onClick={() => onDiscardClick(draftItem)}
|
||||
>
|
||||
Show diff
|
||||
Discard draft
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
destructive
|
||||
startIcon={{ icon: Undo2 }}
|
||||
onClick={() => (discardTarget = draftItem)}
|
||||
>
|
||||
Discard draft
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -532,23 +675,18 @@
|
||||
<DiffDrawer bind:this={diffDrawer} {isFlow} />
|
||||
</div>
|
||||
|
||||
<!-- Only the destructive discard (deleting the last draft of a never-deployed
|
||||
item) opens this modal; non-destructive discards run without confirmation. -->
|
||||
<ConfirmationModal
|
||||
open={discardTarget !== undefined}
|
||||
title={discardTarget?.draft_only ? 'Delete item' : 'Discard draft'}
|
||||
confirmationText={discardTarget?.draft_only ? 'Delete' : 'Discard'}
|
||||
title="Delete item"
|
||||
confirmationText="Delete"
|
||||
onConfirmed={confirmDiscard}
|
||||
onCanceled={() => (discardTarget = undefined)}
|
||||
>
|
||||
{#if discardTarget?.draft_only}
|
||||
<p>
|
||||
<span class="font-mono font-medium text-primary">{discardTarget?.path}</span> exists only as a
|
||||
draft. Discarding it will permanently delete the item. This cannot be undone.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
Discard the draft of
|
||||
<span class="font-mono font-medium text-primary">{discardTarget?.path}</span>? The deployed
|
||||
version is unaffected.
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<span class="font-mono font-medium text-primary"
|
||||
>{discardTarget?.draft_path ?? discardTarget?.path}</span
|
||||
> exists only as a draft. Discarding it will permanently delete the item. This cannot be undone.
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import OauthExtraParams from './OauthExtraParams.svelte'
|
||||
import OauthScopes from './OauthScopes.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
let { connect_config = $bindable() } = $props();
|
||||
let { connect_config = $bindable() } = $props()
|
||||
|
||||
run(() => {
|
||||
if (!connect_config) {
|
||||
@@ -19,12 +19,17 @@
|
||||
extra_params_callback: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-emphasis font-semibold text-xs">Auth URL</span>
|
||||
<span class="text-emphasis font-semibold text-xs"
|
||||
>Auth URL <Tooltip
|
||||
>Leave empty for providers that only support the client credentials flow: the provider is
|
||||
then offered exclusively with server-to-server authentication.</Tooltip
|
||||
></span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://github.com/login/oauth/authorize"
|
||||
|
||||
@@ -162,7 +162,13 @@
|
||||
if (!selected.schemaKey && schemaKeys.length) {
|
||||
let schemaKey =
|
||||
initialSchemaKey ??
|
||||
('public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0])
|
||||
('public' in dbSchema.schema
|
||||
? 'public'
|
||||
: 'dbo' in dbSchema.schema
|
||||
? 'dbo'
|
||||
: 'main' in dbSchema.schema
|
||||
? 'main'
|
||||
: schemaKeys[0])
|
||||
let tableKey =
|
||||
initialTableKey && dbSchema.schema?.[schemaKey]?.[initialTableKey]
|
||||
? initialTableKey
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<DbManager
|
||||
dbSupportsSchemas={input?.type == 'database' && dbSupportsSchemas(input.resourceType)}
|
||||
dbSupportsSchemas={dbSupportsSchemas(dbType)}
|
||||
databaseIsEmpty={!Object.values(dbSchema.schema).flatMap((s) => Object.values(s)).length}
|
||||
{dbSchema}
|
||||
colDefs={colDefs.current}
|
||||
@@ -166,7 +166,7 @@
|
||||
workspace: $workspaceStore
|
||||
})}
|
||||
initialTableKey={input.specificTable}
|
||||
initialSchemaKey={input.type == 'database' ? input.specificSchema : undefined}
|
||||
initialSchemaKey={input.specificSchema}
|
||||
asset={_input.type == 'ducklake'
|
||||
? { kind: 'ducklake', path: _input.ducklake }
|
||||
: _input.resourcePath.startsWith('datatable://')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Home-page draft badge with per-user initial circles. Hover popover lists
|
||||
* each draft owner; when full context (workspace + itemKind + path) is
|
||||
* passed, OTHER users' rows get inline "View JSON" / "Fork" (forking
|
||||
* passed, OTHER users' rows get inline "View Diff" / "Load" (loading
|
||||
* yourself is meaningless, so own rows don't). draft_only → "Draft only"
|
||||
* (no deployed row), else "Draft". Renders nothing when there's no draft.
|
||||
*/
|
||||
@@ -10,11 +10,14 @@
|
||||
import Tooltip from './meltComponents/Tooltip.svelte'
|
||||
import { Badge } from './common'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Modal2 from './common/modal/Modal2.svelte'
|
||||
import { Braces, GitFork } from 'lucide-svelte'
|
||||
import DiffDrawer from './DiffDrawer.svelte'
|
||||
import MigrateLegacyDraftModal from './common/confirmationModal/MigrateLegacyDraftModal.svelte'
|
||||
import { GitCompareArrows, Pencil, Wrench } from 'lucide-svelte'
|
||||
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { forkDraftToImport } from '$lib/components/forkDraftToImport'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { OtherUserDraftLoad, editRouteFor } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
type DraftUser = { username?: string | null }
|
||||
@@ -25,10 +28,16 @@
|
||||
draft_users?: DraftUser[]
|
||||
/** Authed user's username — pins their circle first and annotates `(you)`. */
|
||||
currentUsername?: string | null
|
||||
/** Context for the View JSON / Fork actions. Missing → text-only popover. */
|
||||
/** Context for the View Diff / Load actions. Missing → text-only popover. */
|
||||
workspace?: string
|
||||
itemKind?: UserDraftItemKind
|
||||
path?: string
|
||||
/** Called after an admin migrates (deletes / assigns) the legacy draft, so
|
||||
* the parent row can refetch and drop the now-resolved legacy entry. */
|
||||
onMigrated?: () => void
|
||||
/** Offer "Load" alongside "View Diff" on other users' rows. The deploy /
|
||||
* review page sets this false: loading into a fresh editor is moot there. */
|
||||
allowFork?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -38,7 +47,9 @@
|
||||
currentUsername = undefined,
|
||||
workspace = undefined,
|
||||
itemKind = undefined,
|
||||
path = undefined
|
||||
path = undefined,
|
||||
onMigrated = undefined,
|
||||
allowFork = true
|
||||
}: Props = $props()
|
||||
|
||||
// Authed user lands first; everyone else keeps the backend's ordering.
|
||||
@@ -112,9 +123,14 @@
|
||||
)
|
||||
|
||||
let busyFor = $state<string | null>(null)
|
||||
let jsonOpen = $state(false)
|
||||
let jsonOwnerLabel = $state('')
|
||||
let jsonValue = $state<unknown>(undefined)
|
||||
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
||||
let migrateOpen = $state(false)
|
||||
// The hover popover sits above the diff drawer / migrate modal (z-index), so
|
||||
// close it before opening either or it would cover them.
|
||||
let popoverOpen = $state(false)
|
||||
|
||||
// Legacy (no-owner) drafts can only be resolved by workspace admins / superadmins.
|
||||
const canMigrateLegacy = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin)
|
||||
|
||||
function ownerKey(owner: DraftUser): string {
|
||||
return owner.username ?? '__legacy__'
|
||||
@@ -134,12 +150,23 @@
|
||||
).value
|
||||
}
|
||||
|
||||
async function viewJson(owner: DraftUser) {
|
||||
async function viewDiff(owner: DraftUser) {
|
||||
if (!workspace || !itemKind || !path) return
|
||||
busyFor = ownerKey(owner)
|
||||
try {
|
||||
jsonValue = await fetchDraft(owner)
|
||||
jsonOwnerLabel = fullLabel(owner)
|
||||
jsonOpen = true
|
||||
const [draftValue, deployed] = await Promise.all([
|
||||
fetchDraft(owner),
|
||||
fetchDeployedValueForDiff(workspace, itemKind, path)
|
||||
])
|
||||
// Close the popover so it doesn't render over the drawer.
|
||||
popoverOpen = false
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
title: `${fullLabel(owner)}'s draft vs deployed`,
|
||||
original: deployed,
|
||||
current: draftValue as any
|
||||
})
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
@@ -147,15 +174,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fork(owner: DraftUser) {
|
||||
async function load(owner: DraftUser) {
|
||||
if (!workspace || !itemKind || !path) return
|
||||
busyFor = ownerKey(owner)
|
||||
try {
|
||||
const value = await fetchDraft(owner)
|
||||
// Seed a brand-new own item from the fetched value (no server save).
|
||||
forkDraftToImport(itemKind, value, path)
|
||||
// Stage their value and open this item's editor. If we already have a
|
||||
// draft here, the editor enters overlay mode (no save until the user
|
||||
// confirms overwriting it).
|
||||
OtherUserDraftLoad.stage(workspace, itemKind, value, path, fullLabel(owner), {
|
||||
navigate: true
|
||||
})
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not fork draft: ${e.body ?? e.message}`, true)
|
||||
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busyFor = null
|
||||
}
|
||||
@@ -163,7 +194,15 @@
|
||||
</script>
|
||||
|
||||
{#if showBadge}
|
||||
<Popover openOnHover={true} debounceDelay={50} enableFlyTransition>
|
||||
<!-- inline-flex/items-center so the trigger button hugs the badge and lines up
|
||||
with sibling badges (a plain button is taller, dropping the pill ~2px). -->
|
||||
<Popover
|
||||
openOnHover={true}
|
||||
debounceDelay={50}
|
||||
enableFlyTransition
|
||||
class="inline-flex items-center"
|
||||
bind:isOpen={popoverOpen}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Badge small color="indigo">
|
||||
{#if orderedUsers.length > 0}
|
||||
@@ -232,28 +271,56 @@
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</span>
|
||||
{#if actionsEnabled && !isSelf}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs3"
|
||||
startIcon={{ icon: Braces }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(u)}
|
||||
loading={busyFor === ownerKey(u)}
|
||||
on:click={() => viewJson(u)}
|
||||
>
|
||||
View JSON
|
||||
</Button>
|
||||
<!-- Operators can't create items, so Fork is hidden (View JSON stays, it's read-only). -->
|
||||
{#if !$userStore?.operator}
|
||||
{#if actionsEnabled && isSelf}
|
||||
<!-- Own draft: jump straight into the editor. -->
|
||||
{#if !$userStore?.operator && itemKind && path}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs3"
|
||||
startIcon={{ icon: GitFork }}
|
||||
startIcon={{ icon: Pencil }}
|
||||
on:click={() => goto(editRouteFor(itemKind, path))}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{/if}
|
||||
{:else if actionsEnabled && !isSelf}
|
||||
{#if !draft_only}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs3"
|
||||
startIcon={{ icon: GitCompareArrows }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(u)}
|
||||
loading={busyFor === ownerKey(u)}
|
||||
on:click={() => fork(u)}
|
||||
on:click={() => viewDiff(u)}
|
||||
>
|
||||
Fork
|
||||
View Diff
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- Operators can't edit items, so Load is hidden (View Diff stays, it's
|
||||
read-only). `allowFork=false` (deploy/review page) hides it too. -->
|
||||
{#if allowFork && !$userStore?.operator}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs3"
|
||||
startIcon={{ icon: Pencil }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(u)}
|
||||
loading={busyFor === ownerKey(u)}
|
||||
on:click={() => load(u)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !u.username && canMigrateLegacy}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs3"
|
||||
startIcon={{ icon: Wrench }}
|
||||
on:click={() => {
|
||||
popoverOpen = false
|
||||
migrateOpen = true
|
||||
}}
|
||||
>
|
||||
Migrate
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -266,27 +333,15 @@
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
<Modal2
|
||||
bind:isOpen={jsonOpen}
|
||||
title="Draft JSON — {jsonOwnerLabel}"
|
||||
fixedWidth="lg"
|
||||
fixedHeight="lg"
|
||||
>
|
||||
{#snippet headerRight()}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
navigator.clipboard?.writeText(JSON.stringify(jsonValue, null, 2))
|
||||
sendUserToast('Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-auto">
|
||||
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
|
||||
>{JSON.stringify(jsonValue ?? {}, null, 2)}</pre
|
||||
>
|
||||
</div>
|
||||
</Modal2>
|
||||
<DiffDrawer bind:this={diffDrawer} isFlow={itemKind === 'flow'} />
|
||||
|
||||
{#if workspace && itemKind && path}
|
||||
<MigrateLegacyDraftModal
|
||||
bind:isOpen={migrateOpen}
|
||||
{workspace}
|
||||
{itemKind}
|
||||
{path}
|
||||
ownDraftExists={!!currentUsername && draft_users.some((u) => u.username === currentUsername)}
|
||||
onMigrated={() => onMigrated?.()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -69,12 +69,17 @@
|
||||
} else if (asset.kind === 's3object' && isS3Uri(assetUri)) {
|
||||
s3FilePicker?.open(assetUri)
|
||||
} else if (asset.kind === 'volume') {
|
||||
const storage = (await VolumeService.getVolumeStorage({ workspace: $workspaceStore! })) ?? undefined
|
||||
const storage =
|
||||
(await VolumeService.getVolumeStorage({ workspace: $workspaceStore! })) ?? undefined
|
||||
s3FilePicker?.open({ s3: `volumes/${$workspaceStore}/${asset.path}/`, storage })
|
||||
} else if (asset.kind === 'ducklake') {
|
||||
let ducklake = asset.path.split('/')[0]
|
||||
let specificTable = asset.path.split('/')[1] as string | undefined
|
||||
dbManagerDrawer?.openDrawer({ type: 'ducklake', ducklake, specificTable })
|
||||
let specificTableSplit = asset.path.split('/')[1]?.split('.') as string[] | undefined
|
||||
let [specificSchema, specificTable] =
|
||||
specificTableSplit?.length === 2
|
||||
? [specificTableSplit[0], specificTableSplit[1]]
|
||||
: [undefined, specificTableSplit?.[0]]
|
||||
dbManagerDrawer?.openDrawer({ type: 'ducklake', ducklake, specificSchema, specificTable })
|
||||
} else if (asset.kind === 'datatable') {
|
||||
let datatable = asset.path.split('/')[0]
|
||||
let specificTableSplit = asset.path.split('/')[1]?.split('.') as string[] | undefined
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import AiChatLayout from './copilot/chat/AiChatLayout.svelte'
|
||||
import type { FlowBuilderProps } from './flow_builder'
|
||||
import FlowBuilder from './FlowBuilder.svelte'
|
||||
import { usePageDraftSync } from './usePageDraftSync.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import type { OpenFlow } from '$lib/gen'
|
||||
|
||||
let {
|
||||
flowStore: oldFlowStore,
|
||||
@@ -12,7 +15,6 @@
|
||||
...props
|
||||
}: FlowBuilderProps & { light?: boolean } = $props()
|
||||
|
||||
let flowStore = $state(untrack(() => oldFlowStore))
|
||||
let flowStateStore = $state(untrack(() => oldFlowStateStore))
|
||||
|
||||
let trialRender = $state(true)
|
||||
@@ -22,12 +24,65 @@
|
||||
trialRender = false
|
||||
}, 1000 * 300)
|
||||
}
|
||||
|
||||
// Stable per-user draft storage key. Captured once so editing the flow's path
|
||||
// (which lives in `draft_path`, not the storage key) can't re-key the autosave
|
||||
// handle and orphan the draft. Mirrors the full-page editor keying on the URL
|
||||
// path; falls back through the SDK's path inputs.
|
||||
const draftStoragePath = untrack(
|
||||
() =>
|
||||
props.initialPath ||
|
||||
props.pathStoreInit ||
|
||||
(oldFlowStore.val as { path?: string } | undefined)?.path ||
|
||||
''
|
||||
)
|
||||
|
||||
// Reuse the full-page flow editor's draft orchestration so the SDK gets
|
||||
// autosave + the AutosaveIndicator (gated by FlowBuilder on
|
||||
// `liveEditorDraftStoragePath`) — and `recordRemoteSync`/`seedBaseline`/
|
||||
// `discardIf` if it ever loads a server draft — from one code path. The SDK
|
||||
// may mount before login, so `useReactive` hands out a detached local-only
|
||||
// handle until `$workspaceStore` resolves (no throw). The builder itself is
|
||||
// gated on the workspace below so no edits are made into that detached handle
|
||||
// — they'd be lost when it re-keys to the real entry on login.
|
||||
// `defaultValue` seeds the handle from the consumer's loaded flow on first
|
||||
// acquire (swallowed by the seed guard, never POSTs) — captured once so it
|
||||
// doesn't churn the reconcile.
|
||||
const initialFlow = untrack(() => oldFlowStore.val)
|
||||
const draftSync = usePageDraftSync<OpenFlow>({
|
||||
itemKind: 'flow',
|
||||
path: () => draftStoragePath,
|
||||
workspace: () => $workspaceStore,
|
||||
defaultValue: initialFlow
|
||||
})
|
||||
|
||||
// Bound store the builder reads/writes, backed by the draft handle. Falls back
|
||||
// to the consumer-provided value in the first-render window before the handle
|
||||
// is acquired.
|
||||
const flowStore = {
|
||||
get val() {
|
||||
return draftSync.draft ?? oldFlowStore.val
|
||||
},
|
||||
set val(v: OpenFlow) {
|
||||
draftSync.draft = v
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if trialRender}
|
||||
<AiChatLayout noPadding={true} {disableAi}>
|
||||
{#if light}<div class="bg-red-500 absolute z-10">Trial version</div>{/if}
|
||||
<FlowBuilder {flowStore} {flowStateStore} {disableAi} {...props} />
|
||||
<!-- Gate on a resolved workspace: the draft handle is detached (local-only)
|
||||
until one exists, so editing before then would be lost on the re-key. -->
|
||||
{#if $workspaceStore}
|
||||
<FlowBuilder
|
||||
{flowStore}
|
||||
{flowStateStore}
|
||||
{disableAi}
|
||||
{...props}
|
||||
liveEditorDraftStoragePath={draftStoragePath || undefined}
|
||||
/>
|
||||
{/if}
|
||||
</AiChatLayout>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center h-screen">
|
||||
|
||||
@@ -279,6 +279,10 @@
|
||||
// Per-instance OAuth providers (Snowflake, ServiceNow, …) keyed by name ->
|
||||
// their registry connect_config_template. Adding a new one needs only a
|
||||
// registry entry — no code here.
|
||||
// Every per-instance templated provider is configurable here: authorization-code
|
||||
// ones (ServiceNow, Snowflake) provide an `auth_url`, client-credentials-only
|
||||
// ones (Coupa) provide only a `token_url`. Both need the admin to enter their
|
||||
// instance host so the shared credentials point at the right endpoint.
|
||||
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
|
||||
Object.entries(oauthConnectRegistry)
|
||||
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
|
||||
@@ -309,7 +313,11 @@
|
||||
if (oauths[name].connect_config?.extra_params?.[key] === v) continue
|
||||
oauths[name].connect_config = {
|
||||
scopes: [],
|
||||
auth_url: tmpl.auth_url.replaceAll('{instance}', v),
|
||||
// CC-only templated providers have no auth_url; store an empty string
|
||||
// (not omitted) so the instance-config parser still types the entry.
|
||||
// The backend treats an empty auth_url as the unused placeholder for
|
||||
// the client-credentials grant.
|
||||
auth_url: tmpl.auth_url ? tmpl.auth_url.replaceAll('{instance}', v) : '',
|
||||
token_url: tmpl.token_url.replaceAll('{instance}', v),
|
||||
req_body_auth: tmpl.req_body_auth ?? false,
|
||||
extra_params: { [key]: v },
|
||||
|
||||
@@ -3,12 +3,38 @@
|
||||
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
|
||||
import AiChatLayout from './copilot/chat/AiChatLayout.svelte'
|
||||
import type { ScriptBuilderProps } from './script_builder'
|
||||
import { usePageDraftSync } from './usePageDraftSync.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
let { script: oldScript, disableAi, ...props }: ScriptBuilderProps = $props()
|
||||
|
||||
let script = $state(untrack(() => oldScript))
|
||||
// Stable per-user draft storage key. Mirrors the full-page editor keying on
|
||||
// the URL path; falls back through the SDK's path inputs.
|
||||
const draftStoragePath = untrack(() => props.initialPath || oldScript?.path || '')
|
||||
|
||||
// Reuse the full-page script editor's draft orchestration (same as the flow
|
||||
// SDK) so the SDK gets autosave + the AutosaveIndicator (gated by ScriptBuilder
|
||||
// on `userDraftPath`) from one code path. `defaultValue` seeds the handle from
|
||||
// the consumer's script on first acquire (swallowed by the syncer's seed guard,
|
||||
// never POSTs). `useReactive` tolerates mounting before login (detached
|
||||
// local-only handle, no throw); the builder is gated on the workspace below so
|
||||
// edits aren't made into that detached handle and lost when it re-keys.
|
||||
const initialScript = untrack(() => oldScript)
|
||||
const draftSync = usePageDraftSync<ScriptBuilderProps['script']>({
|
||||
itemKind: 'script',
|
||||
path: () => draftStoragePath,
|
||||
workspace: () => $workspaceStore,
|
||||
defaultValue: initialScript
|
||||
})
|
||||
</script>
|
||||
|
||||
<AiChatLayout noPadding {disableAi}>
|
||||
<ScriptBuilder bind:script {disableAi} {...props} />
|
||||
{#if $workspaceStore && draftSync.draft}
|
||||
<ScriptBuilder
|
||||
bind:script={draftSync.draft}
|
||||
userDraftPath={draftStoragePath}
|
||||
{disableAi}
|
||||
{...props}
|
||||
/>
|
||||
{/if}
|
||||
</AiChatLayout>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { Badge } from './common'
|
||||
import Checkbox from './common/checkbox/Checkbox.svelte'
|
||||
import Row from './common/table/Row.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
@@ -18,6 +19,10 @@
|
||||
items: DeployableItem[]
|
||||
selectedItems: string[]
|
||||
selectablePredicate?: (item: DeployableItem) => boolean
|
||||
/** For a non-deployed item that isn't selectable, return a reason string to
|
||||
* render a disabled checkbox + hover tooltip (instead of greying the row);
|
||||
* return undefined to keep the default greyed-out, no-checkbox treatment. */
|
||||
selectBlockedReason?: (item: DeployableItem) => string | undefined
|
||||
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
|
||||
allSelected?: boolean
|
||||
emptyMessage?: string
|
||||
@@ -26,7 +31,11 @@
|
||||
// Snippets for customization
|
||||
header?: Snippet
|
||||
alerts?: Snippet
|
||||
/** Rendered on the right of the "Select all" row (e.g. a filter toggle). */
|
||||
selectAllActions?: Snippet
|
||||
itemSummary?: Snippet<[DeployableItem]>
|
||||
/** Overrides the secondary path line per item (e.g. to strike a renamed path). */
|
||||
itemPath?: Snippet<[DeployableItem]>
|
||||
itemActions?: Snippet<[DeployableItem]>
|
||||
footer?: Snippet
|
||||
|
||||
@@ -40,12 +49,15 @@
|
||||
items,
|
||||
selectedItems,
|
||||
selectablePredicate = () => true,
|
||||
selectBlockedReason,
|
||||
deploymentStatus,
|
||||
allSelected = false,
|
||||
emptyMessage = 'No items to deploy',
|
||||
header,
|
||||
alerts,
|
||||
selectAllActions,
|
||||
itemSummary,
|
||||
itemPath,
|
||||
itemActions,
|
||||
footer,
|
||||
onToggleItem,
|
||||
@@ -76,24 +88,33 @@
|
||||
{@render alerts()}
|
||||
{/if}
|
||||
|
||||
{#if items.length > 0}
|
||||
<!-- Select all row -->
|
||||
<!-- Controls row: "Select all" (when there are items) + optional right-side
|
||||
actions (e.g. a filter toggle). Renders when there are items OR actions are
|
||||
provided, so a filter that empties the list doesn't take its own toggle with it. -->
|
||||
{#if items.length > 0 || selectAllActions}
|
||||
<div class="px-4 py-2 flex items-center justify-between">
|
||||
<label
|
||||
class="flex items-center gap-2 text-secondary text-xs"
|
||||
class:opacity-50={!hasSelectableItems}
|
||||
class:cursor-pointer={hasSelectableItems}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!hasSelectableItems}
|
||||
checked={allSelected}
|
||||
onchange={allSelected ? onDeselectAll : onSelectAll}
|
||||
class="rounded max-w-4 w-full"
|
||||
/> Select all
|
||||
</label>
|
||||
{#if items.length > 0}
|
||||
<label
|
||||
class="flex items-center gap-2 text-secondary text-xs"
|
||||
class:opacity-50={!hasSelectableItems}
|
||||
class:cursor-pointer={hasSelectableItems}
|
||||
>
|
||||
<Checkbox
|
||||
disabled={!hasSelectableItems}
|
||||
checked={allSelected}
|
||||
onChange={allSelected ? onDeselectAll : onSelectAll}
|
||||
/> Select all
|
||||
</label>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
{#if selectAllActions}
|
||||
{@render selectAllActions()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if items.length > 0}
|
||||
<!-- Items list -->
|
||||
<div class="overflow-y-auto">
|
||||
<div class="border rounded-md bg-surface-tertiary">
|
||||
@@ -102,12 +123,15 @@
|
||||
{@const isSelected = selectedItems.includes(item.key)}
|
||||
{@const status = deploymentStatus[item.key]}
|
||||
{@const isDeployed = status?.status === 'deployed'}
|
||||
{@const blockedReason =
|
||||
!isSelectable && !isDeployed ? selectBlockedReason?.(item) : undefined}
|
||||
|
||||
<Row
|
||||
isSelectable={isSelectable && !isDeployed}
|
||||
selectDisabledReason={blockedReason}
|
||||
selectOnRowClick={true}
|
||||
alignWithSelectable={true}
|
||||
disabled={!isSelectable}
|
||||
disabled={blockedReason ? false : !isSelectable}
|
||||
selected={isSelected && !isDeployed}
|
||||
onSelect={() => handleSelect(item)}
|
||||
path={item.kind !== 'resource' &&
|
||||
@@ -128,6 +152,17 @@
|
||||
{item.path}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet pathDisplay()}
|
||||
{#if itemPath}
|
||||
{@render itemPath(item)}
|
||||
{:else}
|
||||
{item.kind !== 'resource' &&
|
||||
item.kind !== 'variable' &&
|
||||
item.kind !== 'resource_type'
|
||||
? item.path
|
||||
: ''}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
{#if itemActions}
|
||||
{@render itemActions(item)}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function getDbFeatures(dbInput: DbInput): Required<DbFeatures> {
|
||||
primaryKeys: true,
|
||||
defaultValues: true,
|
||||
enforcedForeignKeys: true,
|
||||
schemas: dbInput.type !== 'ducklake' && dbSupportsSchemas(dbInput.resourceType)
|
||||
schemas: dbInput.type === 'ducklake' ? true : dbSupportsSchemas(dbInput.resourceType)
|
||||
}
|
||||
|
||||
if (dbInput.type == 'ducklake')
|
||||
|
||||
@@ -356,7 +356,7 @@ export async function getTablesByResource(
|
||||
const paths: string[] = []
|
||||
for (const key in s?.schema) {
|
||||
for (const subKey in s.schema[key]) {
|
||||
paths.push(`${subKey}`)
|
||||
paths.push(key === 'main' ? `${subKey}` : `${key}.${subKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +381,12 @@ export function getPrimaryKeys(tableMetadata?: TableMetadata): string[] {
|
||||
}
|
||||
|
||||
export function dbSupportsSchemas(dbType: DbType): boolean {
|
||||
return dbType === 'postgresql' || dbType === 'snowflake' || dbType === 'bigquery'
|
||||
return (
|
||||
dbType === 'postgresql' ||
|
||||
dbType === 'snowflake' ||
|
||||
dbType === 'bigquery' ||
|
||||
dbType === 'duckdb'
|
||||
)
|
||||
}
|
||||
|
||||
export function datatypeHasLength(datatype: string): boolean {
|
||||
|
||||
@@ -193,6 +193,20 @@
|
||||
})
|
||||
})
|
||||
|
||||
// Mirror the summary onto the autosaved App so a draft persists it (the
|
||||
// autosave stores the bare App value, which has no summary of its own — it
|
||||
// lives in the `app` table column, set only on deploy). Without this the
|
||||
// summary is lost when reopening a draft or deploying it from the Review &
|
||||
// Deploy page. Parallels `draft_path`.
|
||||
$effect(() => {
|
||||
const s = $summary
|
||||
const a = $app
|
||||
if (!a) return
|
||||
untrack(() => {
|
||||
if (a.summary !== s) a.summary = s
|
||||
})
|
||||
})
|
||||
|
||||
const { history, jobsDrawerOpen, refreshComponents } =
|
||||
getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
|
||||
@@ -207,6 +207,15 @@ export type App = {
|
||||
* clears the whole draft.
|
||||
*/
|
||||
draft_path?: string
|
||||
/**
|
||||
* App summary persisted on the autosaved App so a draft round-trips it — the
|
||||
* autosave stores the bare App value, which otherwise drops the summary (it
|
||||
* normally lives in the `app` table column, set only on deploy). Mirrors
|
||||
* `draft_path`: draft-only metadata; the deployed summary column is
|
||||
* authoritative, and the Review & Deploy page reads it from the draft and
|
||||
* sends it as the summary on deploy.
|
||||
*/
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export type ConnectingInput = {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
/** Controlled checked state. */
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
/** Native title attribute (hover hint). */
|
||||
title?: string | undefined
|
||||
/** Extra classes merged onto the input. */
|
||||
class?: string | undefined
|
||||
/** Change handler (controlled — the parent owns `checked`). */
|
||||
onChange?: (e: Event & { currentTarget: EventTarget & HTMLInputElement }) => void
|
||||
}
|
||||
|
||||
let {
|
||||
checked = false,
|
||||
disabled = false,
|
||||
title = undefined,
|
||||
class: className = undefined,
|
||||
onChange
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
{checked}
|
||||
{disabled}
|
||||
{title}
|
||||
onchange={onChange}
|
||||
class={twMerge(
|
||||
'rounded max-w-4 w-full',
|
||||
// When disabled, grey it and let hover fall through to a wrapping trigger
|
||||
// (e.g. a tooltip explaining why it can't be selected).
|
||||
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
@@ -14,6 +14,8 @@
|
||||
import DraftSyncConflictModal from './DraftSyncConflictModal.svelte'
|
||||
import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte'
|
||||
import StaleDraftModal from './StaleDraftModal.svelte'
|
||||
import ConfirmationModal from './ConfirmationModal.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
@@ -21,6 +23,12 @@
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
otherDraftsUsers: OtherDraftUser[]
|
||||
/** No deployed row exists: hides the OtherUsersDraftsModal's View Diff
|
||||
* (nothing to diff the other user's draft against). */
|
||||
draftOnly?: boolean
|
||||
/** We have our own draft here — legacy "Assign to self" confirms before
|
||||
* overwriting it. */
|
||||
hasOwnDraft?: boolean
|
||||
onLoadFromServer: () => void | Promise<void>
|
||||
getLocalDraft: () => unknown
|
||||
/** Bindable open-flag for the OtherUsersDraftsModal (route-owned). */
|
||||
@@ -41,6 +49,8 @@
|
||||
itemKind,
|
||||
path,
|
||||
otherDraftsUsers,
|
||||
draftOnly = false,
|
||||
hasOwnDraft = false,
|
||||
onLoadFromServer,
|
||||
getLocalDraft,
|
||||
othersModalOpen = $bindable(),
|
||||
@@ -87,6 +97,9 @@
|
||||
{itemKind}
|
||||
{path}
|
||||
{otherDraftsUsers}
|
||||
{draftOnly}
|
||||
{hasOwnDraft}
|
||||
onReload={onLoadFromServer}
|
||||
bind:isOpen={othersModalOpen}
|
||||
/>
|
||||
{/key}
|
||||
@@ -99,4 +112,16 @@
|
||||
{onLoadLatestDeploy}
|
||||
/>
|
||||
{/if}
|
||||
<ConfirmationModal
|
||||
open={OtherUserDraftLoad.isOverwriteModalOpen(workspace, itemKind, path)}
|
||||
title="Overwrite your current draft?"
|
||||
confirmationText="Overwrite"
|
||||
onConfirmed={() => OtherUserDraftLoad.confirmOverwrite(workspace, itemKind, path)}
|
||||
onCanceled={() => OtherUserDraftLoad.dismissOverwriteModal(workspace, itemKind, path)}
|
||||
>
|
||||
<span class="text-sm">
|
||||
You're editing another user's draft. Saving this edit will overwrite your own draft at this
|
||||
path. Continue?
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Admin-only resolution of a LEGACY (workspace-level, no-owner) draft: delete
|
||||
* it, or assign it to yourself as a normal per-user draft. Opened from the
|
||||
* home-page draft popover and the in-editor "other users' drafts" modal.
|
||||
*/
|
||||
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { Trash2, UserCheck, Wrench, TriangleAlert } from 'lucide-svelte'
|
||||
|
||||
type Props = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
isOpen: boolean
|
||||
/** You already have your own draft at this path: "Assign to self" would
|
||||
* replace it, so confirm first. */
|
||||
ownDraftExists?: boolean
|
||||
/** Refresh the caller's view once the legacy row is gone/reassigned. */
|
||||
onMigrated?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
let {
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
isOpen = $bindable(),
|
||||
ownDraftExists = false,
|
||||
onMigrated
|
||||
}: Props = $props()
|
||||
|
||||
let busy = $state<'delete' | 'assign_to_self' | null>(null)
|
||||
// "Assign to self" would overwrite our own draft — show the confirm step first.
|
||||
let confirmingAssign = $state(false)
|
||||
|
||||
// Reset the confirm step whenever the modal (re)opens.
|
||||
$effect(() => {
|
||||
if (isOpen) confirmingAssign = false
|
||||
})
|
||||
|
||||
function onAssignClick() {
|
||||
if (ownDraftExists) confirmingAssign = true
|
||||
else void run('assign_to_self')
|
||||
}
|
||||
|
||||
async function run(action: 'delete' | 'assign_to_self') {
|
||||
busy = action
|
||||
try {
|
||||
await DraftService.migrateLegacyDraft({
|
||||
workspace,
|
||||
kind: itemKind,
|
||||
path,
|
||||
requestBody: { action }
|
||||
})
|
||||
sendUserToast(action === 'delete' ? 'Legacy draft deleted' : 'Legacy draft assigned to you')
|
||||
isOpen = false
|
||||
await onMigrated?.()
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Could not migrate legacy draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busy = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal2 bind:isOpen title="Migrate legacy draft — {path}" fixedWidth="sm" fixedHeight="adaptive">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
{#if confirmingAssign}
|
||||
<div class="flex gap-3 items-start">
|
||||
<TriangleAlert size={20} class="text-yellow-500 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-secondary">
|
||||
You already have your own draft at <span class="font-medium text-primary">{path}</span>.
|
||||
Assigning this legacy draft to yourself will
|
||||
<span class="font-semibold">replace your current draft</span>. This can't be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button variant="default" size="sm" on:click={() => (confirmingAssign = false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
destructive
|
||||
size="sm"
|
||||
startIcon={{ icon: UserCheck }}
|
||||
loading={busy === 'assign_to_self'}
|
||||
on:click={() => run('assign_to_self')}
|
||||
>
|
||||
Replace my draft
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-3 items-start">
|
||||
<Wrench size={20} class="text-blue-500 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-secondary">
|
||||
This is a pre-migration workspace-level draft with no owner. As an admin you can delete
|
||||
it, or assign it to yourself to keep editing it as your own draft.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button variant="default" size="sm" on:click={() => (isOpen = false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
destructive
|
||||
size="sm"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
loading={busy === 'delete'}
|
||||
disabled={busy !== null && busy !== 'delete'}
|
||||
on:click={() => run('delete')}
|
||||
>
|
||||
Delete draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
startIcon={{ icon: UserCheck }}
|
||||
loading={busy === 'assign_to_self'}
|
||||
disabled={busy !== null && busy !== 'assign_to_self'}
|
||||
on:click={onAssignClick}
|
||||
>
|
||||
Assign to self
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -2,17 +2,21 @@
|
||||
/**
|
||||
* On-demand modal (from the AutosaveIndicator or DraftBadge popover) listing
|
||||
* other users' drafts at this path. The owner list rides the overlay/list
|
||||
* payload; individual drafts are fetched lazily for View JSON / Fork.
|
||||
* payload; individual drafts are fetched lazily for View Diff / Load.
|
||||
* Parent-controlled via `isOpen` — never auto-opens.
|
||||
*/
|
||||
import { DraftService, type UserDraftItemKind } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Users, GitFork, Braces } from 'lucide-svelte'
|
||||
import { Users, Pencil, GitCompareArrows, Wrench } from 'lucide-svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { forkDraftToImport } from '$lib/components/forkDraftToImport'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import MigrateLegacyDraftModal from './MigrateLegacyDraftModal.svelte'
|
||||
import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
export type OtherDraftUser = { username?: string | null; draft_saved_at?: string }
|
||||
|
||||
@@ -23,15 +27,35 @@
|
||||
/** Owners from the overlay response (`username`, or `null` for the
|
||||
* legacy row). The authed user is filtered out server-side. */
|
||||
otherDraftsUsers: OtherDraftUser[]
|
||||
/** No deployed row exists (never deployed): hides View Diff, since there's
|
||||
* no deployed baseline to diff against. */
|
||||
draftOnly?: boolean
|
||||
/** We have our own draft at this path — "Migrate → Assign to self" of the
|
||||
* legacy row would overwrite it, so it confirms first. */
|
||||
hasOwnDraft?: boolean
|
||||
/** Reload the editor in place so it picks up the staged "Load" — we're
|
||||
* already on this item's edit route. */
|
||||
onReload?: () => void | Promise<void>
|
||||
/** Controlled visibility — bind from the parent. */
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
let { workspace, itemKind, path, otherDraftsUsers, isOpen = $bindable() }: Props = $props()
|
||||
let {
|
||||
workspace,
|
||||
itemKind,
|
||||
path,
|
||||
otherDraftsUsers,
|
||||
draftOnly = false,
|
||||
hasOwnDraft = false,
|
||||
onReload,
|
||||
isOpen = $bindable()
|
||||
}: Props = $props()
|
||||
let busyFor = $state<string | null>(null)
|
||||
let jsonOpen = $state(false)
|
||||
let jsonOwnerLabel = $state('')
|
||||
let jsonValue = $state<unknown>(undefined)
|
||||
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
||||
let migrateOpen = $state(false)
|
||||
|
||||
// Legacy (no-owner) drafts can only be resolved by workspace admins / superadmins.
|
||||
const canMigrateLegacy = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin)
|
||||
|
||||
function ownerLabel(owner: OtherDraftUser): string {
|
||||
return owner.username ?? 'Legacy draft'
|
||||
@@ -52,12 +76,23 @@
|
||||
).value
|
||||
}
|
||||
|
||||
async function viewJson(owner: OtherDraftUser) {
|
||||
async function viewDiff(owner: OtherDraftUser) {
|
||||
busyFor = ownerKey(owner)
|
||||
try {
|
||||
jsonValue = await fetchDraft(owner)
|
||||
jsonOwnerLabel = ownerLabel(owner)
|
||||
jsonOpen = true
|
||||
const [draftValue, deployed] = await Promise.all([
|
||||
fetchDraft(owner),
|
||||
fetchDeployedValueForDiff(workspace, itemKind, path)
|
||||
])
|
||||
// Close this modal first — the DiffDrawer (z-index ~1100) renders below
|
||||
// Modal2 (z-1110), so leaving it open would hide the drawer behind it.
|
||||
isOpen = false
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
title: `${ownerLabel(owner)}'s draft vs deployed`,
|
||||
original: deployed,
|
||||
current: draftValue as any
|
||||
})
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
@@ -65,17 +100,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fork(owner: OtherDraftUser) {
|
||||
async function load(owner: OtherDraftUser) {
|
||||
busyFor = ownerKey(owner)
|
||||
try {
|
||||
const value = await fetchDraft(owner)
|
||||
// Close before navigating — `goto` returns before Svelte tears down
|
||||
// the route, so the modal would otherwise linger on the destination.
|
||||
isOpen = false
|
||||
// Seed a brand-new own item from the fetched value (no server save).
|
||||
forkDraftToImport(itemKind, value, path)
|
||||
// Already on this item's edit route — stage + reload in place. If we
|
||||
// have our own draft, the loader enters overlay mode (no save until
|
||||
// the user confirms overwriting it).
|
||||
OtherUserDraftLoad.stage(workspace, itemKind, value, path, ownerLabel(owner), {
|
||||
navigate: false
|
||||
})
|
||||
await onReload?.()
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not fork draft: ${e.body ?? e.message}`, true)
|
||||
sendUserToast(`Could not load draft: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
busyFor = null
|
||||
}
|
||||
@@ -87,7 +125,6 @@
|
||||
title="Other users are currently working on {path}"
|
||||
fixedWidth="sm"
|
||||
fixedHeight="sm"
|
||||
closeOnOutsideClick={!jsonOpen}
|
||||
>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex gap-3 items-start">
|
||||
@@ -125,26 +162,38 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !draftOnly}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: GitCompareArrows }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
||||
loading={busyFor === ownerKey(owner)}
|
||||
on:click={() => viewDiff(owner)}
|
||||
>
|
||||
View Diff
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Braces }}
|
||||
startIcon={{ icon: Pencil }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
||||
loading={busyFor === ownerKey(owner)}
|
||||
on:click={() => viewJson(owner)}
|
||||
on:click={() => load(owner)}
|
||||
>
|
||||
View JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: GitFork }}
|
||||
disabled={busyFor !== null && busyFor !== ownerKey(owner)}
|
||||
loading={busyFor === ownerKey(owner)}
|
||||
on:click={() => fork(owner)}
|
||||
>
|
||||
Fork
|
||||
Load
|
||||
</Button>
|
||||
{#if !owner.username && canMigrateLegacy}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
startIcon={{ icon: Wrench }}
|
||||
on:click={() => (migrateOpen = true)}
|
||||
>
|
||||
Migrate
|
||||
</Button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -155,27 +204,16 @@
|
||||
</div>
|
||||
</Modal2>
|
||||
|
||||
<Modal2
|
||||
bind:isOpen={jsonOpen}
|
||||
title="Draft JSON — {jsonOwnerLabel}"
|
||||
fixedWidth="lg"
|
||||
fixedHeight="lg"
|
||||
>
|
||||
{#snippet headerRight()}
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
navigator.clipboard?.writeText(JSON.stringify(jsonValue, null, 2))
|
||||
sendUserToast('Copied to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="w-full overflow-auto">
|
||||
<pre class="text-xs whitespace-pre font-mono bg-surface-secondary rounded p-3"
|
||||
>{JSON.stringify(jsonValue ?? {}, null, 2)}</pre
|
||||
>
|
||||
</div>
|
||||
</Modal2>
|
||||
<DiffDrawer bind:this={diffDrawer} isFlow={itemKind === 'flow'} />
|
||||
|
||||
<MigrateLegacyDraftModal
|
||||
bind:isOpen={migrateOpen}
|
||||
{workspace}
|
||||
{itemKind}
|
||||
{path}
|
||||
ownDraftExists={hasOwnDraft}
|
||||
onMigrated={async () => {
|
||||
isOpen = false
|
||||
await onReload?.()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ export { default as TabContent } from './tabs/TabContent.svelte'
|
||||
export { default as Tabs } from './tabs/Tabs.svelte'
|
||||
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte'
|
||||
export { default as FileInput } from './fileInput/FileInput.svelte'
|
||||
export { default as RadioCard } from './radioCard/RadioCard.svelte'
|
||||
export { default as Section } from '../Section.svelte'
|
||||
export { default as Url } from './Url.svelte'
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { X } from 'lucide-svelte'
|
||||
import List from '$lib/components/common/layout/List.svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -85,6 +87,11 @@
|
||||
function fadeFast(node: HTMLElement) {
|
||||
return fade(node, { duration: 200 })
|
||||
}
|
||||
|
||||
// Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so
|
||||
// the dialog isn't hidden behind it; otherwise keep the default modal
|
||||
// stacking just above disposables (zIndexes.disposables).
|
||||
const overlayZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10)
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -92,7 +99,8 @@
|
||||
{#if isOpen}
|
||||
<Portal name="always-mounted" {target}>
|
||||
<div
|
||||
class={'fixed top-0 bottom-0 left-0 right-0 transition-all z-[1110] overflow-auto bg-black bg-opacity-60 w-full h-full'}
|
||||
class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'}
|
||||
style="z-index: {overlayZIndex}"
|
||||
transition:fadeFast|local
|
||||
>
|
||||
<div class="flex min-h-full items-center justify-center p-8">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { Circle, CircleDot } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
let {
|
||||
label,
|
||||
description = undefined,
|
||||
selected = false,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
icon = undefined,
|
||||
class: className = ''
|
||||
}: {
|
||||
/** Title shown in bold at the top of the card */
|
||||
label: string
|
||||
/** Optional supporting line under the label */
|
||||
description?: string
|
||||
/** Whether this card is the selected option */
|
||||
selected?: boolean
|
||||
/** Called when the card is clicked */
|
||||
onSelect: () => void
|
||||
disabled?: boolean
|
||||
/** Optional leading icon, rendered after the radio */
|
||||
icon?: Snippet
|
||||
class?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
onclick={onSelect}
|
||||
class={twMerge(
|
||||
'w-full text-left rounded-md border p-3 transition-colors',
|
||||
selected
|
||||
? 'border-border-selected bg-surface-selected'
|
||||
: 'border-border-light hover:bg-surface-hover',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
{#if selected}
|
||||
<CircleDot size={16} class="text-accent shrink-0 mt-0.5" />
|
||||
{:else}
|
||||
<Circle size={16} class="text-hint shrink-0 mt-0.5" />
|
||||
{/if}
|
||||
{#if icon}
|
||||
<div class="shrink-0 mt-0.5">{@render icon()}</div>
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-semibold text-emphasis">{label}</div>
|
||||
{#if description}
|
||||
<div class="text-xs font-normal text-secondary mt-0.5">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -130,6 +130,7 @@
|
||||
workspace={$workspaceStore ?? undefined}
|
||||
itemKind={app.raw_app ? 'raw_app' : 'app'}
|
||||
path={app.path}
|
||||
onMigrated={() => dispatch('change')}
|
||||
/>
|
||||
{#if app.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
workspace={$workspaceStore ?? undefined}
|
||||
itemKind="flow"
|
||||
path={flow.path}
|
||||
onMigrated={() => dispatch('change')}
|
||||
/>
|
||||
{#if flow.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import Tooltip from '../../meltComponents/Tooltip.svelte'
|
||||
import Checkbox from '../checkbox/Checkbox.svelte'
|
||||
|
||||
interface Props {
|
||||
marked: string | undefined
|
||||
@@ -14,6 +16,10 @@
|
||||
disabled?: boolean
|
||||
canFavorite?: boolean
|
||||
isSelectable?: boolean
|
||||
/** When the row is not selectable, render a disabled checkbox with this
|
||||
* reason as a hover tooltip (instead of an empty slot) — explains why the
|
||||
* row can't be selected without greying the whole row via `disabled`. */
|
||||
selectDisabledReason?: string
|
||||
/** When true, clicking anywhere on the row card (except interactive
|
||||
* children — checkbox, buttons, links) toggles selection. Opt-in so
|
||||
* existing tables that don't want it are unaffected. */
|
||||
@@ -53,6 +59,9 @@
|
||||
badges?: import('svelte').Snippet
|
||||
actions?: import('svelte').Snippet
|
||||
customSummary?: import('svelte').Snippet
|
||||
/** Overrides the secondary path line (e.g. to strike a renamed path).
|
||||
* Falls back to the plain `path` string when not provided. */
|
||||
pathDisplay?: import('svelte').Snippet
|
||||
onSelect?: (
|
||||
e: Event & {
|
||||
currentTarget: EventTarget & HTMLInputElement
|
||||
@@ -67,6 +76,7 @@
|
||||
disabled = false,
|
||||
canFavorite = true,
|
||||
isSelectable = false,
|
||||
selectDisabledReason = undefined,
|
||||
selectOnRowClick = false,
|
||||
alignWithSelectable = false,
|
||||
errorHandlerMuted = false,
|
||||
@@ -82,6 +92,7 @@
|
||||
badges,
|
||||
actions,
|
||||
customSummary,
|
||||
pathDisplay,
|
||||
onSelect = () => {}
|
||||
}: Props = $props()
|
||||
|
||||
@@ -154,7 +165,12 @@
|
||||
onkeydown={clickToSelect ? handleRowKeydown : undefined}
|
||||
>
|
||||
{#if isSelectable}
|
||||
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
|
||||
<Checkbox checked={selected} onChange={onSelect} />
|
||||
{:else if selectDisabledReason}
|
||||
<Tooltip class="cursor-not-allowed">
|
||||
<Checkbox disabled checked={false} />
|
||||
{#snippet text()}{selectDisabledReason}{/snippet}
|
||||
</Tooltip>
|
||||
{:else if alignWithSelectable}
|
||||
<div class="rounded max-w-4 w-full"></div>
|
||||
{/if}
|
||||
@@ -209,7 +225,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-hint text-3xs truncate text-left font-normal" title={path}>
|
||||
{path}
|
||||
{#if pathDisplay}
|
||||
{@render pathDisplay()}
|
||||
{:else}
|
||||
{path}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
workspace={$workspaceStore ?? undefined}
|
||||
itemKind="script"
|
||||
path={script.path}
|
||||
onMigrated={() => dispatch('change')}
|
||||
/>
|
||||
{#if script.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import QueuedMessageChip from './QueuedMessageChip.svelte'
|
||||
import { getModifierKey } from '$lib/utils'
|
||||
import type { SelectedContext } from './app/core'
|
||||
|
||||
@@ -533,6 +534,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<QueuedMessageChip />
|
||||
{#if inputPreface}
|
||||
{@render inputPreface()}
|
||||
{/if}
|
||||
|
||||
@@ -188,6 +188,14 @@
|
||||
focusInput()
|
||||
}
|
||||
|
||||
/** Put text back into the textarea (queued-message delete, or restore
|
||||
* after a cancelled/errored turn), prepended to any draft so nothing
|
||||
* the user typed is lost. */
|
||||
export function prependText(text: string) {
|
||||
instructions = instructions.trim() ? `${text}\n\n${instructions}` : text
|
||||
focusInput()
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (node && !node.contains(event.target as Node)) {
|
||||
@@ -270,6 +278,17 @@
|
||||
|
||||
function sendRequest() {
|
||||
if (aiChatManager.loading) {
|
||||
// Queue the message instead of silently discarding it — it is
|
||||
// auto-sent when the streaming turn completes successfully.
|
||||
// Editing-while-loading keeps the old discard behavior. Paste
|
||||
// tokens are expanded into the queued text (the queue is plain
|
||||
// strings), so the full content survives the auto-send.
|
||||
if (editingMessageIndex === null && instructions.trim()) {
|
||||
aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes)))
|
||||
contextTextareaComponent?.clearForSend()
|
||||
instructions = ''
|
||||
pastes = []
|
||||
}
|
||||
return
|
||||
}
|
||||
if (editingMessageIndex !== null) {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { get } from 'svelte/store'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
||||
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
|
||||
import {
|
||||
createAppBackendRunnableContextElement,
|
||||
@@ -89,6 +90,10 @@ import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
// from mode switches, and the estimate's chars/4 error.
|
||||
const COMPACTION_TRIGGER_RATIO = 0.8
|
||||
const COMPACTION_TARGET_RATIO = 0.7
|
||||
// Abort reason for a deliberate user cancel (Esc / Stop). Programmatic cancels
|
||||
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
|
||||
// flush can tell "the user wants to move on" from "the turn was torn down".
|
||||
const USER_CANCEL_REASON = 'user_cancelled'
|
||||
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
|
||||
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
|
||||
const WEB_SEARCH_ERROR_HINT =
|
||||
@@ -220,6 +225,11 @@ export class AIChatManager {
|
||||
savedSize = $state<number>(0)
|
||||
instructions = $state<string>('')
|
||||
pendingPrompt = $state<string>('')
|
||||
// Message typed while a turn is streaming. There is only ever one queued
|
||||
// message; pressing Enter again appends another line to it. Auto-sent when
|
||||
// the turn finishes (clean completion or user cancel). Ephemeral — never
|
||||
// saved to displayMessages or history.
|
||||
queuedMessage = $state<string>('')
|
||||
loading = $state<boolean>(false)
|
||||
currentReply = $state<string>('')
|
||||
currentReasoning = $state<string>('')
|
||||
@@ -391,7 +401,7 @@ export class AIChatManager {
|
||||
try {
|
||||
this.apiTools = await loadApiTools()
|
||||
if (this.mode === AIMode.API) {
|
||||
this.tools = [...this.apiTools]
|
||||
this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading api tools', err)
|
||||
@@ -509,6 +519,38 @@ export class AIChatManager {
|
||||
this.aiChatInput = aiChatInput
|
||||
}
|
||||
|
||||
/** Queue the message typed while a turn is streaming. There is only ever
|
||||
* one queued message; pressing Enter again appends the new text as another
|
||||
* line so it all goes out as a single message. */
|
||||
queueMessage(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed
|
||||
}
|
||||
|
||||
/** Remove the queued message and put its text back into the input. */
|
||||
dequeueMessage() {
|
||||
if (!this.queuedMessage) {
|
||||
return
|
||||
}
|
||||
const message = this.queuedMessage
|
||||
this.queuedMessage = ''
|
||||
this.restoreToInput(message)
|
||||
}
|
||||
|
||||
/** Put text the user typed back where they can see it: into the input
|
||||
* when it's mounted, otherwise back into the queue so it reappears with
|
||||
* the chat panel instead of being silently dropped. */
|
||||
private restoreToInput(text: string) {
|
||||
if (this.aiChatInput) {
|
||||
this.aiChatInput.prependText(text)
|
||||
} else {
|
||||
this.queuedMessage = text
|
||||
}
|
||||
}
|
||||
|
||||
focusInput() {
|
||||
if (this.aiChatInput) {
|
||||
this.aiChatInput.focusInput()
|
||||
@@ -625,7 +667,7 @@ export class AIChatManager {
|
||||
} else if (mode === AIMode.API) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
this.systemMessage = prepareApiSystemMessage(customPrompt)
|
||||
this.tools = [...this.apiTools]
|
||||
this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools]
|
||||
this.helpers = {}
|
||||
} else if (mode === AIMode.GLOBAL) {
|
||||
const customPrompt = getCombinedCustomPrompt(mode)
|
||||
@@ -789,16 +831,22 @@ export class AIChatManager {
|
||||
}
|
||||
|
||||
// Roll a turn that produced nothing usable back out of the transcript and
|
||||
// hand its text back to the composer for editing/resending.
|
||||
// hand its text back to the composer for editing/resending. `restoreToInput`
|
||||
// is false when a queued message is about to take over (a user cancel with
|
||||
// something queued) — then the rolled-back prompt is dropped rather than
|
||||
// shoved back into the input, so the handoff to the queued message is clean.
|
||||
private restoreUnsentTurn = (
|
||||
displayLenAfterUser: number,
|
||||
modelLenAfterUser: number,
|
||||
instructions: string,
|
||||
pastes: PasteAttachment[]
|
||||
pastes: PasteAttachment[],
|
||||
restoreToInput: boolean = true
|
||||
) => {
|
||||
this.displayMessages = this.displayMessages.slice(0, displayLenAfterUser - 1)
|
||||
this.messages = this.messages.slice(0, modelLenAfterUser - 1)
|
||||
this.aiChatInput?.restoreInstructions(instructions, pastes)
|
||||
if (restoreToInput) {
|
||||
this.aiChatInput?.restoreInstructions(instructions, pastes)
|
||||
}
|
||||
}
|
||||
|
||||
private chatRequest = async ({
|
||||
@@ -1003,9 +1051,12 @@ export class AIChatManager {
|
||||
isPreprocessor?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
// Returns whether the message was actually turned into a chat turn —
|
||||
// the queue flush uses this to restore messages dropped by an early
|
||||
// return instead of silently losing them.
|
||||
const requestedMode = options.mode ?? this.mode
|
||||
if (!isAIModeVisible(requestedMode)) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
this.changeMode(requestedMode, undefined, {
|
||||
lang: options.lang,
|
||||
@@ -1015,7 +1066,7 @@ export class AIChatManager {
|
||||
this.instructions = options.instructions
|
||||
}
|
||||
if (!this.instructions.trim()) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (this.beforeSend) {
|
||||
try {
|
||||
@@ -1032,7 +1083,7 @@ export class AIChatManager {
|
||||
}. Your message was not sent — please try again.`,
|
||||
true
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
|
||||
@@ -1045,6 +1096,10 @@ export class AIChatManager {
|
||||
// from saveChat) must not make the catch commit the turn a second time.
|
||||
let turnOutcomeHandled = false
|
||||
let webSearchUnavailable = false
|
||||
// Gates the queued-message flush below: only a cleanly committed turn
|
||||
// auto-sends the next queued message. Cancel, error, and empty-response
|
||||
// rollbacks leave it false so queued text is restored to the input.
|
||||
let turnCommittedCleanly = false
|
||||
try {
|
||||
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
|
||||
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
|
||||
@@ -1313,7 +1368,17 @@ export class AIChatManager {
|
||||
// (or only reasoning) — treat the turn as unsent (matches Claude Code).
|
||||
// contextUsage is left as-is: the turn is rolled back, so the last
|
||||
// report (pre-turn, possibly debited by compaction) still stands.
|
||||
this.restoreUnsentTurn(displayLenAfterUser, modelLenAfterUser, sentInstructions, sentPastes)
|
||||
// When the user cancelled with a message queued, that message is
|
||||
// about to auto-send (see the flush below) — drop the rolled-back
|
||||
// prompt instead of restoring it to the input so the handoff is clean.
|
||||
const willAutoSendQueued = this.wasCancelledByUser() && !!this.queuedMessage
|
||||
this.restoreUnsentTurn(
|
||||
displayLenAfterUser,
|
||||
modelLenAfterUser,
|
||||
sentInstructions,
|
||||
sentPastes,
|
||||
!willAutoSendQueued
|
||||
)
|
||||
if (this.displayMessages.length === 0) {
|
||||
// saveChat no-ops on an empty transcript; the chat persisted earlier
|
||||
// this turn would linger in history and resurface the rolled-back
|
||||
@@ -1340,6 +1405,10 @@ export class AIChatManager {
|
||||
this.acceptPendingFlowEdits()
|
||||
}
|
||||
await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage)
|
||||
// Only this branch is a clean send: the queued-message flush below
|
||||
// auto-sends the next message after it (set after saveChat so a
|
||||
// persistence failure falls through to the restore path instead).
|
||||
turnCommittedCleanly = true
|
||||
if (isFirstUserTurn && this.afterFirstTurnSaved) {
|
||||
void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => {
|
||||
console.error('AIChatManager afterFirstTurnSaved hook failed', e)
|
||||
@@ -1369,6 +1438,31 @@ export class AIChatManager {
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
// Flush the queued message. Send it after a cleanly committed turn OR a
|
||||
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
|
||||
// to move on, so it sends automatically. A genuine error, an
|
||||
// empty-response rollback, or a programmatic cancel (panel teardown,
|
||||
// save-and-clear) leaves it in place as a card so it isn't fired into a
|
||||
// failed or torn-down turn.
|
||||
if ((turnCommittedCleanly || this.wasCancelledByUser()) && this.queuedMessage) {
|
||||
const next = this.queuedMessage
|
||||
this.queuedMessage = ''
|
||||
const accepted = await this.sendRequest({ instructions: next })
|
||||
if (accepted === false) {
|
||||
// The auto-send bailed before becoming a turn (e.g. beforeSend
|
||||
// failed); keep it as the queued message instead of losing it.
|
||||
this.queuedMessage = next
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// True when the current turn's controller was aborted by a deliberate user
|
||||
// cancel (Esc / Stop), as opposed to a programmatic cancel (panel teardown,
|
||||
// save-and-clear) or no abort at all. Gates the queued-message auto-send.
|
||||
private wasCancelledByUser(): boolean {
|
||||
const signal = this.abortController?.signal
|
||||
return !!signal?.aborted && signal.reason === USER_CANCEL_REASON
|
||||
}
|
||||
|
||||
cancel = (reason?: string) => {
|
||||
@@ -1380,7 +1474,7 @@ export class AIChatManager {
|
||||
resolveQuestion(undefined)
|
||||
}
|
||||
this.userQuestionCallbacks.clear()
|
||||
const cancelReason = reason ?? 'user_cancelled'
|
||||
const cancelReason = reason ?? USER_CANCEL_REASON
|
||||
console.log('cancelling request:', {
|
||||
reason: cancelReason,
|
||||
abortController: this.abortController
|
||||
@@ -1460,6 +1554,9 @@ export class AIChatManager {
|
||||
|
||||
saveAndClear = async () => {
|
||||
this.cancel('saveAndClear')
|
||||
// Drop any message queued in this conversation so it can't auto-send into
|
||||
// the fresh chat or linger as a card across the switch.
|
||||
this.queuedMessage = ''
|
||||
await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage)
|
||||
this.displayMessages = []
|
||||
this.messages = []
|
||||
@@ -1469,6 +1566,9 @@ export class AIChatManager {
|
||||
loadPastChat = async (id: string) => {
|
||||
const chat = this.historyManager.loadPastChat(id)
|
||||
if (chat) {
|
||||
// Drop any message queued in the current conversation so it doesn't
|
||||
// auto-send into the loaded one or linger as a card across the switch.
|
||||
this.queuedMessage = ''
|
||||
this.displayMessages = chat.displayMessages
|
||||
this.messages = chat.actualMessages
|
||||
this.contextUsage = normalizeContextUsage(chat.contextUsage)
|
||||
|
||||
@@ -315,6 +315,205 @@ describe('AIChatManager persisted autonomy default', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager queued messages', () => {
|
||||
const model = { provider: 'openai', model: 'gpt-4o' }
|
||||
|
||||
// The turn-outcome handling rolls back turns with no usable output, so a
|
||||
// "successful" send must produce a reply to take the clean-commit path
|
||||
// (which is what gates the queued-message auto-send).
|
||||
const replyWith = (reply: string) =>
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
const message = { role: 'assistant' as const, content: reply }
|
||||
config.addedMessages?.push(message)
|
||||
return {
|
||||
addedMessages: [message],
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mocks.getCurrentModel.mockReturnValue(model)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
})
|
||||
|
||||
function createInputMock() {
|
||||
return {
|
||||
prependText: vi.fn(),
|
||||
restoreInstructions: vi.fn(),
|
||||
focusInput: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createManager(input?: ReturnType<typeof createInputMock>) {
|
||||
const manager = new AIChatManager()
|
||||
manager.mode = AIMode.NAVIGATOR
|
||||
if (input) {
|
||||
manager.setAiChatInput(input as unknown as Parameters<typeof manager.setAiChatInput>[0])
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
it('queues a single trimmed message and ignores blank input', () => {
|
||||
const manager = createManager()
|
||||
manager.queueMessage(' first ')
|
||||
manager.queueMessage(' ')
|
||||
expect(manager.queuedMessage).toBe('first')
|
||||
})
|
||||
|
||||
it('appends additional lines to the single queued message', () => {
|
||||
const manager = createManager()
|
||||
manager.queueMessage('first line')
|
||||
manager.queueMessage('second line')
|
||||
expect(manager.queuedMessage).toBe('first line\nsecond line')
|
||||
})
|
||||
|
||||
it('dequeues the message and restores it into the input', () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
manager.queuedMessage = 'line one\nline two'
|
||||
|
||||
manager.dequeueMessage()
|
||||
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
expect(input.prependText).toHaveBeenCalledWith('line one\nline two')
|
||||
})
|
||||
|
||||
it('re-queues instead of dropping when the input is unmounted', () => {
|
||||
const manager = createManager()
|
||||
manager.queuedMessage = 'keep me'
|
||||
|
||||
manager.dequeueMessage()
|
||||
|
||||
// no input to restore into → the message stays queued
|
||||
expect(manager.queuedMessage).toBe('keep me')
|
||||
})
|
||||
|
||||
it('auto-sends the queued message on a clean completion', async () => {
|
||||
replyWith('done')
|
||||
const manager = createManager(createInputMock())
|
||||
|
||||
manager.queuedMessage = 'followup'
|
||||
await manager.sendRequest({ instructions: 'first' })
|
||||
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(2)
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
const userMessages = manager.displayMessages
|
||||
.filter((m) => m.role === 'user')
|
||||
.map((m) => m.content)
|
||||
expect(userMessages).toEqual(['first', 'followup'])
|
||||
})
|
||||
|
||||
it('keeps the queued message as a card (not flushed to input) when the turn errors', async () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
mocks.runChatLoop.mockRejectedValue(new Error('provider down'))
|
||||
|
||||
manager.queuedMessage = 'followup'
|
||||
await manager.sendRequest({ instructions: 'first' })
|
||||
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
// stays a card, nothing flushed into the input
|
||||
expect(manager.queuedMessage).toBe('followup')
|
||||
expect(input.prependText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-sends the queued message when the user cancels the turn (Esc/Stop)', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
// the followup turn completes cleanly...
|
||||
replyWith('done')
|
||||
// ...but the first turn is cancelled by the user
|
||||
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
|
||||
abortController.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
manager.queuedMessage = 'followup'
|
||||
await manager.sendRequest({ instructions: 'first' })
|
||||
|
||||
// cancel sends the queued message automatically
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
const userMessages = manager.displayMessages
|
||||
.filter((m) => m.role === 'user')
|
||||
.map((m) => m.content)
|
||||
expect(userMessages).toContain('followup')
|
||||
})
|
||||
|
||||
it('does NOT auto-send on a programmatic cancel (e.g. save-and-clear / teardown)', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
replyWith('done')
|
||||
// the turn is aborted programmatically, not by the user pressing Esc/Stop
|
||||
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
|
||||
abortController.abort('saveAndClear')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
manager.queuedMessage = 'followup'
|
||||
await manager.sendRequest({ instructions: 'first' })
|
||||
|
||||
// a non-user abort must not fire the queued message; it stays a card
|
||||
expect(manager.queuedMessage).toBe('followup')
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not restore the cancelled prompt to the input when a queued message takes over', async () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
replyWith('done')
|
||||
// cancel before any usable output → the rollback (restoreUnsentTurn) path
|
||||
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
|
||||
abortController.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
manager.queuedMessage = 'followup'
|
||||
await manager.sendRequest({ instructions: 'the long cancelled prompt' })
|
||||
|
||||
// clean handoff: queued message sent, cancelled prompt NOT shoved back in
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
expect(input.restoreInstructions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-queues the message when its auto-send is rejected by beforeSend', async () => {
|
||||
replyWith('done')
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
// first turn goes through, the queued auto-send is rejected
|
||||
manager.beforeSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('workspace commit failed'))
|
||||
|
||||
manager.queuedMessage = 'followup'
|
||||
await manager.sendRequest({ instructions: 'first' })
|
||||
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
// the rejected message stays a card rather than being lost or moved to input
|
||||
expect(manager.queuedMessage).toBe('followup')
|
||||
expect(input.prependText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the queued message when switching conversations (no cross-chat leak)', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
|
||||
manager.queuedMessage = 'meant for chat A'
|
||||
await manager.saveAndClear()
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
|
||||
manager.queuedMessage = 'still meant for chat A'
|
||||
vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({
|
||||
id: 'chat-b',
|
||||
title: 'Chat B',
|
||||
displayMessages: [],
|
||||
actualMessages: [],
|
||||
lastModified: 0
|
||||
} as unknown as ReturnType<typeof manager.historyManager.loadPastChat>)
|
||||
await manager.loadPastChat('chat-b')
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager context compaction', () => {
|
||||
// claude-sonnet-4-6 resolves to a known 1M window (modelConfig is
|
||||
// unmocked): compaction triggers at a projected 800k and drops head
|
||||
@@ -708,7 +907,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
vi.mocked(runChatLoop).mockImplementation(async (config) => {
|
||||
config.callbacks.onNewToken('Here is the partial ')
|
||||
config.callbacks.onNewToken('answer')
|
||||
config.abortController.abort('user_cancelled')
|
||||
config.abortController.abort()
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
@@ -733,7 +932,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
vi.mocked(runChatLoop).mockImplementation(async (config) => {
|
||||
config.callbacks.onReasoningStart?.()
|
||||
config.callbacks.onReasoningDelta?.('still thinking...')
|
||||
config.abortController.abort('user_cancelled')
|
||||
config.abortController.abort()
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
@@ -764,7 +963,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
vi.mocked(runChatLoop).mockImplementation(async (config) => {
|
||||
config.callbacks.onNewToken('Partial from Claude')
|
||||
config.callbacks.onMessageEnd()
|
||||
config.abortController.abort('user_cancelled')
|
||||
config.abortController.abort()
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
@@ -790,7 +989,7 @@ describe('AIChatManager sendRequest lifecycle', () => {
|
||||
config.callbacks.onNewToken('The full answer')
|
||||
config.addedMessages!.push({ role: 'assistant', content: 'The full answer' })
|
||||
config.callbacks.onMessageEnd()
|
||||
config.abortController.abort('user_cancelled')
|
||||
config.abortController.abort()
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { X } from 'lucide-svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
|
||||
// The single message typed while a turn was streaming, waiting to be
|
||||
// auto-sent when the turn finishes. Rendered above the whole input stack
|
||||
// (session bars, context badges, textarea) so it reads as "next in the
|
||||
// conversation". Pressing Enter again appends another line to it; the X
|
||||
// removes it and restores its text into the input so nothing is lost.
|
||||
const aiChatManager = getAiChatManager()
|
||||
</script>
|
||||
|
||||
{#if aiChatManager.queuedMessage}
|
||||
<div
|
||||
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60"
|
||||
title={aiChatManager.queuedMessage}
|
||||
>
|
||||
<div class="min-w-0 grow">
|
||||
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
|
||||
{aiChatManager.queuedMessage}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
title="Remove queued message and put it back in the input"
|
||||
startIcon={{ icon: X }}
|
||||
on:click={() => aiChatManager.dequeueMessage()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from 'openai/resources/index.mjs'
|
||||
import type { Tool } from '../shared'
|
||||
import { loadApiTools } from './apiTools'
|
||||
import { getDocumentationTool } from '../navigator/core'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
@@ -14,13 +13,13 @@ You are Windmill's intelligent assistant, designed to interact with the platform
|
||||
Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications.
|
||||
|
||||
You have access to these tools:
|
||||
1. Get documentation for user requests (get_documentation)
|
||||
1. Search the documentation (search_docs) and read a documentation page (read_docs_page)
|
||||
2. A comprehensive list of API endpoints to interact with the Windmill backend
|
||||
|
||||
INSTRUCTIONS:
|
||||
- You can directly query, list, create, update, and delete various Windmill resources like scripts, flows, jobs, resources, variables, schedules, and workers through the provided API tools.
|
||||
- When users ask about specific data or want to perform operations, use the appropriate API endpoints to fulfill their requests.
|
||||
- Use get_documentation to retrieve accurate information about features, concepts, and best practices when needed.
|
||||
- Use search_docs (then read_docs_page on a returned Source URL) to retrieve accurate information about features, concepts, and best practices when needed.
|
||||
- Always present API results in a clear, readable format for the user.
|
||||
- If you need to make multiple related API calls to fulfill a request, do so systematically and explain what you're doing.
|
||||
- When showing lists of items, provide meaningful summaries rather than overwhelming the user with raw data.
|
||||
@@ -55,8 +54,6 @@ export async function getApiTools(): Promise<Tool<{}>[]> {
|
||||
return apiToolsCache
|
||||
}
|
||||
|
||||
export const apiTools: Tool<{}>[] = [getDocumentationTool]
|
||||
|
||||
export function prepareApiSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam {
|
||||
let content = CHAT_SYSTEM_PROMPT(get(userStore)?.username ?? '')
|
||||
|
||||
|
||||
@@ -3,19 +3,23 @@ import type {
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/index.mjs'
|
||||
import type { Tool } from '../shared'
|
||||
import { getDocumentationTool } from '../navigator/core'
|
||||
import { readDocsPageTool, searchDocsTool } from '../docs/core'
|
||||
|
||||
export const CHAT_SYSTEM_PROMPT = `
|
||||
You are Windmill's intelligent assistant, designed to answer questions about its functionality. It is your only purpose to help the user in the context of the windmill application.
|
||||
Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications.
|
||||
|
||||
You have access to these tools:
|
||||
1. Get documentation for user requests (get_documentation)
|
||||
1. Search the documentation (search_docs)
|
||||
2. Read a documentation page (read_docs_page)
|
||||
|
||||
INSTRUCTIONS:
|
||||
- When user asks about something, use the get_documentation tool to retrieve accurate information about how to fulfill the user's request.
|
||||
- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.
|
||||
- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team.
|
||||
- Call search_docs FIRST with a few distinctive keywords from the user's question to find the most relevant documentation pages and matching snippets.
|
||||
- If the snippets already answer the question, answer directly. Otherwise call read_docs_page with one of the returned Source URLs to read the full page; if read_docs_page returns a list of section headings, call it again with the same path and a \`section\` argument to read the relevant section.
|
||||
- If the first search returns nothing useful, retry with different or broader keywords before giving up.
|
||||
- Answer based ONLY on what you find in the documentation. Do not invent features, flags, syntax, or behavior that you did not see in the docs.
|
||||
- Always include the documentation URL(s) you consulted in your answer. Cite the exact "Source" URL shown in the search results (or the "Source page" URL at the top of a read page) — never reconstruct a URL from a link inside the page body.
|
||||
- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team.
|
||||
|
||||
GENERAL PRINCIPLES:
|
||||
- Be concise but thorough
|
||||
@@ -23,7 +27,7 @@ GENERAL PRINCIPLES:
|
||||
- If you encounter an error or can't complete a request, explain why and suggest alternatives
|
||||
`
|
||||
|
||||
export const askTools: Tool<{}>[] = [getDocumentationTool]
|
||||
export const askTools: Tool<{}>[] = [searchDocsTool, readDocsPageTool]
|
||||
|
||||
export function prepareAskSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam {
|
||||
let content = CHAT_SYSTEM_PROMPT
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildDocsOutline,
|
||||
canonicalDocsPageUrl,
|
||||
extractDocsSection,
|
||||
formatDocsSearchResults,
|
||||
makeSnippet,
|
||||
mergeDocsSearchResults,
|
||||
normalizeDocsUrl,
|
||||
parseDocsFullText,
|
||||
parseDocsHeadings,
|
||||
parseDocsIndex,
|
||||
renderDocsPageResult,
|
||||
sanitizeDocsMarkdownLinks,
|
||||
searchDocsIndex,
|
||||
searchDocsPages
|
||||
} from './core'
|
||||
|
||||
const SAMPLE = `# Jobs
|
||||
|
||||
Intro text about jobs.
|
||||
|
||||
## Job kinds
|
||||
|
||||
Some kinds.
|
||||
|
||||
## Result
|
||||
|
||||
### Result of jobs that failed
|
||||
|
||||
\`\`\`
|
||||
{ "error": "boom" }
|
||||
\`\`\`
|
||||
|
||||
### Result streaming
|
||||
|
||||
#### Returning a stream directly
|
||||
|
||||
\`\`\`python
|
||||
# Returning a stream directly is a comment heading that must be ignored
|
||||
def main():
|
||||
pass
|
||||
\`\`\`
|
||||
|
||||
## Retention policy
|
||||
|
||||
Final section.
|
||||
`
|
||||
|
||||
describe('parseDocsHeadings', () => {
|
||||
it('parses headings with their levels and ignores headings inside fenced code blocks', () => {
|
||||
const headings = parseDocsHeadings(SAMPLE)
|
||||
const titles = headings.map((h) => `${h.level}:${h.title}`)
|
||||
|
||||
expect(titles).toEqual([
|
||||
'1:Jobs',
|
||||
'2:Job kinds',
|
||||
'2:Result',
|
||||
'3:Result of jobs that failed',
|
||||
'3:Result streaming',
|
||||
'4:Returning a stream directly',
|
||||
'2:Retention policy'
|
||||
])
|
||||
// The "# Returning a stream directly is a comment..." line inside the
|
||||
// python fence must not be parsed as a heading.
|
||||
expect(titles).not.toContain('1:Returning a stream directly is a comment heading that must be ignored')
|
||||
})
|
||||
|
||||
it('returns startIndex offsets that point at the heading line', () => {
|
||||
const headings = parseDocsHeadings(SAMPLE)
|
||||
for (const heading of headings) {
|
||||
expect(SAMPLE.slice(heading.startIndex)).toMatch(
|
||||
new RegExp(`^#{${heading.level}}\\s+${heading.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles tilde fences', () => {
|
||||
const content = '# Title\n\n~~~\n# not a heading\n~~~\n\n## Real\n'
|
||||
const headings = parseDocsHeadings(content)
|
||||
expect(headings.map((h) => h.title)).toEqual(['Title', 'Real'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractDocsSection', () => {
|
||||
it('extracts a section from its heading up to the next same-or-higher level heading', () => {
|
||||
const section = extractDocsSection(SAMPLE, 'Result')
|
||||
expect(section).toBeDefined()
|
||||
expect(section).toContain('## Result')
|
||||
expect(section).toContain('### Result of jobs that failed')
|
||||
expect(section).toContain('### Result streaming')
|
||||
// Stops before the next level-2 heading.
|
||||
expect(section).not.toContain('## Retention policy')
|
||||
})
|
||||
|
||||
it('matches case-insensitively and tolerates punctuation differences', () => {
|
||||
const section = extractDocsSection(SAMPLE, 'retention-policy!')
|
||||
expect(section).toBeDefined()
|
||||
expect(section).toContain('## Retention policy')
|
||||
expect(section).toContain('Final section.')
|
||||
})
|
||||
|
||||
it('returns the deepest section bounded by the next same-level heading', () => {
|
||||
const section = extractDocsSection(SAMPLE, 'Result streaming')
|
||||
expect(section).toBeDefined()
|
||||
expect(section).toContain('### Result streaming')
|
||||
expect(section).toContain('#### Returning a stream directly')
|
||||
expect(section).not.toContain('## Retention policy')
|
||||
})
|
||||
|
||||
it('returns undefined when no heading matches', () => {
|
||||
expect(extractDocsSection(SAMPLE, 'Nonexistent section')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDocsOutline', () => {
|
||||
it('lists headings with approximate per-section sizes and indentation', () => {
|
||||
const outline = buildDocsOutline(SAMPLE)
|
||||
expect(outline).toContain('- Jobs (~')
|
||||
expect(outline).toContain(' - Job kinds (~')
|
||||
expect(outline).toContain(' - Result of jobs that failed (~')
|
||||
})
|
||||
|
||||
it('handles pages with no headings', () => {
|
||||
expect(buildDocsOutline('just some text\nwith no headings')).toBe(
|
||||
'(no markdown headings found on this page)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeDocsUrl', () => {
|
||||
it('appends .md to a bare path', () => {
|
||||
expect(normalizeDocsUrl('/docs/core_concepts/jobs')).toBe(
|
||||
'https://www.windmill.dev/docs/core_concepts/jobs.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a path without a leading slash', () => {
|
||||
expect(normalizeDocsUrl('docs/core_concepts/jobs')).toBe(
|
||||
'https://www.windmill.dev/docs/core_concepts/jobs.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a full URL and strips anchors and query strings', () => {
|
||||
expect(
|
||||
normalizeDocsUrl('https://www.windmill.dev/docs/core_concepts/jobs#result?foo=bar')
|
||||
).toBe('https://www.windmill.dev/docs/core_concepts/jobs.md')
|
||||
})
|
||||
|
||||
it('does not double-append .md', () => {
|
||||
expect(normalizeDocsUrl('/docs/core_concepts/jobs.md')).toBe(
|
||||
'https://www.windmill.dev/docs/core_concepts/jobs.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a trailing slash before appending .md', () => {
|
||||
expect(normalizeDocsUrl('/docs/core_concepts/jobs/')).toBe(
|
||||
'https://www.windmill.dev/docs/core_concepts/jobs.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips docusaurus numeric ordering prefixes from path segments', () => {
|
||||
expect(normalizeDocsUrl('/docs/flows/13_flow_branches')).toBe(
|
||||
'https://www.windmill.dev/docs/flows/flow_branches.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('converts a .mdx source suffix to .md', () => {
|
||||
expect(normalizeDocsUrl('/docs/flows/13_flow_branches.mdx')).toBe(
|
||||
'https://www.windmill.dev/docs/flows/flow_branches.md'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeDocsMarkdownLinks', () => {
|
||||
const PAGE = 'https://www.windmill.dev/docs/flows/flow_editor.md'
|
||||
|
||||
it('rewrites a relative .mdx source link to a canonical published URL', () => {
|
||||
expect(sanitizeDocsMarkdownLinks('See [retries](./14_retries.mdx) for more.', PAGE)).toBe(
|
||||
'See [retries](https://www.windmill.dev/docs/flows/retries) for more.'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips numeric prefixes from same-directory links', () => {
|
||||
expect(sanitizeDocsMarkdownLinks('[handling](./8_error_handling.mdx)', PAGE)).toBe(
|
||||
'[handling](https://www.windmill.dev/docs/flows/error_handling)'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves anchors when rewriting', () => {
|
||||
expect(sanitizeDocsMarkdownLinks('[branch all](./13_flow_branches.mdx#branch-all)', PAGE)).toBe(
|
||||
'[branch all](https://www.windmill.dev/docs/flows/flow_branches#branch-all)'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves image and external links untouched', () => {
|
||||
const input =
|
||||
' and [site](https://example.com/page.md)'
|
||||
expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves bare anchor links untouched', () => {
|
||||
expect(sanitizeDocsMarkdownLinks('[top](#introduction)', PAGE)).toBe('[top](#introduction)')
|
||||
})
|
||||
|
||||
// `../` links are authored against the docusaurus source tree, whose directory
|
||||
// depth differs from the published URL on slug-flattened pages, so resolving
|
||||
// them against the page URL is unreliable (a single `../` can over-escape just
|
||||
// as a double one does). All `../` links are left untouched and disambiguated
|
||||
// by the canonical "Source page" header instead.
|
||||
it('leaves single ../ cross-directory links untouched', () => {
|
||||
const input = '[handling](../core_concepts/8_error_handling.mdx)'
|
||||
expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input)
|
||||
})
|
||||
|
||||
it('leaves double ../../ cross-directory links untouched', () => {
|
||||
const input = '[retries](../../flows/14_retries.md)'
|
||||
expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canonicalDocsPageUrl', () => {
|
||||
it('returns the published URL without the .md suffix', () => {
|
||||
expect(canonicalDocsPageUrl('/docs/flows/flow_editor')).toBe(
|
||||
'https://www.windmill.dev/docs/flows/flow_editor'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips numeric prefixes so a source-style path maps to the published URL', () => {
|
||||
expect(canonicalDocsPageUrl('/docs/flows/14_retries.md')).toBe(
|
||||
'https://www.windmill.dev/docs/flows/retries'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDocsPageResult', () => {
|
||||
it('returns the whole page when small and no section requested', () => {
|
||||
expect(renderDocsPageResult(SAMPLE)).toBe(SAMPLE)
|
||||
})
|
||||
|
||||
it('returns an outline for large pages with no section requested', () => {
|
||||
const large = `# Big\n\n${'x'.repeat(25_000)}\n\n## Tail\n\nmore`
|
||||
const result = renderDocsPageResult(large)
|
||||
expect(result).toContain('This documentation page is large')
|
||||
expect(result).toContain('- Big (~')
|
||||
expect(result).toContain('- Tail (~')
|
||||
})
|
||||
|
||||
it('returns the requested section content when found', () => {
|
||||
const result = renderDocsPageResult(SAMPLE, 'Job kinds')
|
||||
expect(result).toContain('## Job kinds')
|
||||
expect(result).toContain('Some kinds.')
|
||||
})
|
||||
|
||||
it('returns the outline with a note when the requested section is missing', () => {
|
||||
const result = renderDocsPageResult(SAMPLE, 'Does not exist')
|
||||
expect(result).toContain('No section matching "Does not exist" was found')
|
||||
expect(result).toContain('- Jobs (~')
|
||||
})
|
||||
})
|
||||
|
||||
// Mirrors the llms-full.txt layout: a corpus preamble, then per-page blocks each
|
||||
// introduced by a `---` + `## <Category>` lead-in followed by a `Source:` line.
|
||||
const SAMPLE_FULL = `# Windmill
|
||||
|
||||
> Preamble blurb that precedes the first Source line and must be ignored.
|
||||
|
||||
## Browser automation
|
||||
|
||||
Source: https://www.windmill.dev/docs/advanced/browser_automation
|
||||
|
||||
# Browser automation
|
||||
|
||||
By default, a worker group named \`reports\` handles jobs with the \`chromium\` tag.
|
||||
The chromium binary will be available on these workers at /usr/bin/chromium.
|
||||
You can disable the sandbox by passing the --no-sandbox flag.
|
||||
|
||||
---
|
||||
|
||||
## Worker groups
|
||||
|
||||
Source: https://www.windmill.dev/docs/core_concepts/worker_groups
|
||||
|
||||
# Worker groups
|
||||
|
||||
Worker groups let you assign tags to workers.
|
||||
Set the chromium tag on a worker so it can run browser jobs.
|
||||
|
||||
---
|
||||
|
||||
## Scheduling
|
||||
|
||||
Source: https://www.windmill.dev/docs/core_concepts/scheduling
|
||||
|
||||
# Scheduling
|
||||
|
||||
Use cron expressions to schedule scripts and flows.
|
||||
`
|
||||
|
||||
describe('parseDocsFullText', () => {
|
||||
it('splits the corpus into pages keyed by Source URL, dropping the preamble', () => {
|
||||
const pages = parseDocsFullText(SAMPLE_FULL)
|
||||
expect(pages.map((p) => p.url)).toEqual([
|
||||
'https://www.windmill.dev/docs/advanced/browser_automation',
|
||||
'https://www.windmill.dev/docs/core_concepts/worker_groups',
|
||||
'https://www.windmill.dev/docs/core_concepts/scheduling'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses each page first heading as its title', () => {
|
||||
const pages = parseDocsFullText(SAMPLE_FULL)
|
||||
expect(pages.map((p) => p.title)).toEqual([
|
||||
'Browser automation',
|
||||
'Worker groups',
|
||||
'Scheduling'
|
||||
])
|
||||
})
|
||||
|
||||
it('strips the trailing category lead-in so it is not mis-attributed to the previous page', () => {
|
||||
const pages = parseDocsFullText(SAMPLE_FULL)
|
||||
const browser = pages.find((p) => p.url.endsWith('/browser_automation'))
|
||||
// "## Worker groups" introduces the *next* page and must not leak into this body.
|
||||
expect(browser?.body).not.toContain('Worker groups')
|
||||
expect(browser?.body).not.toContain('---')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchDocsPages', () => {
|
||||
const pages = parseDocsFullText(SAMPLE_FULL)
|
||||
|
||||
it('ranks the page with more occurrences of the term first', () => {
|
||||
const results = searchDocsPages(pages, 'chromium')
|
||||
expect(results.map((r) => r.url)).toEqual([
|
||||
'https://www.windmill.dev/docs/advanced/browser_automation',
|
||||
'https://www.windmill.dev/docs/core_concepts/worker_groups'
|
||||
])
|
||||
expect(results[0].snippets.length).toBeGreaterThan(0)
|
||||
expect(results[0].snippets.join('\n')).toContain('chromium')
|
||||
})
|
||||
|
||||
it('prefers pages that cover every query term over partial matches', () => {
|
||||
// Only browser_automation mentions both "chromium" and "sandbox".
|
||||
const results = searchDocsPages(pages, 'chromium sandbox')
|
||||
expect(results.map((r) => r.url)).toEqual([
|
||||
'https://www.windmill.dev/docs/advanced/browser_automation'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns nothing when no term matches', () => {
|
||||
expect(searchDocsPages(pages, 'kubernetes helm chart')).toEqual([])
|
||||
})
|
||||
|
||||
it('respects the maxPages cap', () => {
|
||||
const results = searchDocsPages(pages, 'worker', { maxPages: 1 })
|
||||
expect(results.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeSnippet', () => {
|
||||
it('returns short lines unchanged after collapsing whitespace', () => {
|
||||
expect(makeSnippet(' hello world ', ['world'], 200)).toBe('hello world')
|
||||
})
|
||||
|
||||
it('windows a long line around the first matched term with ellipses', () => {
|
||||
const line = `${'a '.repeat(200)}NEEDLE${' b'.repeat(200)}`
|
||||
const snippet = makeSnippet(line, ['needle'], 60)
|
||||
expect(snippet.length).toBeLessThanOrEqual(62) // 60 + two ellipsis chars
|
||||
expect(snippet.toLowerCase()).toContain('needle')
|
||||
expect(snippet.startsWith('…')).toBe(true)
|
||||
expect(snippet.endsWith('…')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDocsSearchResults', () => {
|
||||
it('renders Source URLs, snippet bullets and a citation instruction', () => {
|
||||
const results = searchDocsPages(parseDocsFullText(SAMPLE_FULL), 'chromium')
|
||||
const rendered = formatDocsSearchResults('chromium', results)
|
||||
expect(rendered).toContain('Source: https://www.windmill.dev/docs/advanced/browser_automation')
|
||||
expect(rendered).toContain(' - ')
|
||||
expect(rendered).toContain('Cite the exact "Source" URL')
|
||||
})
|
||||
|
||||
it('returns a no-match message when there are no results', () => {
|
||||
expect(formatDocsSearchResults('zzz', [])).toContain('No documentation pages matched "zzz"')
|
||||
})
|
||||
})
|
||||
|
||||
const SAMPLE_INDEX = `# Windmill
|
||||
|
||||
> Blurb.
|
||||
|
||||
## Documentation structure
|
||||
|
||||
### Core concepts
|
||||
- [AI agents](https://www.windmill.dev/docs/core_concepts/ai_agents.md): How do I build AI agents in Windmill? Add agent steps to flows. Connect to OpenAI, Anthropic and more.
|
||||
- [Retries](https://www.windmill.dev/docs/flows/retries.md): How do I retry a failing flow step automatically with exponential backoff?
|
||||
- [Persistent storage](https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill.md): How do I persist state between runs in Windmill?
|
||||
`
|
||||
|
||||
describe('parseDocsIndex', () => {
|
||||
it('parses index entries into title, url and description', () => {
|
||||
const entries = parseDocsIndex(SAMPLE_INDEX)
|
||||
expect(entries).toHaveLength(3)
|
||||
expect(entries[0]).toEqual({
|
||||
title: 'AI agents',
|
||||
url: 'https://www.windmill.dev/docs/core_concepts/ai_agents.md',
|
||||
description:
|
||||
'How do I build AI agents in Windmill? Add agent steps to flows. Connect to OpenAI, Anthropic and more.'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores lines that are not docs links', () => {
|
||||
expect(parseDocsIndex('## Heading\n> blurb\nplain text')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchDocsIndex', () => {
|
||||
const entries = parseDocsIndex(SAMPLE_INDEX)
|
||||
|
||||
it('surfaces a named feature from its title/description when body grep would miss it', () => {
|
||||
// The branch-centric phrasing a model used that failed body search; the
|
||||
// index entry still matches on "agent"/"LLM"-adjacent terms.
|
||||
const results = searchDocsIndex(entries, 'AI agent step decide')
|
||||
expect(results[0].url).toBe('https://www.windmill.dev/docs/core_concepts/ai_agents.md')
|
||||
expect(results[0].snippets[0]).toContain('agent steps')
|
||||
})
|
||||
|
||||
it('ranks title matches above description-only matches', () => {
|
||||
const results = searchDocsIndex(entries, 'retries')
|
||||
expect(results[0].url).toBe('https://www.windmill.dev/docs/flows/retries.md')
|
||||
})
|
||||
|
||||
it('returns nothing when no term matches', () => {
|
||||
expect(searchDocsIndex(entries, 'kubernetes helm')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeDocsSearchResults', () => {
|
||||
const body: ReturnType<typeof searchDocsPages> = [
|
||||
{ url: 'https://www.windmill.dev/docs/openflow', title: 'OpenFlow', score: 10, snippets: ['x'] }
|
||||
]
|
||||
const index: ReturnType<typeof searchDocsIndex> = [
|
||||
// Same page as a body hit but as the index `.md` URL — must dedupe.
|
||||
{
|
||||
url: 'https://www.windmill.dev/docs/openflow.md',
|
||||
title: 'OpenFlow',
|
||||
score: 5,
|
||||
snippets: ['desc']
|
||||
},
|
||||
{ url: 'https://www.windmill.dev/docs/flows/retries.md', title: 'Retries', score: 4, snippets: ['desc'] }
|
||||
]
|
||||
|
||||
it('keeps body results first and appends index-only matches, deduping by canonical URL', () => {
|
||||
const merged = mergeDocsSearchResults(body, index)
|
||||
expect(merged.map((r) => r.url)).toEqual([
|
||||
'https://www.windmill.dev/docs/openflow',
|
||||
'https://www.windmill.dev/docs/flows/retries.md'
|
||||
])
|
||||
})
|
||||
|
||||
it('respects the maxPages cap', () => {
|
||||
expect(mergeDocsSearchResults(body, index, 1)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,863 @@
|
||||
import type { Tool } from '../shared'
|
||||
import type { ChatCompletionTool } from 'openai/resources/index.mjs'
|
||||
|
||||
const DOCS_ORIGIN = 'https://www.windmill.dev'
|
||||
const LLMS_TXT_URL = `${DOCS_ORIGIN}/llms.txt`
|
||||
const LLMS_FULL_TXT_URL = `${DOCS_ORIGIN}/llms-full.txt`
|
||||
const CACHE_TTL_MS = 15 * 60 * 1000
|
||||
// Above this size, return an outline of the page's headings instead of the full
|
||||
// content, prompting the model to request a specific section.
|
||||
const FULL_PAGE_CHAR_LIMIT = 20_000
|
||||
|
||||
// search_docs result caps — keep the returned payload small (the whole point of
|
||||
// search vs. dumping the index or full pages is token economy).
|
||||
const SEARCH_MAX_PAGES = 8
|
||||
const SEARCH_MAX_SNIPPETS_PER_PAGE = 3
|
||||
const SEARCH_MAX_SNIPPET_CHARS = 200
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number
|
||||
promise: Promise<string>
|
||||
}
|
||||
|
||||
let llmsTxtCache: CacheEntry | undefined
|
||||
let llmsFullTxtCache: CacheEntry | undefined
|
||||
const pageCache = new Map<string, CacheEntry>()
|
||||
|
||||
/**
|
||||
* Fetches the docs index (llms.txt) listing every documentation page. Cached at
|
||||
* module level with a TTL so repeated tool calls within a session reuse it.
|
||||
*/
|
||||
export async function fetchDocsIndex(): Promise<string> {
|
||||
const now = Date.now()
|
||||
if (llmsTxtCache && llmsTxtCache.expiresAt > now) {
|
||||
return llmsTxtCache.promise
|
||||
}
|
||||
|
||||
const promise = fetchText(LLMS_TXT_URL).catch((error) => {
|
||||
// Drop the failed promise from the cache so the next call retries.
|
||||
if (llmsTxtCache?.promise === promise) {
|
||||
llmsTxtCache = undefined
|
||||
}
|
||||
throw error
|
||||
})
|
||||
llmsTxtCache = { expiresAt: now + CACHE_TTL_MS, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the full documentation corpus (llms-full.txt): every page concatenated
|
||||
* into one document, each delimited by a `Source: <url>` line. ~2 MB. Cached at
|
||||
* module level with a TTL. Mirrors fetchDocsIndex; used by search_docs to grep
|
||||
* the whole corpus in a single fetch.
|
||||
*/
|
||||
export async function fetchDocsFullText(): Promise<string> {
|
||||
const now = Date.now()
|
||||
if (llmsFullTxtCache && llmsFullTxtCache.expiresAt > now) {
|
||||
return llmsFullTxtCache.promise
|
||||
}
|
||||
|
||||
const promise = fetchText(LLMS_FULL_TXT_URL).catch((error) => {
|
||||
if (llmsFullTxtCache?.promise === promise) {
|
||||
llmsFullTxtCache = undefined
|
||||
}
|
||||
throw error
|
||||
})
|
||||
llmsFullTxtCache = { expiresAt: now + CACHE_TTL_MS, promise }
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single docs page as raw markdown. `path` may be a full URL or a
|
||||
* /docs/... path; it is normalized to a `.md` URL. Cached per resolved URL.
|
||||
*/
|
||||
export async function fetchDocsPage(path: string): Promise<string> {
|
||||
const url = normalizeDocsUrl(path)
|
||||
const now = Date.now()
|
||||
const cached = pageCache.get(url)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise
|
||||
}
|
||||
|
||||
const promise = fetchText(url)
|
||||
.then((content) => sanitizeDocsMarkdownLinks(content, url))
|
||||
.catch((error) => {
|
||||
if (pageCache.get(url)?.promise === promise) {
|
||||
pageCache.delete(url)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
pageCache.set(url, { expiresAt: now + CACHE_TTL_MS, promise })
|
||||
return promise
|
||||
}
|
||||
|
||||
async function fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request to ${url} failed with status ${response.status}`)
|
||||
}
|
||||
return await response.text()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a user/model-supplied docs reference to a fully-qualified `.md`
|
||||
* URL on the docs origin. Accepts:
|
||||
* - `https://www.windmill.dev/docs/core_concepts/jobs`
|
||||
* - `/docs/core_concepts/jobs.md`
|
||||
* - `docs/core_concepts/jobs`
|
||||
*/
|
||||
export function normalizeDocsUrl(input: string): string {
|
||||
let value = input.trim()
|
||||
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
// Strip the origin so we can re-anchor to DOCS_ORIGIN and normalize the path.
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
value = parsed.pathname
|
||||
} catch {
|
||||
// Fall through and treat as a path.
|
||||
}
|
||||
}
|
||||
|
||||
// Drop any query string or hash fragment.
|
||||
value = value.split('#')[0].split('?')[0]
|
||||
|
||||
if (!value.startsWith('/')) {
|
||||
value = `/${value}`
|
||||
}
|
||||
|
||||
// Strip a trailing slash (but keep the leading one).
|
||||
if (value.length > 1 && value.endsWith('/')) {
|
||||
value = value.slice(0, -1)
|
||||
}
|
||||
|
||||
// Relative links inside the raw markdown reference docusaurus source files
|
||||
// (e.g. `13_flow_branches.mdx`), but the published routes drop the numeric
|
||||
// ordering prefixes and use `.md`.
|
||||
value = stripDocsPathPrefixes(value)
|
||||
if (value.endsWith('.mdx')) {
|
||||
value = value.slice(0, -1)
|
||||
}
|
||||
|
||||
if (!value.endsWith('.md')) {
|
||||
value = `${value}.md`
|
||||
}
|
||||
|
||||
return `${DOCS_ORIGIN}${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical published URL a model should cite for a docs page (the `.md`
|
||||
* fetch URL without the suffix), e.g. `https://www.windmill.dev/docs/flows/retries`.
|
||||
*/
|
||||
export function canonicalDocsPageUrl(path: string): string {
|
||||
return normalizeDocsUrl(path).replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips docusaurus numeric ordering prefixes (`13_`, `8-`) from each segment of
|
||||
* a docs path so it matches the published route. Operates on the path only.
|
||||
*/
|
||||
function stripDocsPathPrefixes(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => segment.replace(/^\d+[_-]/, ''))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites relative/source-file doc links inside raw page markdown to canonical
|
||||
* published URLs, so the model never echoes a docusaurus source path (e.g.
|
||||
* `./13_flow_branches.mdx`) into its answer as a broken link. Resolves each link
|
||||
* relative to the page it came from, strips numeric ordering prefixes, and drops
|
||||
* the `.md`/`.mdx` extension. Non-doc links (external, images, anchors) are left
|
||||
* untouched.
|
||||
*/
|
||||
export function sanitizeDocsMarkdownLinks(content: string, pageUrl: string): string {
|
||||
return content.replace(/\]\(([^)\s]+?)(\s+"[^"]*")?\)/g, (match, target: string, title) => {
|
||||
if (!/\.mdx?($|[#?])/i.test(target)) {
|
||||
// Only rewrite links to docusaurus source files (.md/.mdx); leave
|
||||
// images, external URLs and bare anchors untouched.
|
||||
return match
|
||||
}
|
||||
if (/(^|\/)\.\.\//.test(target)) {
|
||||
// `../` cross-directory links are authored against the docusaurus
|
||||
// source tree, whose depth differs from the published URL, so strict
|
||||
// resolution is unreliable. Leave them for the canonical-URL header to
|
||||
// disambiguate rather than risk rewriting to a wrong path.
|
||||
return match
|
||||
}
|
||||
let resolved: URL
|
||||
try {
|
||||
resolved = new URL(target, pageUrl)
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
if (resolved.origin !== DOCS_ORIGIN || !resolved.pathname.startsWith('/docs/')) {
|
||||
return match
|
||||
}
|
||||
const pathname = stripDocsPathPrefixes(resolved.pathname).replace(/\.mdx?$/i, '')
|
||||
return `](${DOCS_ORIGIN}${pathname}${resolved.hash}${title ?? ''})`
|
||||
})
|
||||
}
|
||||
|
||||
export interface DocsHeading {
|
||||
level: number
|
||||
title: string
|
||||
/** Character offset of the start of the heading line within the document. */
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the markdown headings (`#`–`####`) of a docs page, ignoring any
|
||||
* heading-like lines that appear inside fenced code blocks (``` fences), which
|
||||
* are common in docs pages (e.g. `# comment` inside a python sample).
|
||||
*/
|
||||
export function parseDocsHeadings(content: string): DocsHeading[] {
|
||||
const headings: DocsHeading[] = []
|
||||
let offset = 0
|
||||
let inFence = false
|
||||
let fenceMarker = ''
|
||||
|
||||
const lines = content.split('\n')
|
||||
for (const line of lines) {
|
||||
const fence = matchFence(line)
|
||||
if (fence) {
|
||||
if (!inFence) {
|
||||
inFence = true
|
||||
fenceMarker = fence
|
||||
} else if (line.trimStart().startsWith(fenceMarker)) {
|
||||
inFence = false
|
||||
fenceMarker = ''
|
||||
}
|
||||
offset += line.length + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inFence) {
|
||||
const match = /^(#{1,4})\s+(.*\S)\s*$/.exec(line)
|
||||
if (match) {
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
title: match[2].trim(),
|
||||
startIndex: offset
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
offset += line.length + 1
|
||||
}
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
function matchFence(line: string): string | undefined {
|
||||
const trimmed = line.trimStart()
|
||||
const match = /^(`{3,}|~{3,})/.exec(trimmed)
|
||||
return match ? match[1] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable outline of a page's headings, including an approximate
|
||||
* character size for each section. Used when a page is too large to return whole.
|
||||
*/
|
||||
export function buildDocsOutline(content: string): string {
|
||||
const headings = parseDocsHeadings(content)
|
||||
if (headings.length === 0) {
|
||||
return '(no markdown headings found on this page)'
|
||||
}
|
||||
|
||||
const lines = headings.map((heading, index) => {
|
||||
const sectionEnd = sectionEndIndex(content, headings, index)
|
||||
const approxChars = sectionEnd - heading.startIndex
|
||||
const indent = ' '.repeat(Math.max(0, heading.level - 1))
|
||||
return `${indent}- ${heading.title} (~${approxChars} chars)`
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionEndIndex(content: string, headings: DocsHeading[], index: number): number {
|
||||
const heading = headings[index]
|
||||
// A section ends at the next heading of the same or higher (shallower) level.
|
||||
for (let i = index + 1; i < headings.length; i++) {
|
||||
if (headings[i].level <= heading.level) {
|
||||
return headings[i].startIndex
|
||||
}
|
||||
}
|
||||
return content.length
|
||||
}
|
||||
|
||||
/** Normalizes a heading title for tolerant, case/punctuation-insensitive matching. */
|
||||
function normalizeHeadingTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the content of the section whose heading matches `section`, from the
|
||||
* matching heading up to the next heading of the same or higher level. Matching
|
||||
* is case-insensitive and tolerant of minor punctuation differences. Returns
|
||||
* `undefined` when no heading matches.
|
||||
*/
|
||||
export function extractDocsSection(content: string, section: string): string | undefined {
|
||||
const headings = parseDocsHeadings(content)
|
||||
const target = normalizeHeadingTitle(section)
|
||||
if (target.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let matchIndex = headings.findIndex(
|
||||
(heading) => normalizeHeadingTitle(heading.title) === target
|
||||
)
|
||||
if (matchIndex === -1) {
|
||||
// Fall back to a contains match so "Result streaming" matches "Result".
|
||||
matchIndex = headings.findIndex((heading) =>
|
||||
normalizeHeadingTitle(heading.title).includes(target)
|
||||
)
|
||||
}
|
||||
if (matchIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const start = headings[matchIndex].startIndex
|
||||
const end = sectionEndIndex(content, headings, matchIndex)
|
||||
return content.slice(start, end).trim()
|
||||
}
|
||||
|
||||
const READ_DOCS_PAGE_TOOL: ChatCompletionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_docs_page',
|
||||
description:
|
||||
'Fetch the raw markdown of a single Windmill documentation page. Provide the `path` (or full URL) of a page found via search_docs. If the page is large, this returns its list of section headings instead of the full content; call again with the `section` argument set to one of those headings to read that section.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The docs page to read, as a path (e.g. /docs/core_concepts/jobs) or full URL (e.g. https://www.windmill.dev/docs/core_concepts/jobs).'
|
||||
},
|
||||
section: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional. A heading title from the page outline to read just that section instead of the full page.'
|
||||
}
|
||||
},
|
||||
required: ['path']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const readDocsPageTool: Tool<{}> = {
|
||||
def: READ_DOCS_PAGE_TOOL,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const path = typeof args?.path === 'string' ? args.path : ''
|
||||
const section = typeof args?.section === 'string' && args.section.trim() ? args.section : undefined
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: section ? `Reading docs section "${section}"...` : 'Reading documentation page...'
|
||||
})
|
||||
try {
|
||||
if (!path.trim()) {
|
||||
return 'No documentation page path was provided. Provide a `path` — e.g. a `Source` URL returned by search_docs.'
|
||||
}
|
||||
const content = await fetchDocsPage(path)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Read documentation page' })
|
||||
const canonicalUrl = canonicalDocsPageUrl(path)
|
||||
const header = `Source page — cite this URL when referencing this page: ${canonicalUrl}\n\n`
|
||||
return header + renderDocsPageResult(content, section)
|
||||
} catch (error) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Error reading documentation page',
|
||||
error: 'Error reading documentation page'
|
||||
})
|
||||
console.error('Error reading documentation page:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'An error occurred while reading the documentation page'
|
||||
return `Failed to read documentation page: ${errorMessage}, pursuing with the user request...`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what to return for read_docs_page: a requested section, the full page,
|
||||
* or an outline asking the model to pick a section.
|
||||
*/
|
||||
export function renderDocsPageResult(content: string, section?: string): string {
|
||||
if (section) {
|
||||
const extracted = extractDocsSection(content, section)
|
||||
if (extracted !== undefined) {
|
||||
return extracted
|
||||
}
|
||||
return [
|
||||
`No section matching "${section}" was found on this page. Available sections:`,
|
||||
'',
|
||||
buildDocsOutline(content)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Gate on the page body only; the caller may prepend a short "Source page"
|
||||
// header, so the returned payload can exceed this limit by that header's
|
||||
// length. This threshold only decides whole-page vs. outline, so the small
|
||||
// overshoot is immaterial.
|
||||
if (content.length <= FULL_PAGE_CHAR_LIMIT) {
|
||||
return content
|
||||
}
|
||||
|
||||
return [
|
||||
'This documentation page is large. Below is its list of sections with approximate sizes.',
|
||||
'Call read_docs_page again with the same path and a `section` set to one of these headings to read that section.',
|
||||
'',
|
||||
buildDocsOutline(content)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-text docs search (search_docs)
|
||||
//
|
||||
// Discovery primitive for the `search` ask variant: instead of dumping the whole
|
||||
// llms.txt index, grep the full corpus (llms-full.txt) for the user's keywords
|
||||
// and return only small matching snippets plus each page's `Source:` URL. The
|
||||
// model then cites that URL directly or passes it to read_docs_page for more.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SOURCE_LINE_RE = /^Source:\s*(\S+)\s*$/
|
||||
// In llms-full.txt every page's `Source:` line is preceded by a category-header
|
||||
// lead-in: `...page body...\n\n---\n\n## <Category>\n\nSource: <url>`. Splitting
|
||||
// on `Source:` lines leaves that lead-in on the *previous* page, so strip a
|
||||
// trailing `---` + level-2-heading block to avoid mis-attributing the next
|
||||
// page's category title to the previous page.
|
||||
const TRAILING_LEAD_IN_RE = /\n+-{3,}[ \t]*\n+#{2}[ \t]+.*[ \t]*\n*$/
|
||||
|
||||
export interface DocsFullPage {
|
||||
url: string
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface DocsSearchResult {
|
||||
url: string
|
||||
title: string
|
||||
/** Higher = more relevant. Distinct query terms matched dominate raw occurrences. */
|
||||
score: number
|
||||
snippets: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the llms-full.txt corpus into per-page records keyed by the `Source:`
|
||||
* URL. Content before the first `Source:` line (the corpus preamble) is dropped.
|
||||
*/
|
||||
export function parseDocsFullText(fullText: string): DocsFullPage[] {
|
||||
const pages: DocsFullPage[] = []
|
||||
let url: string | undefined
|
||||
let buffer: string[] = []
|
||||
|
||||
const flush = () => {
|
||||
if (url === undefined) {
|
||||
return
|
||||
}
|
||||
const body = buffer.join('\n').replace(TRAILING_LEAD_IN_RE, '').trim()
|
||||
if (body.length > 0) {
|
||||
pages.push({ url, title: firstHeading(body) ?? url, body })
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of fullText.split('\n')) {
|
||||
const match = SOURCE_LINE_RE.exec(line)
|
||||
if (match) {
|
||||
flush()
|
||||
url = match[1]
|
||||
buffer = []
|
||||
continue
|
||||
}
|
||||
if (url !== undefined) {
|
||||
buffer.push(line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return pages
|
||||
}
|
||||
|
||||
function firstHeading(body: string): string | undefined {
|
||||
for (const line of body.split('\n')) {
|
||||
const match = /^#{1,6}\s+(.*\S)\s*$/.exec(line)
|
||||
if (match) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks docs pages for a keyword query. The query is split into distinct terms;
|
||||
* a page's score is `distinctTermsMatched` (dominant) then total occurrences.
|
||||
* Pages covering every term are preferred over partial matches. Each result
|
||||
* carries up to `maxSnippetsPerPage` of its most term-dense lines.
|
||||
*/
|
||||
export function searchDocsPages(
|
||||
pages: DocsFullPage[],
|
||||
query: string,
|
||||
opts: { maxPages?: number; maxSnippetsPerPage?: number; maxSnippetChars?: number } = {}
|
||||
): DocsSearchResult[] {
|
||||
const maxPages = opts.maxPages ?? SEARCH_MAX_PAGES
|
||||
const maxSnippetsPerPage = opts.maxSnippetsPerPage ?? SEARCH_MAX_SNIPPETS_PER_PAGE
|
||||
const maxSnippetChars = opts.maxSnippetChars ?? SEARCH_MAX_SNIPPET_CHARS
|
||||
|
||||
const terms = tokenizeQuery(query)
|
||||
if (terms.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
interface Scored extends DocsSearchResult {
|
||||
distinctTerms: number
|
||||
order: number
|
||||
}
|
||||
const scored: Scored[] = []
|
||||
|
||||
pages.forEach((page, order) => {
|
||||
const lowerBody = page.body.toLowerCase()
|
||||
let distinctTerms = 0
|
||||
let occurrences = 0
|
||||
for (const term of terms) {
|
||||
const count = countOccurrences(lowerBody, term)
|
||||
if (count > 0) {
|
||||
distinctTerms += 1
|
||||
occurrences += count
|
||||
}
|
||||
}
|
||||
if (distinctTerms === 0) {
|
||||
return
|
||||
}
|
||||
scored.push({
|
||||
url: page.url,
|
||||
title: page.title,
|
||||
// distinctTerms dominates so a page matching all terms always outranks
|
||||
// one matching fewer, regardless of raw occurrence counts.
|
||||
score: distinctTerms * 1_000_000 + occurrences,
|
||||
distinctTerms,
|
||||
order,
|
||||
snippets: selectSnippets(page.body, terms, maxSnippetsPerPage, maxSnippetChars)
|
||||
})
|
||||
})
|
||||
|
||||
// Prefer pages that cover every query term; fall back to partial matches only
|
||||
// when nothing covers all of them.
|
||||
const fullCoverage = scored.filter((entry) => entry.distinctTerms === terms.length)
|
||||
const pool = fullCoverage.length > 0 ? fullCoverage : scored
|
||||
|
||||
pool.sort((a, b) => b.score - a.score || a.order - b.order)
|
||||
|
||||
return pool
|
||||
.slice(0, maxPages)
|
||||
.map(({ url, title, score, snippets }) => ({ url, title, score, snippets }))
|
||||
}
|
||||
|
||||
/** Splits a query into distinct, lowercased, non-empty terms. */
|
||||
function tokenizeQuery(query: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.map((term) => term.trim())
|
||||
.filter((term) => term.length > 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
if (needle.length === 0) {
|
||||
return 0
|
||||
}
|
||||
let count = 0
|
||||
let index = haystack.indexOf(needle)
|
||||
while (index !== -1) {
|
||||
count += 1
|
||||
index = haystack.indexOf(needle, index + needle.length)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the most term-dense lines of a page body as snippets, in document order,
|
||||
* deduped, each trimmed to `maxChars` around the first matched term.
|
||||
*/
|
||||
function selectSnippets(
|
||||
body: string,
|
||||
terms: string[],
|
||||
maxSnippets: number,
|
||||
maxChars: number
|
||||
): string[] {
|
||||
interface LineHit {
|
||||
text: string
|
||||
distinct: number
|
||||
order: number
|
||||
}
|
||||
const hits: LineHit[] = []
|
||||
|
||||
body.split('\n').forEach((line, order) => {
|
||||
const lower = line.toLowerCase()
|
||||
let distinct = 0
|
||||
for (const term of terms) {
|
||||
if (lower.includes(term)) {
|
||||
distinct += 1
|
||||
}
|
||||
}
|
||||
if (distinct === 0) {
|
||||
return
|
||||
}
|
||||
const text = makeSnippet(line, terms, maxChars)
|
||||
if (text.length > 0) {
|
||||
hits.push({ text, distinct, order })
|
||||
}
|
||||
})
|
||||
|
||||
hits.sort((a, b) => b.distinct - a.distinct || a.order - b.order)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const hit of hits) {
|
||||
if (seen.has(hit.text)) {
|
||||
continue
|
||||
}
|
||||
seen.add(hit.text)
|
||||
result.push(hit.text)
|
||||
if (result.length >= maxSnippets) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses a matched line to a single-line snippet of at most `maxChars`,
|
||||
* windowed around the first matched term (with ellipses) when the line is long.
|
||||
*/
|
||||
export function makeSnippet(line: string, terms: string[], maxChars: number): string {
|
||||
const collapsed = line.replace(/\s+/g, ' ').trim()
|
||||
if (collapsed.length <= maxChars) {
|
||||
return collapsed
|
||||
}
|
||||
|
||||
const lower = collapsed.toLowerCase()
|
||||
let firstIndex = -1
|
||||
for (const term of terms) {
|
||||
const index = lower.indexOf(term)
|
||||
if (index !== -1 && (firstIndex === -1 || index < firstIndex)) {
|
||||
firstIndex = index
|
||||
}
|
||||
}
|
||||
if (firstIndex === -1) {
|
||||
return `${collapsed.slice(0, maxChars).trimEnd()}…`
|
||||
}
|
||||
|
||||
const start = Math.max(0, firstIndex - Math.floor(maxChars / 3))
|
||||
const end = Math.min(collapsed.length, start + maxChars)
|
||||
const prefix = start > 0 ? '…' : ''
|
||||
const suffix = end < collapsed.length ? '…' : ''
|
||||
return `${prefix}${collapsed.slice(start, end).trim()}${suffix}`
|
||||
}
|
||||
|
||||
export interface DocsIndexEntry {
|
||||
title: string
|
||||
url: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// A line in llms.txt: `- [Title](https://.../page.md): question-phrased description`.
|
||||
const INDEX_ENTRY_RE = /^\s*-\s*\[([^\]]+)\]\(([^)\s]+)\)\s*:?\s*(.*)$/
|
||||
|
||||
/** Parses the llms.txt index into per-page entries (title, URL, description). */
|
||||
export function parseDocsIndex(indexText: string): DocsIndexEntry[] {
|
||||
const entries: DocsIndexEntry[] = []
|
||||
for (const line of indexText.split('\n')) {
|
||||
const match = INDEX_ENTRY_RE.exec(line)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const [, title, url, description] = match
|
||||
if (!url.includes('/docs/')) {
|
||||
continue
|
||||
}
|
||||
entries.push({ title: title.trim(), url: url.trim(), description: description.trim() })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks index entries for a query by matching its terms against each entry's
|
||||
* title and description. Title matches weigh more than description matches.
|
||||
* The description becomes the result's single snippet. This recovers the
|
||||
* "named feature" discovery that full-text grep misses when the model searches
|
||||
* the wrong keywords (e.g. finding "AI agents" for "LLM decides which script").
|
||||
*/
|
||||
export function searchDocsIndex(
|
||||
entries: DocsIndexEntry[],
|
||||
query: string,
|
||||
opts: { maxPages?: number } = {}
|
||||
): DocsSearchResult[] {
|
||||
const maxPages = opts.maxPages ?? SEARCH_MAX_PAGES
|
||||
const terms = tokenizeQuery(query)
|
||||
if (terms.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
interface Scored extends DocsSearchResult {
|
||||
distinctTerms: number
|
||||
order: number
|
||||
}
|
||||
const scored: Scored[] = []
|
||||
|
||||
entries.forEach((entry, order) => {
|
||||
const title = entry.title.toLowerCase()
|
||||
const description = entry.description.toLowerCase()
|
||||
let distinctTerms = 0
|
||||
let score = 0
|
||||
for (const term of terms) {
|
||||
const inTitle = title.includes(term)
|
||||
const inDescription = description.includes(term)
|
||||
if (inTitle || inDescription) {
|
||||
distinctTerms += 1
|
||||
score += (inTitle ? 5 : 0) + (inDescription ? 1 : 0)
|
||||
}
|
||||
}
|
||||
if (distinctTerms === 0) {
|
||||
return
|
||||
}
|
||||
scored.push({
|
||||
url: entry.url,
|
||||
title: entry.title,
|
||||
score: distinctTerms * 1_000_000 + score,
|
||||
distinctTerms,
|
||||
order,
|
||||
snippets: entry.description ? [entry.description] : []
|
||||
})
|
||||
})
|
||||
|
||||
const fullCoverage = scored.filter((entry) => entry.distinctTerms === terms.length)
|
||||
const pool = fullCoverage.length > 0 ? fullCoverage : scored
|
||||
pool.sort((a, b) => b.score - a.score || a.order - b.order)
|
||||
|
||||
return pool
|
||||
.slice(0, maxPages)
|
||||
.map(({ url, title, score, snippets }) => ({ url, title, score, snippets }))
|
||||
}
|
||||
|
||||
/** Strips the `.md` suffix and trailing slash so index/body URLs dedupe. */
|
||||
function canonicalSearchUrl(url: string): string {
|
||||
return url.replace(/\.md$/i, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges full-text (body) results with index-description results. Body matches
|
||||
* come first (concrete content hits), then index-only matches fill remaining
|
||||
* slots — so a named feature surfaced only by its index entry still appears even
|
||||
* when body grep landed on the wrong pages.
|
||||
*/
|
||||
export function mergeDocsSearchResults(
|
||||
bodyResults: DocsSearchResult[],
|
||||
indexResults: DocsSearchResult[],
|
||||
maxPages = SEARCH_MAX_PAGES
|
||||
): DocsSearchResult[] {
|
||||
const seen = new Set(bodyResults.map((result) => canonicalSearchUrl(result.url)))
|
||||
const merged = [...bodyResults]
|
||||
for (const entry of indexResults) {
|
||||
const key = canonicalSearchUrl(entry.url)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
merged.push(entry)
|
||||
}
|
||||
return merged.slice(0, maxPages)
|
||||
}
|
||||
|
||||
/** Renders search results as the string returned to the model. */
|
||||
export function formatDocsSearchResults(query: string, results: DocsSearchResult[]): string {
|
||||
if (results.length === 0) {
|
||||
return `No documentation pages matched "${query}". Try fewer or more general keywords (a single distinctive term often works best).`
|
||||
}
|
||||
|
||||
const blocks = results.map((result) => {
|
||||
const lines = [`## ${result.title}`, `Source: ${result.url}`]
|
||||
for (const snippet of result.snippets) {
|
||||
lines.push(` - ${snippet}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
return [
|
||||
`Found ${results.length} documentation page(s) matching "${query}", most relevant first:`,
|
||||
'',
|
||||
blocks.join('\n\n'),
|
||||
'',
|
||||
'Cite the exact "Source" URL when referencing a page. If these snippets are not enough, call read_docs_page with a Source URL to read the full page or a section.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const SEARCH_DOCS_TOOL: ChatCompletionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_docs',
|
||||
description:
|
||||
'Full-text search across the entire Windmill documentation. Provide one or more keywords; returns the most relevant docs pages, each with its Source URL and short matching snippets. Use this FIRST to find relevant pages by their content (a flag, function, error message, config key or concept). If the snippets answer the question, answer directly; otherwise call read_docs_page with a returned Source URL to read more.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Keywords to search for in the documentation body, e.g. "chromium worker tag" or "retry exponential backoff". Fewer, more distinctive words match better.'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const searchDocsTool: Tool<{}> = {
|
||||
def: SEARCH_DOCS_TOOL,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
const query = typeof args?.query === 'string' ? args.query.trim() : ''
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: query ? `Searching documentation for "${query}"...` : 'Searching documentation...'
|
||||
})
|
||||
try {
|
||||
if (!query) {
|
||||
return 'No search query was provided. Provide a `query` of one or more keywords.'
|
||||
}
|
||||
const bodyResults = searchDocsPages(parseDocsFullText(await fetchDocsFullText()), query, {
|
||||
maxPages: 5
|
||||
})
|
||||
// Also match the (small) index titles/descriptions to surface named
|
||||
// features that body grep misses. Best-effort: a failed index fetch
|
||||
// still leaves full-text results.
|
||||
let indexResults: DocsSearchResult[] = []
|
||||
try {
|
||||
indexResults = searchDocsIndex(parseDocsIndex(await fetchDocsIndex()), query, {
|
||||
maxPages: 4
|
||||
})
|
||||
} catch (indexError) {
|
||||
console.error('Error searching documentation index:', indexError)
|
||||
}
|
||||
const results = mergeDocsSearchResults(bodyResults, indexResults)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content:
|
||||
results.length > 0 ? `Found ${results.length} matching page(s)` : 'No matching pages found'
|
||||
})
|
||||
return formatDocsSearchResults(query, results)
|
||||
} catch (error) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Error searching documentation',
|
||||
error: 'Error searching documentation'
|
||||
})
|
||||
console.error('Error searching documentation:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'An error occurred while searching the documentation'
|
||||
return `Failed to search documentation: ${errorMessage}, pursuing with the user request...`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type FrameworkKey
|
||||
} from '$lib/components/raw_apps/templates'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
|
||||
import {
|
||||
applyEditableFlowJsonToFlow,
|
||||
buildEditableFlowJson,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type ToolCallbacks,
|
||||
type ToolDisplayAction
|
||||
} from '../shared'
|
||||
import { searchDocsTool, readDocsPageTool } from '../docs/core'
|
||||
import type { ContextElement } from '../context'
|
||||
import { getDatatableTools } from '../datatableTools'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
@@ -677,6 +679,13 @@ Rules:
|
||||
: ''
|
||||
}
|
||||
|
||||
Documentation:
|
||||
- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or "does Windmill support X") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it.
|
||||
- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.
|
||||
- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team.
|
||||
- If the first search returns nothing useful, retry with different or broader keywords before giving up.
|
||||
- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team.
|
||||
|
||||
Flows:
|
||||
- read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>".
|
||||
- Use read_flow_module_code and set_flow_module_code for inline script bodies.
|
||||
@@ -1072,38 +1081,6 @@ function getInlineRunnableContent(
|
||||
return { content: runnable.inlineScript?.content ?? '', runnable }
|
||||
}
|
||||
|
||||
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
|
||||
if (value.data?.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
if (value.data) {
|
||||
return value.data
|
||||
}
|
||||
if (value.datatables) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
|
||||
}
|
||||
if (value.dataTableRefs) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
return { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
|
||||
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
const value = (app.value ?? {}) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppValueForRead(path: string, workspace: string): Promise<AppDraftValue> {
|
||||
const draft = await getGlobalDraft(workspace, 'app', path)
|
||||
if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) {
|
||||
@@ -1494,6 +1471,8 @@ export const globalTools: Tool<{}>[] = [
|
||||
}
|
||||
},
|
||||
createSearchHubScriptsTool(false),
|
||||
searchDocsTool,
|
||||
readDocsPageTool,
|
||||
{
|
||||
def: createToolDef(
|
||||
askUserQuestionSchema,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/index.mjs'
|
||||
import { createSearchWorkspaceTool, createGetRunnableDetailsTool, type Tool } from '../shared'
|
||||
import { readDocsPageTool, searchDocsTool } from '../docs/core'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
@@ -16,13 +17,14 @@ Windmill is an open-source developer platform for building internal tools, API i
|
||||
You have access to these tools:
|
||||
1. View current buttons and inputs on the page (get_triggerable_components)
|
||||
2. Execute buttons and inputs (trigger_component)
|
||||
3. Get documentation for user requests (get_documentation)
|
||||
4. Change the AI mode to the one specified (change_mode)
|
||||
5. Search for scripts and flows in the workspace (search_workspace)
|
||||
6. Get detailed information about a specific script or flow (get_runnable_details)
|
||||
3. Search the documentation (search_docs)
|
||||
4. Read a documentation page (read_docs_page)
|
||||
5. Change the AI mode to the one specified (change_mode)
|
||||
6. Search for scripts and flows in the workspace (search_workspace)
|
||||
7. Get detailed information about a specific script or flow (get_runnable_details)
|
||||
|
||||
INSTRUCTIONS:
|
||||
- When users ask about application features or concepts, first use get_documentation internally to retrieve accurate information about how to fulfill the user's request.
|
||||
- When users ask about application features or concepts, first use search_docs (with a few keywords) and, when a snippet is not enough, read_docs_page on a returned Source URL to retrieve accurate information about how to fulfill the user's request.
|
||||
- Then immediately use the available tools to guide the user through the application. Do not wait for the user's confirmation before taking action.
|
||||
- If you detect a confirmation modal that needs user confirmation, stop the navigation and let the user know that the action is pending confirmation.
|
||||
- Use get_triggerable_components to understand available options, and then trigger the components using trigger_component. Then wait a moment before rescanning the current page, and then continue with the next step. Do this 5 times max.
|
||||
@@ -59,30 +61,12 @@ When you complete the user's request, do not say "I created..." or "I updated...
|
||||
|
||||
Example of good behavior:
|
||||
- User: "How can I set my AI providers?"
|
||||
- You: <call get_documentation and fetch relevant documentation>
|
||||
- You: <call search_docs (and read_docs_page if needed) to fetch relevant documentation>
|
||||
- You: <call get_triggerable_components to find relevant components>
|
||||
- You: <trigger the components>
|
||||
- You: "<precisions about the request based on the documentation>"
|
||||
`
|
||||
|
||||
const GET_DOCUMENTATION_TOOL: ChatCompletionTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_documentation',
|
||||
description: 'Get the documentation for the user request',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
request: {
|
||||
type: 'string',
|
||||
description: 'The user request'
|
||||
}
|
||||
},
|
||||
required: ['request']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool definitions
|
||||
const GET_TRIGGERABLE_COMPONENTS_TOOL: ChatCompletionTool = {
|
||||
type: 'function',
|
||||
@@ -234,47 +218,6 @@ function triggerComponent(args: { id: string; value: string }): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function getDocumentation(args: { request: string }): Promise<string> {
|
||||
const retrieval = await fetch('/api/inkeep', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: args.request
|
||||
})
|
||||
})
|
||||
|
||||
if (!retrieval.ok) {
|
||||
const errorText = await retrieval.text()
|
||||
throw new Error(errorText)
|
||||
}
|
||||
|
||||
const data = await retrieval.json()
|
||||
if (!data.choices?.[0]?.message?.content) {
|
||||
return 'No documentation found for this request'
|
||||
}
|
||||
|
||||
// Parse the raw response
|
||||
const raw = data.choices[0].message.content
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
// Clean up the response to include only essential information
|
||||
if (parsed.content && Array.isArray(parsed.content)) {
|
||||
const cleanedContent = parsed.content.map((item: any) => ({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
content: item.source?.content.map((c: any) => c.text).join('\n') || []
|
||||
}))
|
||||
// Limit the response to 30000 characters max
|
||||
const stringified = JSON.stringify({ content: cleanedContent }).slice(0, 30000)
|
||||
|
||||
return stringified
|
||||
}
|
||||
|
||||
return data.choices[0].message.content
|
||||
}
|
||||
|
||||
async function getAvailableResources(args: { resource_type: string }): Promise<string> {
|
||||
const resources = await ResourceService.listResource({
|
||||
workspace: get(workspaceStore) as string,
|
||||
@@ -318,27 +261,6 @@ const getCurrentPageNameTool: Tool<{}> = {
|
||||
}
|
||||
}
|
||||
|
||||
export const getDocumentationTool: Tool<{}> = {
|
||||
def: GET_DOCUMENTATION_TOOL,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Getting documentation...' })
|
||||
try {
|
||||
const docResult = await getDocumentation(args)
|
||||
toolCallbacks.setToolStatus(toolId, { content: 'Retrieved documentation' })
|
||||
return docResult
|
||||
} catch (error) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: 'Error getting documentation',
|
||||
error: 'Error getting documentation'
|
||||
})
|
||||
console.error('Error getting documentation:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'An error occurred while getting documentation'
|
||||
return `Failed to get documentation: ${errorMessage}, pursuing with the user request...`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getAvailableResourcesTool: Tool<{}> = {
|
||||
def: GET_AVAILABLE_RESOURCES_TOOL,
|
||||
fn: async ({ args, toolId, toolCallbacks }) => {
|
||||
@@ -361,7 +283,8 @@ const getAvailableResourcesTool: Tool<{}> = {
|
||||
export const navigatorTools: Tool<{}>[] = [
|
||||
getTriggerableComponentsTool,
|
||||
triggerComponentTool,
|
||||
getDocumentationTool,
|
||||
searchDocsTool,
|
||||
readDocsPageTool,
|
||||
getCurrentPageNameTool,
|
||||
getAvailableResourcesTool,
|
||||
createSearchWorkspaceTool(),
|
||||
|
||||
@@ -123,6 +123,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
return {
|
||||
type: 'ducklake' as const,
|
||||
ducklake: parsed.path,
|
||||
specificSchema: parsed.schema,
|
||||
specificTable: parsed.table
|
||||
}
|
||||
}
|
||||
@@ -161,6 +162,7 @@ export function useDbManagerUriState(): DbManagerUriState {
|
||||
params.dbm = buildDbm({
|
||||
type: 'ducklake',
|
||||
path: nInput.ducklake,
|
||||
schema: nInput.specificSchema,
|
||||
table: nInput.specificTable
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,35 +297,45 @@ export async function getDucklakeSchema({
|
||||
args: {}
|
||||
}
|
||||
})
|
||||
let mainSchema = Array.isArray(result) && result.length && (result?.[0]?.['result'] ?? [])
|
||||
let schemas = Array.isArray(result) && result.length && (result?.[0]?.['result'] ?? {})
|
||||
// Safety for agent workers (duckdb ffi lib used to return JSON as stringified json)
|
||||
if (typeof mainSchema === 'string') mainSchema = JSON.parse(mainSchema)
|
||||
if (typeof schemas === 'string') schemas = JSON.parse(schemas)
|
||||
|
||||
if (!mainSchema) throw new Error('Failed to get Ducklake schema: ' + JSON.stringify(result))
|
||||
assert('mainSchema is an object', typeof mainSchema === 'object')
|
||||
if (!schemas) throw new Error('Failed to get Ducklake schema: ' + JSON.stringify(result))
|
||||
assert('schemas is an object', typeof schemas === 'object')
|
||||
let schema: Omit<SQLSchema, 'stringified'> = {
|
||||
schema: { main: mainSchema },
|
||||
publicOnly: true,
|
||||
schema: schemas,
|
||||
publicOnly: false,
|
||||
lang: 'ducklake'
|
||||
}
|
||||
return { ...schema, stringified: stringifySchema(schema) }
|
||||
}
|
||||
|
||||
// Returns every schema in the ducklake (including empty ones, e.g. freshly created)
|
||||
// as a nested map { schema: { table: { column: {...} } } }.
|
||||
const DUCKLAKE_GET_SCHEMA_QUERY = `
|
||||
SELECT json_group_object(table_name, table_data) AS result FROM (
|
||||
SELECT json_group_object(schema_name, COALESCE(schema_data, json_object())) AS result FROM (
|
||||
SELECT
|
||||
table_name,
|
||||
json_group_object(
|
||||
c.column_name,
|
||||
json_object(
|
||||
'type', c.data_type,
|
||||
'default', c.column_default,
|
||||
'required', c.is_nullable == 'NO'
|
||||
s.schema_name,
|
||||
(
|
||||
SELECT json_group_object(table_name, table_data) FROM (
|
||||
SELECT
|
||||
c.table_name,
|
||||
json_group_object(
|
||||
c.column_name,
|
||||
json_object(
|
||||
'type', c.data_type,
|
||||
'default', c.column_default,
|
||||
'required', c.is_nullable == 'NO'
|
||||
)
|
||||
) AS table_data
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_catalog = '__ducklake__' AND c.table_schema = s.schema_name
|
||||
GROUP BY c.table_name
|
||||
)
|
||||
) AS table_data
|
||||
FROM information_schema.columns c
|
||||
WHERE table_catalog = '__ducklake__' AND table_schema = current_schema()
|
||||
GROUP BY c.table_name
|
||||
) AS schema_data
|
||||
FROM information_schema.schemata s
|
||||
WHERE s.catalog_name = '__ducklake__'
|
||||
)`
|
||||
|
||||
export function getDbType(input: DbInput): DbType {
|
||||
|
||||
@@ -9,6 +9,7 @@ export type DbInput =
|
||||
| {
|
||||
type: 'ducklake'
|
||||
ducklake: string
|
||||
specificSchema?: string
|
||||
specificTable?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import type { Flow, NewScript, UserDraftItemKind } from '$lib/gen'
|
||||
import { importStore } from '$lib/components/apps/store'
|
||||
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
|
||||
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
|
||||
import { getUsernameForNamespace } from '$lib/userNamespace'
|
||||
|
||||
/**
|
||||
* Re-home the source path into the forker's namespace: drop the first two
|
||||
* segments, prefix `u/{me}`. `u/admin/myflow` → `u/me/myflow`.
|
||||
*/
|
||||
function forkSeedPath(sourcePath: string): string {
|
||||
const rest = sourcePath.split('/').slice(2).join('/')
|
||||
return `u/${getUsernameForNamespace()}/${rest}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a fetched draft value as a brand-new item of `itemKind`, via the same
|
||||
* one-shot import handoff as "Import from YAML/JSON": stash the payload in the
|
||||
* import store and route to the kind's `/add` page. The fork behaves like a new
|
||||
* item of one's own — nothing saved until the first edit, no source identity
|
||||
* carried over. The re-homed source path travels as `?seed_path=` (not `?path=`,
|
||||
* which ScriptBuilder strips in transit) so the Path widget starts recognizable.
|
||||
* Only the cross-user-visible kinds can be forked.
|
||||
*/
|
||||
export function forkDraftToImport(
|
||||
itemKind: UserDraftItemKind,
|
||||
value: unknown,
|
||||
sourcePath: string
|
||||
): void {
|
||||
const seed = `?seed_path=${encodeURIComponent(forkSeedPath(sourcePath))}`
|
||||
switch (itemKind) {
|
||||
case 'script':
|
||||
importScriptStore.set(value as NewScript)
|
||||
goto(`${base}/scripts/add${seed}`)
|
||||
return
|
||||
case 'flow':
|
||||
importFlowStore.set(value as Flow)
|
||||
goto(`${base}/flows/add${seed}`)
|
||||
return
|
||||
case 'app':
|
||||
// App drafts store the bare `App` value (no summary/policy
|
||||
// wrapper) — the /apps/edit import branch accepts both shapes.
|
||||
importStore.set(value as any)
|
||||
goto(`${base}/apps/add${seed}`)
|
||||
return
|
||||
case 'raw_app': {
|
||||
// Raw-app drafts bundle `{files, runnables, data, summary,
|
||||
// policy, ...}` flat; wrap so the /apps_raw/edit import branch
|
||||
// picks up summary and policy alongside the value.
|
||||
const v = value as any
|
||||
importStore.set({ summary: v?.summary ?? '', value: v, policy: v?.policy })
|
||||
goto(`${base}/apps_raw/add${seed}`)
|
||||
return
|
||||
}
|
||||
default:
|
||||
throw new Error(`Cannot fork drafts of kind ${itemKind}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AppService, FlowService, ScriptService, type UserDraftItemKind } from '$lib/gen'
|
||||
import type { Value } from '$lib/utils'
|
||||
import { DEFAULT_DATA, extractDataConfig } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
|
||||
/**
|
||||
* Fetch the currently-deployed value for an item, in the same shape its draft
|
||||
* is stored — so it can be the "original" side of a draft-vs-deployed diff.
|
||||
* App drafts hold the bare `App` value (unwrap `.value`); raw-app drafts hold a
|
||||
* flat bundle (`files`/`runnables`/`data`/`summary`/`policy`/`custom_path`),
|
||||
* with `summary`/`policy`/`custom_path` living OUTSIDE `.value` on the deployed
|
||||
* row — so we project the deployed app into that bundle (via the same
|
||||
* `extractDataConfig` the editor uses) instead of diffing mismatched shapes.
|
||||
* Only the cross-user-visible kinds have other-user drafts to diff.
|
||||
*/
|
||||
export async function fetchDeployedValueForDiff(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): Promise<Value> {
|
||||
switch (itemKind) {
|
||||
case 'script':
|
||||
return (await ScriptService.getScriptByPath({
|
||||
workspace,
|
||||
path,
|
||||
getDraft: false
|
||||
})) as unknown as Value
|
||||
case 'flow':
|
||||
return (await FlowService.getFlowByPath({ workspace, path })) as unknown as Value
|
||||
case 'app':
|
||||
return (await AppService.getAppByPath({ workspace, path, getDraft: false }))
|
||||
.value as unknown as Value
|
||||
case 'raw_app': {
|
||||
const app = await AppService.getAppByPath({ workspace, path, getDraft: false })
|
||||
const v = (app.value ?? {}) as any
|
||||
return {
|
||||
files: v.files,
|
||||
runnables: v.runnables,
|
||||
data: extractDataConfig(v) ?? { ...DEFAULT_DATA },
|
||||
summary: app.summary,
|
||||
policy: app.policy,
|
||||
custom_path: app.custom_path
|
||||
} as unknown as Value
|
||||
}
|
||||
default:
|
||||
throw new Error(`Cannot diff drafts of kind ${itemKind}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { UserDraft, draftValuesEqual, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
/**
|
||||
* Coordinates "Load another user's draft into the editor as if it were ours".
|
||||
*
|
||||
* Two cases, decided by the route once it knows whether WE already have a draft
|
||||
* at this path (`is_draft` for us):
|
||||
* - No own draft → the loaded value just becomes our draft (normal autosave).
|
||||
* - Own draft → "overlay" mode: the loaded value is shown but NEVER saved
|
||||
* (so our own draft on the server is untouched). The first edit prompts
|
||||
* "overwrite your current draft?". Confirm persists the edited value as our
|
||||
* draft; Reset restores our own draft.
|
||||
*
|
||||
* The save block is a hard per-key lock in the syncer (see `lockSync`) — it
|
||||
* covers the reactive mirror AND the navigation / tab-death flush paths, so the
|
||||
* foreign value can't leak onto the server through any route.
|
||||
*/
|
||||
|
||||
function keyOf(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
||||
return `${workspace}/${itemKind}/${path}`
|
||||
}
|
||||
|
||||
export type PendingOtherUserDraftLoad = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
value: unknown
|
||||
ownerLabel: string
|
||||
}
|
||||
|
||||
type ActiveSession = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
path: string
|
||||
ownerLabel: string
|
||||
/** The loaded value the editor shows. A blocked save whose value still
|
||||
* equals this is a programmatic load-cascade write, not an edit — so the
|
||||
* overwrite prompt fires only once the value actually diverges. */
|
||||
loadedValue: unknown
|
||||
/** Reload our own draft into the editor (AutosaveIndicator's "Reset to draft"). */
|
||||
onResetToOwnDraft: () => void | Promise<void>
|
||||
}
|
||||
|
||||
// One-shot handoff: set by the Load action, consumed by the editor's loader.
|
||||
const pending = new SvelteMap<string, PendingOtherUserDraftLoad>()
|
||||
// Live overlay sessions, keyed by (workspace, itemKind, path).
|
||||
const active = new SvelteMap<string, ActiveSession>()
|
||||
// Keys whose overwrite-confirmation modal is currently open.
|
||||
const overwriteOpen = new SvelteSet<string>()
|
||||
|
||||
export function editRouteFor(itemKind: UserDraftItemKind, path: string): string {
|
||||
switch (itemKind) {
|
||||
case 'script':
|
||||
return `${base}/scripts/edit/${path}`
|
||||
case 'flow':
|
||||
return `${base}/flows/edit/${path}`
|
||||
case 'app':
|
||||
return `${base}/apps/edit/${path}`
|
||||
case 'raw_app':
|
||||
return `${base}/apps_raw/edit/${path}`
|
||||
default:
|
||||
throw new Error(`Cannot load drafts of kind ${itemKind}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const OtherUserDraftLoad = {
|
||||
/**
|
||||
* Stage a load. The editor's loader picks it up via `takePending`. When
|
||||
* `navigate`, route to the item's edit page (home-page entry point); the
|
||||
* in-editor entry point reloads in place instead.
|
||||
*/
|
||||
stage(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
value: unknown,
|
||||
path: string,
|
||||
ownerLabel: string,
|
||||
opts: { navigate: boolean }
|
||||
): void {
|
||||
pending.set(keyOf(workspace, itemKind, path), { workspace, itemKind, path, value, ownerLabel })
|
||||
if (opts.navigate) goto(editRouteFor(itemKind, path))
|
||||
},
|
||||
|
||||
/** Consume the staged load for this key (returns + removes it). */
|
||||
takePending(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): PendingOtherUserDraftLoad | undefined {
|
||||
const k = keyOf(workspace, itemKind, path)
|
||||
const v = pending.get(k)
|
||||
if (v) pending.delete(k)
|
||||
return v
|
||||
},
|
||||
|
||||
/**
|
||||
* Enter overlay mode: lock all server saves for this key and remember how
|
||||
* to restore our own draft. A blocked save opens the overwrite modal ONLY
|
||||
* once the value diverges from `loadedValue` — so the programmatic load
|
||||
* cascade (which writes back the same value) never trips the prompt, while
|
||||
* the user's very first real edit does, with no timing window.
|
||||
*/
|
||||
beginOverlay(session: ActiveSession): void {
|
||||
const k = keyOf(session.workspace, session.itemKind, session.path)
|
||||
active.set(k, session)
|
||||
UserDraftDbSyncer.lockSync(
|
||||
{ workspace: session.workspace, itemKind: session.itemKind, path: session.path },
|
||||
() => {
|
||||
const current = UserDraft.get(session.itemKind, session.path, {
|
||||
workspace: session.workspace
|
||||
})
|
||||
if (current !== undefined && !draftValuesEqual(current, session.loadedValue)) {
|
||||
this.requestOverwriteModal(session.workspace, session.itemKind, session.path)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
isActive(workspace: string, itemKind: UserDraftItemKind, path: string): boolean {
|
||||
return active.has(keyOf(workspace, itemKind, path))
|
||||
},
|
||||
|
||||
getSession(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): ActiveSession | undefined {
|
||||
return active.get(keyOf(workspace, itemKind, path))
|
||||
},
|
||||
|
||||
requestOverwriteModal(workspace: string, itemKind: UserDraftItemKind, path: string): void {
|
||||
const k = keyOf(workspace, itemKind, path)
|
||||
if (active.has(k)) overwriteOpen.add(k)
|
||||
},
|
||||
|
||||
isOverwriteModalOpen(workspace: string, itemKind: UserDraftItemKind, path: string): boolean {
|
||||
return overwriteOpen.has(keyOf(workspace, itemKind, path))
|
||||
},
|
||||
|
||||
/** Cancel: keep editing the loaded value, stay paused; re-prompt on the next edit. */
|
||||
dismissOverwriteModal(workspace: string, itemKind: UserDraftItemKind, path: string): void {
|
||||
overwriteOpen.delete(keyOf(workspace, itemKind, path))
|
||||
},
|
||||
|
||||
/** Confirm: adopt the current (edited) value as our own draft and resume saving. */
|
||||
confirmOverwrite(workspace: string, itemKind: UserDraftItemKind, path: string): void {
|
||||
const current = UserDraft.get(itemKind, path, { workspace })
|
||||
this.clear(workspace, itemKind, path)
|
||||
if (current !== undefined) {
|
||||
void UserDraftDbSyncer.save({ workspace, itemKind, path, value: current, immediate: true })
|
||||
}
|
||||
},
|
||||
|
||||
/** Discard the loaded view and restore our own draft. */
|
||||
async resetToOwnDraft(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
const session = active.get(keyOf(workspace, itemKind, path))
|
||||
this.clear(workspace, itemKind, path)
|
||||
await session?.onResetToOwnDraft()
|
||||
},
|
||||
|
||||
/** Exit overlay mode: unlock saves, drop the session, close the modal. */
|
||||
clear(workspace: string, itemKind: UserDraftItemKind, path: string): void {
|
||||
const k = keyOf(workspace, itemKind, path)
|
||||
active.delete(k)
|
||||
overwriteOpen.delete(k)
|
||||
UserDraftDbSyncer.unlockSync({ workspace, itemKind, path })
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,30 @@ export const DEFAULT_DATA: RawAppData = {
|
||||
schema: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw-app value's data config to `RawAppData`, handling the old
|
||||
* nested `creation` shape and the legacy top-level `datatables`. Shared by the
|
||||
* editor loader and the deployed-vs-draft diff so both project identically.
|
||||
* Returns `undefined` when the value carries no data config.
|
||||
*/
|
||||
export function extractDataConfig(value: any): RawAppData | undefined {
|
||||
if (value?.data) {
|
||||
const d = value.data
|
||||
// Handle old nested creation format
|
||||
if (d.creation) {
|
||||
return {
|
||||
tables: d.tables ?? [],
|
||||
datatable: d.creation.datatable,
|
||||
schema: d.creation.schema
|
||||
}
|
||||
}
|
||||
return d
|
||||
} else if (value?.datatables) {
|
||||
return { ...DEFAULT_DATA, tables: value.datatables }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type DataTableWhitelist = {
|
||||
datatables: Set<string>
|
||||
allTablesDatatables: Set<string>
|
||||
@@ -76,7 +100,12 @@ export function isDatatableTableAllowed(
|
||||
return true
|
||||
}
|
||||
|
||||
return whitelist.tables.get(datatableName)?.get(schemaName ?? 'public')?.has(tableName) ?? false
|
||||
return (
|
||||
whitelist.tables
|
||||
.get(datatableName)
|
||||
?.get(schemaName ?? 'public')
|
||||
?.has(tableName) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shared projection of a raw-app *source* into the flat `AppDraftValue` the
|
||||
* deploy paths consume. The single source for BOTH the global AI chat
|
||||
* (`copilot/chat/global/core.ts`) and the Review & Deploy page
|
||||
* (`rawAppDeploy.ts`), so both bundle the identical shape. It must read a
|
||||
* draft's top-level `files` (a `RawAppDraft` carries them there, not under
|
||||
* `value.files`) — otherwise the bundle is empty and deploy fails with "Raw app
|
||||
* bundle requires /index.ts".
|
||||
*/
|
||||
import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
|
||||
/** Collapse the historical `data` shapes a raw-app source might carry into the
|
||||
* canonical `AppDraftValue['data']`. */
|
||||
export function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
|
||||
if (value.data?.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
if (value.data) {
|
||||
return value.data
|
||||
}
|
||||
if (value.datatables) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
|
||||
}
|
||||
if (value.dataTableRefs) {
|
||||
return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
|
||||
}
|
||||
return { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a raw-app source into a flat `AppDraftValue`. Handles both shapes:
|
||||
* - a deployed app nests its source under `value` (`app.value.files`);
|
||||
* - a `RawAppDraft` already carries `files`/`runnables`/`data` at the top level.
|
||||
* Falling back to the object itself when there's no nested `value` keeps a
|
||||
* draft's bundle from being dropped.
|
||||
*/
|
||||
export function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
const value = (app.value ?? app) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { visibleWorkspaceIds } from './sessionScope.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -318,9 +317,11 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if !globalEnabled || !$copilotInfo.enabled}
|
||||
<!-- Sessions hidden until the global-ai dev gate is enabled, and whenever AI is
|
||||
unavailable (no provider configured or disabled in the user's AI settings). -->
|
||||
{#if !globalEnabled}
|
||||
<!-- Sessions hidden until the global-ai dev gate is enabled. When AI is
|
||||
unavailable (no provider configured or disabled in the user's settings)
|
||||
the section still shows — the per-session chat input is disabled with an
|
||||
explanatory message, mirroring the sidebar AI chat. -->
|
||||
{:else if isCollapsed}
|
||||
<div class="px-2 pt-3 pb-2 border-b border-light dark:border-gray-700">
|
||||
<Menubar>
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface PageDraftSyncOptions<V = unknown> {
|
||||
* `draftValuesEqual` so it can't disagree with the "unsaved changes"
|
||||
* banner. Return false for draft-only items (no deployed baseline). */
|
||||
discardIf?: (val: V) => boolean
|
||||
/** Seeds the cell on first acquire without POSTing (the syncer's seed
|
||||
* guard swallows it). Use when the value is already in hand at mount (an
|
||||
* embedder providing the item) instead of assigning `draft` after load.
|
||||
* Pass a STABLE reference (read it under `untrack`). */
|
||||
defaultValue?: V
|
||||
}
|
||||
|
||||
export interface PageDraftSync<V> {
|
||||
@@ -57,6 +62,7 @@ export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions<V>): Pa
|
||||
path: opts.path(),
|
||||
workspace: opts.workspace(),
|
||||
canBeDisabled: true,
|
||||
defaultValue: opts.defaultValue,
|
||||
discardIf: opts.discardIf
|
||||
}))
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
* This mirrors how the global AI chat deploys raw apps
|
||||
* (`copilot/chat/global/core.ts` → deployDraft, case 'app'): read the item with
|
||||
* its draft, normalise to an AppDraftValue, recompute the policy, bundle the
|
||||
* files, then createAppRaw/updateAppRaw. The two pure transforms
|
||||
* (appSourceToDraftValue / normalizeRawAppData) are re-implemented here to avoid
|
||||
* importing the heavy chat module.
|
||||
* files, then createAppRaw/updateAppRaw. The source→AppDraftValue projection is
|
||||
* shared via `rawAppDraftValue` so the two deploy paths can't drift.
|
||||
*/
|
||||
import { get } from 'svelte/store'
|
||||
import { AppService } from '$lib/gen'
|
||||
@@ -18,32 +17,7 @@ import { bundleRawAppDraft } from '$lib/components/copilot/chat/global/rawAppBun
|
||||
import type { AppDraftValue } from '$lib/components/copilot/chat/global/workspaceItems'
|
||||
import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
|
||||
function normalizeRawAppData(value: Record<string, any>): AppDraftValue['data'] {
|
||||
if (value.data?.creation) {
|
||||
return {
|
||||
tables: value.data.tables ?? [],
|
||||
datatable: value.data.creation.datatable,
|
||||
schema: value.data.creation.schema
|
||||
}
|
||||
}
|
||||
if (value.data) return value.data
|
||||
if (value.datatables) return { ...DEFAULT_RAW_APP_DATA, tables: value.datatables }
|
||||
if (value.dataTableRefs) return { ...DEFAULT_RAW_APP_DATA, tables: value.dataTableRefs }
|
||||
return { ...DEFAULT_RAW_APP_DATA }
|
||||
}
|
||||
|
||||
function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue {
|
||||
const value = (app.value ?? {}) as Record<string, any>
|
||||
return {
|
||||
summary: app.summary ?? '',
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: normalizeRawAppData(value),
|
||||
policy: app.policy ?? fallback?.policy,
|
||||
custom_path: app.custom_path ?? fallback?.custom_path
|
||||
}
|
||||
}
|
||||
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
|
||||
|
||||
/**
|
||||
* Promote a raw app's draft to deployed. Throws on failure (caller wraps into a
|
||||
@@ -59,8 +33,11 @@ export async function deployRawAppDraft(
|
||||
// the raw_app draft kind server-side instead of 404ing.
|
||||
const app = await AppService.getAppByPath({ workspace, path, getDraft: true, rawApp: true })
|
||||
const draft = (app as any).draft
|
||||
// Honor a renamed draft path; the URL `path` below stays the existing item key.
|
||||
const targetPath = draft?.path ?? path
|
||||
// Deploy at the draft's intended path. A raw-app draft carries the user-typed
|
||||
// path in `draft_path` (a never-deployed app is parked at a synthetic
|
||||
// `u/{user}/draft_{uuid}` storage key); the URL `path` below stays that storage
|
||||
// key. Falls back to `path` for an unrenamed draft on a deployed app.
|
||||
const targetPath = draft?.draft_path ?? draft?.path ?? path
|
||||
const value = appSourceToDraftValue(draft ?? app, app)
|
||||
|
||||
const policy = (await updateRawAppPolicy(
|
||||
|
||||
@@ -501,6 +501,11 @@ export const UserDraft = {
|
||||
path: string
|
||||
workspace?: string
|
||||
canBeDisabled?: boolean
|
||||
/** See the `useMany` spec field. Seeds the cell on first acquire
|
||||
* without POSTing. Pass a STABLE reference (read it under `untrack`)
|
||||
* — it's consumed once, so re-reading reactive state here only churns
|
||||
* the reconcile. */
|
||||
defaultValue?: V
|
||||
/** See the `useMany` spec field. Captured per re-keyed acquire. */
|
||||
discardIf?: (val: V) => boolean
|
||||
}
|
||||
@@ -566,15 +571,19 @@ export const UserDraft = {
|
||||
const next: UserDraftHandle<V>[] = []
|
||||
|
||||
for (const spec of specs) {
|
||||
const ws = spec.workspace ?? resolveWorkspace()
|
||||
const mk = mapKey(ws, spec.itemKind, spec.path)
|
||||
// Resolve the workspace WITHOUT throwing: a reactive caller (e.g. an
|
||||
// SDK editor mounted before login) may not have one yet. An absent
|
||||
// workspace is handled like an empty path below, so the handle
|
||||
// re-keys into a real entry once the workspace resolves.
|
||||
const ws = spec.workspace ?? get(workspaceStore) ?? undefined
|
||||
const mk = mapKey(ws ?? '', spec.itemKind, spec.path)
|
||||
|
||||
// Empty path = no draftable item (e.g. read-only
|
||||
// historical-hash view that still binds an editor value).
|
||||
// No workspace yet, or empty path = no draftable item (e.g. a
|
||||
// read-only historical-hash view that still binds an editor value).
|
||||
// Acquiring would mirror edits into an unroutable
|
||||
// `POST /drafts/update/kind/` (permanent "Save failed").
|
||||
// Hand out a detached, local-only handle instead.
|
||||
if (!spec.path) {
|
||||
if (!ws || !spec.path) {
|
||||
seen.add(mk)
|
||||
let handle = handleCache.get(mk)
|
||||
// Drop the cached handle when the caller hands in a fresh
|
||||
|
||||
@@ -249,11 +249,16 @@ export async function migrateUserDraftsToDb(): Promise<void> {
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Preserve the draft's original age: stamp `created_at` with the LS
|
||||
// write time (epoch 0 when unknown) so migrated drafts don't all
|
||||
// resurface to the top as freshly created. Same value as `last_sync`,
|
||||
// which still drives the conflict check.
|
||||
const writtenAt = new Date(lastWrittenAt ?? 0).toISOString()
|
||||
const res = await DraftService.updateDraft({
|
||||
workspace: parsed.workspace,
|
||||
kind: parsed.itemKind,
|
||||
path,
|
||||
requestBody: { value, last_sync: new Date(lastWrittenAt ?? 0).toISOString() }
|
||||
requestBody: { value, last_sync: writtenAt, created_at: writtenAt }
|
||||
})
|
||||
if (res.status === 'conflict') {
|
||||
console.info(
|
||||
|
||||
@@ -150,6 +150,15 @@ let autosaveEnabledState = $state(readAutosaveEnabled())
|
||||
*/
|
||||
const pendingSaveOpts = new Map<string, UserDraftDbSyncerSaveOpts>()
|
||||
|
||||
/**
|
||||
* Keys whose saves are HARD-blocked: while editing another user's loaded draft
|
||||
* the foreign value must never reach the server through ANY path (reactive
|
||||
* mirror, explicit flush, the pagehide keepalive flush). The value is the
|
||||
* "blocked save attempted" callback — the first such attempt is the user's
|
||||
* first edit, which the overlay UI turns into an "overwrite?" prompt.
|
||||
*/
|
||||
const syncLocked = new Map<string, (() => void) | undefined>()
|
||||
|
||||
/**
|
||||
* Conflict snapshots, populated when the server rejects a save (row
|
||||
* `created_at` newer than our `last_sync`). Read via `getConflict(query)`
|
||||
@@ -264,6 +273,8 @@ const staleSyncAfterHideFlush = new Set<string>()
|
||||
function flushOnPageHide(): void {
|
||||
if (pendingSaveOpts.size === 0) return
|
||||
for (const [key, opts] of pendingSaveOpts) {
|
||||
// Editing another user's loaded draft: never flush the foreign value.
|
||||
if (syncLocked.has(key)) continue
|
||||
// Auto-save off: page-editor opts are dropped with the page;
|
||||
// drawer-kind pendings (no `canBeDisabled`) still flush.
|
||||
if (!autosaveEnabledState && opts.auto && opts.canBeDisabled) continue
|
||||
@@ -357,6 +368,13 @@ export const UserDraftDbSyncer = {
|
||||
|
||||
async save(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
const key = draftKey(opts.workspace, opts.itemKind, opts.path)
|
||||
// Hard lock (editing another user's loaded draft): block EVERY save path
|
||||
// for this key — no parking, no POST. Notify the overlay so the first
|
||||
// blocked attempt (the user's first edit) can prompt before overwriting.
|
||||
if (syncLocked.has(key)) {
|
||||
syncLocked.get(key)?.()
|
||||
return
|
||||
}
|
||||
// Park the latest opts BEFORE the pipeline so the unload flush has
|
||||
// something to send even if the page hides before the debouncer fires.
|
||||
pendingSaveOpts.set(key, opts)
|
||||
@@ -420,6 +438,26 @@ export const UserDraftDbSyncer = {
|
||||
failures.delete(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Hard-block every save for this key (editing another user's loaded draft).
|
||||
* Cancels any in-flight/queued autosave and drops parked opts so a pending
|
||||
* flush can't fire the user's own value either. `onBlockedAttempt` fires on
|
||||
* each subsequent blocked save — the overlay uses it to detect the first
|
||||
* edit. MUST pair with `unlockSync`.
|
||||
*/
|
||||
lockSync(query: UserDraftLastSyncQuery, onBlockedAttempt?: () => void): void {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
syncLocked.set(key, onBlockedAttempt)
|
||||
debouncer.cancel(key)
|
||||
runner.cancel(key)
|
||||
pendingSaveOpts.delete(key)
|
||||
},
|
||||
|
||||
/** Release a `lockSync`; subsequent saves go through normally. */
|
||||
unlockSync(query: UserDraftLastSyncQuery): void {
|
||||
syncLocked.delete(draftKey(query.workspace, query.itemKind, query.path))
|
||||
},
|
||||
|
||||
/** Reactive conflict snapshot (if any) for a draft. */
|
||||
getConflict(query: UserDraftLastSyncQuery): {
|
||||
readonly conflict: DraftConflictInfo | undefined
|
||||
|
||||
@@ -2113,7 +2113,12 @@ export function parseDbInputFromAssetSyntax(path: string): DbInput | null {
|
||||
const [p2, _p3] = _p2.split('/')
|
||||
const [p3, p4] = _p3.split('.')
|
||||
return p1 === 'ducklake'
|
||||
? { type: 'ducklake', ducklake: p2 || 'main', specificTable: p4 ?? p3 }
|
||||
? {
|
||||
type: 'ducklake',
|
||||
ducklake: p2 || 'main',
|
||||
specificTable: p4 ?? p3,
|
||||
specificSchema: p4 ? p3 : undefined
|
||||
}
|
||||
: p1 === 'datatable'
|
||||
? {
|
||||
type: 'database',
|
||||
|
||||
@@ -305,8 +305,11 @@ export async function deployDraft(
|
||||
const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any
|
||||
const d = r.draft ?? r
|
||||
const requestBody = {
|
||||
// Honor a renamed draft path; the URL `path` stays the existing item key.
|
||||
path: d.path ?? path,
|
||||
// Deploy at the draft's intended path: flow/app/raw-app drafts keep the
|
||||
// user-typed path in `draft_path` (a never-deployed item is parked at a
|
||||
// synthetic `u/{user}/draft_{uuid}` storage key). The URL `path` stays
|
||||
// that storage key.
|
||||
path: d.draft_path ?? d.path ?? path,
|
||||
summary: d.summary ?? '',
|
||||
description: d.description ?? '',
|
||||
value: d.value,
|
||||
@@ -327,30 +330,37 @@ export async function deployDraft(
|
||||
await FlowService.updateFlow({ workspace, path, requestBody })
|
||||
}
|
||||
// Then deploy any draft trigger edits, so they aren't dropped with the draft.
|
||||
await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly)
|
||||
await deployDraftTriggers(
|
||||
d.draft_triggers,
|
||||
workspace,
|
||||
d.draft_path ?? d.path ?? path,
|
||||
draftOnly
|
||||
)
|
||||
} else if (kind === 'app') {
|
||||
// `raw_app` is handled above; only visual apps reach here.
|
||||
const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any
|
||||
const d = r.draft ?? {
|
||||
value: r.value,
|
||||
summary: r.summary,
|
||||
policy: r.policy,
|
||||
path: r.path,
|
||||
custom_path: r.custom_path
|
||||
}
|
||||
// custom_path requires admin on app update. Non-admins send undefined so
|
||||
// the backend preserves the existing route (no RequireAdmin 403). For
|
||||
// admins, fall back to the *deployed* route (`r.custom_path`) when the
|
||||
// draft doesn't carry one — the visual-app draft value usually omits
|
||||
// custom_path, and sending `''` would clear the existing route. An
|
||||
// explicit '' in the draft still clears (`'' ?? x === ''`).
|
||||
// A visual-app draft is stored as the *bare* app value (grid/theme/...,
|
||||
// plus a `draft_path` when the path was renamed) — NOT wrapped in
|
||||
// { value, summary, policy } like script/flow drafts. So the deploy value
|
||||
// is the draft object itself; fall back to the deployed value when there's
|
||||
// no draft. `draft_path` and `summary` are draft-only fields mirrored onto
|
||||
// the App value (the editor drops them on deploy), so strip them from the
|
||||
// value and apply them as the deploy path / summary column.
|
||||
const draft = r.draft as Record<string, any> | undefined
|
||||
const { draft_path: draftPath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {}
|
||||
// Policy isn't carried in the app draft, so it comes from the deployed app
|
||||
// (or a default). custom_path requires admin on update; non-admins send
|
||||
// undefined so the backend preserves the existing route. The draft has no
|
||||
// custom_path, so admins fall back to the deployed route (`''` when none).
|
||||
const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin)
|
||||
const requestBody = {
|
||||
value: d.value,
|
||||
summary: d.summary ?? '',
|
||||
policy: d.policy ?? { execution_mode: 'publisher' },
|
||||
path: d.path ?? path,
|
||||
custom_path: isAdmin ? (d.custom_path ?? r.custom_path) : undefined
|
||||
value: appValue,
|
||||
summary: draftSummary ?? r.summary ?? '',
|
||||
policy: r.policy ?? { execution_mode: 'publisher' },
|
||||
// Honor the draft's intended path; `draft_path` holds the user-typed path
|
||||
// for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key.
|
||||
path: draftPath ?? r.path ?? path,
|
||||
custom_path: isAdmin ? (r.custom_path ?? '') : undefined
|
||||
}
|
||||
// Same as flows: draft-only apps have no app row → create;
|
||||
// drafts on a deployed app update it.
|
||||
|
||||
@@ -34,10 +34,26 @@ export interface DraftItem {
|
||||
legacy_draft: boolean
|
||||
/** App is a raw app (deploys via the raw-app endpoints). Always false for non-apps. */
|
||||
raw_app: boolean
|
||||
/** Current user may deploy/discard this draft — matches the server-side check.
|
||||
* Defaults to true when the field is absent (older backend) so a frontend
|
||||
* running ahead of the API doesn't disable every action; the deploy/discard
|
||||
* endpoints enforce permission regardless. */
|
||||
can_write: boolean
|
||||
/** Draft authors at this (path, kind); populated only for the shared
|
||||
* full-page-editor kinds (script/flow/app/raw_app). Feeds the badge circles. */
|
||||
draft_users?: { username?: string | null }[]
|
||||
/** The row is the current user's own draft (or the legacy no-owner row), so
|
||||
* they can deploy/discard it. Always true in the default listing; only the
|
||||
* `allUsers` listing surfaces other users' rows as `false` (view-only).
|
||||
* Defaults to true when the field is absent (older backend). */
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
export async function getDraftItems(workspace: string): Promise<DraftItem[]> {
|
||||
const rows = await DraftService.listDrafts({ workspace })
|
||||
export async function getDraftItems(
|
||||
workspace: string,
|
||||
allUsers: boolean = false
|
||||
): Promise<DraftItem[]> {
|
||||
const rows = await DraftService.listDrafts({ workspace, allUsers: allUsers || undefined })
|
||||
return rows.map((r) => ({
|
||||
kind: r.kind,
|
||||
path: r.path,
|
||||
@@ -45,7 +61,10 @@ export async function getDraftItems(workspace: string): Promise<DraftItem[]> {
|
||||
draft_path: r.draft_path,
|
||||
draft_only: r.draft_only,
|
||||
legacy_draft: r.legacy_draft,
|
||||
raw_app: r.kind === 'raw_app'
|
||||
raw_app: r.kind === 'raw_app',
|
||||
can_write: r.can_write ?? true,
|
||||
draft_users: r.draft_users,
|
||||
mine: r.mine ?? true
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -71,13 +90,16 @@ export interface WorkspaceDraftsHandle {
|
||||
* Re-fetches on mount, when `workspace` changes, and when
|
||||
* `invalidateWorkspaceDrafts(workspace)` is called while mounted.
|
||||
*/
|
||||
export function useWorkspaceDrafts(workspace: () => string | undefined): WorkspaceDraftsHandle {
|
||||
export function useWorkspaceDrafts(
|
||||
workspace: () => string | undefined,
|
||||
allUsers: () => boolean = () => false
|
||||
): WorkspaceDraftsHandle {
|
||||
const res = resource(
|
||||
() => {
|
||||
const ws = workspace()
|
||||
return { ws, v: ws ? (versions[ws] ?? 0) : 0 }
|
||||
return { ws, all: allUsers(), v: ws ? (versions[ws] ?? 0) : 0 }
|
||||
},
|
||||
async ({ ws }) => (ws ? getDraftItems(ws) : [])
|
||||
async ({ ws, all }) => (ws ? getDraftItems(ws, all) : [])
|
||||
)
|
||||
return {
|
||||
get items() {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import { migrateApp } from '$lib/components/apps/migrateApp'
|
||||
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
@@ -17,6 +18,7 @@
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { runResetToDeployed } from '$lib/userDraftToast'
|
||||
|
||||
let app = $state(undefined as (AppWithLastVersion & { value: any }) | undefined)
|
||||
@@ -197,7 +199,14 @@
|
||||
// there's no deployed row (draft-only path).
|
||||
deployedBaseline = backendApp.no_deployed
|
||||
? undefined
|
||||
: (structuredClone(stateSnapshot(backendApp.value)) as App)
|
||||
: // Carry the deployed summary onto the baseline: the autosave mirrors the
|
||||
// summary onto the live App value, so the `discardIf` no-op comparison must
|
||||
// see the deployed summary here too — otherwise a reverted-to-deployed draft
|
||||
// never compares equal (and a summary-only edit still counts as a change).
|
||||
({
|
||||
...(structuredClone(stateSnapshot(backendApp.value)) as App),
|
||||
summary: backendApp.summary
|
||||
} as App)
|
||||
// `other_drafts_users` only computed when `getDraft`; don't clobber the
|
||||
// known list on a `getDraft:false` reload. See /scripts/edit's loader.
|
||||
if (getDraft) {
|
||||
@@ -220,7 +229,9 @@
|
||||
isNewApp = !!backendApp.no_deployed
|
||||
if (backendApp.no_deployed) {
|
||||
backendApp = {
|
||||
summary: '',
|
||||
// Draft-only app: the summary rides on the autosaved App value
|
||||
// (no deployed column to read it from).
|
||||
summary: savedDraftApp?.summary ?? '',
|
||||
value: (savedDraftApp ?? {}) as App,
|
||||
path: page.params.path ?? '',
|
||||
// `execution_mode` required; matches the new-app seed above.
|
||||
@@ -237,11 +248,18 @@
|
||||
no_deployed: true
|
||||
} as unknown as typeof backendApp
|
||||
} else if (savedDraftApp) {
|
||||
backendApp = { ...backendApp, value: savedDraftApp } as typeof backendApp
|
||||
}
|
||||
if (backendApp.is_draft) {
|
||||
loadedFromDraft = true
|
||||
// Deployed app with a draft: swap in the draft value and honor a draft
|
||||
// summary edit (falls back to the deployed summary when the draft has none).
|
||||
backendApp = {
|
||||
...backendApp,
|
||||
value: savedDraftApp,
|
||||
summary: savedDraftApp.summary ?? backendApp.summary
|
||||
} as typeof backendApp
|
||||
}
|
||||
// Per-response, NOT sticky: a later no-own-draft load in the same editor
|
||||
// must reset this so it can't wrongly force overlay mode.
|
||||
const hasOwnDraft = !!backendApp.is_draft
|
||||
loadedFromDraft = hasOwnDraft
|
||||
// Pass both timestamps for DraftEditorModals' staleness compare: `created_at`
|
||||
// is the deploy time, `draft_saved_at` the draft's. Skip `deployedAt` when
|
||||
// `no_deployed` — no baseline to be older than.
|
||||
@@ -255,6 +273,41 @@
|
||||
policy: backendApp_.policy,
|
||||
custom_path: backendApp_.custom_path
|
||||
}
|
||||
// "Load another user's draft" handoff: render their value. Overlay mode (we
|
||||
// have our own draft) hard-locks saves until the user confirms overwriting
|
||||
// it (AppEditor's own autosave is blocked by the lock). See /scripts/edit.
|
||||
const pendingLoad = getDraft
|
||||
? OtherUserDraftLoad.takePending($workspaceStore!, 'app', path)
|
||||
: undefined
|
||||
// Revisiting a path whose overlay was never confirmed/reset: drop the stale
|
||||
// lock so editing our own draft works again. See /scripts/edit's loader.
|
||||
if (!pendingLoad && OtherUserDraftLoad.isActive($workspaceStore!, 'app', path)) {
|
||||
OtherUserDraftLoad.clear($workspaceStore!, 'app', path)
|
||||
}
|
||||
if (pendingLoad) {
|
||||
backendApp = { ...backendApp, value: pendingLoad.value as App } as typeof backendApp
|
||||
if (hasOwnDraft) {
|
||||
// AppEditor `migrateApp`s the value in place on mount (see its
|
||||
// `migratedDeployedBaseline`), so the draft cell settles to the
|
||||
// MIGRATED app. Match it here, else the first post-mount mirror write
|
||||
// would diverge from the raw value and trip the overwrite prompt
|
||||
// before any edit. Mirrors raw_app's bundle-matching baseline.
|
||||
const overlayBaseline = structuredClone($state.snapshot(pendingLoad.value)) as App
|
||||
migrateApp(overlayBaseline)
|
||||
OtherUserDraftLoad.beginOverlay({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'app',
|
||||
path,
|
||||
ownerLabel: pendingLoad.ownerLabel,
|
||||
// AppEditor stores the bare (migrated) App in the draft cell.
|
||||
loadedValue: overlayBaseline,
|
||||
onResetToOwnDraft: async () => {
|
||||
await loadApp({ getDraft: true })
|
||||
redraw++
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Assign the fresh response onto `app`. The path-change $effect sets
|
||||
// `app = undefined` first, unmounting AppEditor and releasing the UserDraft
|
||||
// entry, so the remount starts fresh — no local discard is needed here
|
||||
@@ -337,6 +390,8 @@
|
||||
itemKind="app"
|
||||
{path}
|
||||
{otherDraftsUsers}
|
||||
draftOnly={isNewApp}
|
||||
hasOwnDraft={loadedFromDraft}
|
||||
onLoadFromServer={async () => {
|
||||
// AppEditor's `stateApp` is captured at mount and ignores prop changes,
|
||||
// so `redraw++` remounts it against the fresh `app`.
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
|
||||
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
||||
import { page } from '$app/state'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
type RawAppData,
|
||||
DEFAULT_DATA,
|
||||
extractDataConfig
|
||||
} from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { importStore } from '$lib/components/apps/store'
|
||||
import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { armRestartOnFirstInteraction, runResetToDeployed } from '$lib/userDraftToast'
|
||||
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
@@ -121,24 +126,6 @@
|
||||
/** Normalize a raw-app `value` into the editor's `data` config, supporting
|
||||
* the old nested `creation` / `datatables` shapes. `undefined` when the
|
||||
* value carries no data config (caller keeps the current/default `data`). */
|
||||
function extractDataConfig(value: any): RawAppData | undefined {
|
||||
if (value?.data) {
|
||||
const d = value.data
|
||||
// Handle old nested creation format
|
||||
if (d.creation) {
|
||||
return {
|
||||
tables: d.tables ?? [],
|
||||
datatable: d.creation.datatable,
|
||||
schema: d.creation.schema
|
||||
}
|
||||
}
|
||||
return d
|
||||
} else if (value?.datatables) {
|
||||
return { ...DEFAULT_DATA, tables: value.datatables }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractRawApp(app: any) {
|
||||
runnables = app.value.runnables
|
||||
// Support old formats and new format
|
||||
@@ -276,9 +263,10 @@
|
||||
}
|
||||
draftSync.recordRemoteSync(backendApp.draft_saved_at as string | undefined)
|
||||
isNewApp = !!backendApp.no_deployed
|
||||
if (backendApp.is_draft) {
|
||||
loadedFromDraft = true
|
||||
}
|
||||
// Per-response, NOT sticky: a later no-own-draft load in the same editor
|
||||
// must reset this so it can't wrongly force overlay mode.
|
||||
const hasOwnDraft = !!backendApp.is_draft
|
||||
loadedFromDraft = hasOwnDraft
|
||||
// Deploy timestamp is `backendApp.created_at`; skip when `no_deployed`.
|
||||
// See /apps/edit's loader.
|
||||
draftSavedAt = backendApp.draft_saved_at as string | undefined
|
||||
@@ -356,6 +344,47 @@
|
||||
// $effect re-mirrors them into `draftSync.draft`; the first write is
|
||||
// swallowed by `acquireEntry`'s seed guard, so no POST.
|
||||
extractRawApp(backendApp)
|
||||
// "Load another user's draft" handoff: their value is a flat RawAppDraft
|
||||
// bundle. Override the local pieces with it; overlay mode (we have our own
|
||||
// draft) hard-locks saves until the user confirms overwriting. The bundle
|
||||
// $effect's write to `draftSync.draft` is blocked by the lock. See /scripts.
|
||||
const pendingLoad = getDraft
|
||||
? OtherUserDraftLoad.takePending($workspaceStore!, 'raw_app', path)
|
||||
: undefined
|
||||
// Revisiting a path whose overlay was never confirmed/reset: drop the stale
|
||||
// lock so editing our own draft works again. See /scripts/edit's loader.
|
||||
if (!pendingLoad && OtherUserDraftLoad.isActive($workspaceStore!, 'raw_app', path)) {
|
||||
OtherUserDraftLoad.clear($workspaceStore!, 'raw_app', path)
|
||||
}
|
||||
if (pendingLoad) {
|
||||
const v = pendingLoad.value as RawAppDraft
|
||||
files = v.files ?? {}
|
||||
runnables = v.runnables ?? {}
|
||||
data = v.data ?? { ...DEFAULT_DATA }
|
||||
summary = v.summary ?? ''
|
||||
policy = v.policy ?? {}
|
||||
newPath = v.draft_path ?? savedApp?.path ?? path
|
||||
if (hasOwnDraft) {
|
||||
OtherUserDraftLoad.beginOverlay({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'raw_app',
|
||||
path,
|
||||
ownerLabel: pendingLoad.ownerLabel,
|
||||
// Mirror the bundle the persist-$effect produces from these pieces,
|
||||
// so the cascade write that re-mirrors them isn't seen as an edit.
|
||||
loadedValue: {
|
||||
files,
|
||||
runnables,
|
||||
data,
|
||||
summary,
|
||||
policy,
|
||||
custom_path: savedApp?.custom_path,
|
||||
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
|
||||
} as RawAppDraft,
|
||||
onResetToOwnDraft: () => loadApp({ getDraft: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run(() => {
|
||||
@@ -465,6 +494,8 @@
|
||||
itemKind="raw_app"
|
||||
{path}
|
||||
{otherDraftsUsers}
|
||||
draftOnly={isNewApp}
|
||||
hasOwnDraft={loadedFromDraft}
|
||||
onLoadFromServer={() => loadApp()}
|
||||
getLocalDraft={() => draftSync.draft}
|
||||
bind:othersModalOpen
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
||||
import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte'
|
||||
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
import { type OtherDraftUser } from '$lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte'
|
||||
import type { ScheduleTrigger } from '$lib/components/triggers'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
@@ -333,9 +334,10 @@
|
||||
draftSync.recordRemoteSync(backendFlow.draft_saved_at as string | undefined)
|
||||
// Re-evaluate per load: true for draft-only paths, false once deployed.
|
||||
isNewFlow = !!backendFlow.no_deployed
|
||||
if (backendFlow.is_draft) {
|
||||
loadedFromDraft = true
|
||||
}
|
||||
// Per-response, NOT sticky: a later no-own-draft load in the same editor
|
||||
// must reset this so it can't wrongly force overlay mode.
|
||||
const hasOwnDraft = !!backendFlow.is_draft
|
||||
loadedFromDraft = hasOwnDraft
|
||||
// Pass both timestamps for DraftEditorModals' staleness compare: `edited_at`
|
||||
// is the deploy time (from `flow_version.created_at`), `draft_saved_at` the draft's.
|
||||
draftSavedAt = backendFlow.draft_saved_at as string | undefined
|
||||
@@ -358,10 +360,43 @@
|
||||
const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined
|
||||
if (renderedDraftPath) flowInitialPath = renderedDraftPath
|
||||
|
||||
// Overwrite the cell with the effective flow. The first cell write after
|
||||
// `acquireEntry` is swallowed by the syncer's seed guard, so no POST.
|
||||
flow = effectiveFlow
|
||||
draftSync.draft = effectiveFlow
|
||||
// "Load another user's draft" handoff: render their value over the deployed
|
||||
// metadata. Overlay mode (we have our own draft) never saves until the user
|
||||
// confirms overwriting it. See /scripts/edit's loader.
|
||||
const pendingLoad = getDraft
|
||||
? OtherUserDraftLoad.takePending($workspaceStore!, 'flow', flowDraftPath)
|
||||
: undefined
|
||||
// Revisiting a path whose overlay was never confirmed/reset: drop the stale
|
||||
// lock so editing our own draft works again. See /scripts/edit's loader.
|
||||
if (!pendingLoad && OtherUserDraftLoad.isActive($workspaceStore!, 'flow', flowDraftPath)) {
|
||||
OtherUserDraftLoad.clear($workspaceStore!, 'flow', flowDraftPath)
|
||||
}
|
||||
const flowToRender: Flow = pendingLoad
|
||||
? ({ ...deployedFlow, ...(pendingLoad.value as object) } as Flow)
|
||||
: effectiveFlow
|
||||
flow = flowToRender
|
||||
if (pendingLoad && hasOwnDraft) {
|
||||
OtherUserDraftLoad.beginOverlay({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'flow',
|
||||
path: flowDraftPath,
|
||||
ownerLabel: pendingLoad.ownerLabel,
|
||||
loadedValue: flowToRender,
|
||||
// Force a builder remount (like nav does) — FlowBuilder captures the
|
||||
// flow at mount, so reloading alone leaves the foreign graph on screen.
|
||||
onResetToOwnDraft: async () => {
|
||||
renderEditor = false
|
||||
await loadFlow({ getDraft: true })
|
||||
}
|
||||
})
|
||||
// Seed so the bound value updates WITHOUT a POST (the lock blocks it
|
||||
// anyway, but seeding avoids tripping the edit prompt).
|
||||
UserDraft.seed('flow', flowDraftPath, flowToRender, { workspace: $workspaceStore! })
|
||||
} else {
|
||||
// Overwrite the cell with the effective flow. The first cell write after
|
||||
// `acquireEntry` is swallowed by the syncer's seed guard, so no POST.
|
||||
draftSync.draft = flowToRender
|
||||
}
|
||||
|
||||
flowBuilder?.setDraftTriggers(undefined)
|
||||
|
||||
@@ -447,6 +482,8 @@
|
||||
itemKind="flow"
|
||||
path={flowDraftPath}
|
||||
{otherDraftsUsers}
|
||||
draftOnly={isNewFlow}
|
||||
hasOwnDraft={loadedFromDraft}
|
||||
onLoadFromServer={() => loadFlow()}
|
||||
getLocalDraft={() => draftSync.draft}
|
||||
bind:othersModalOpen
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte'
|
||||
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
|
||||
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
|
||||
|
||||
@@ -292,9 +293,10 @@
|
||||
// timestamp the backend can stale-check. `undefined` (no draft) clears
|
||||
// it, making the next save take the "first push" branch.
|
||||
draftSync.recordRemoteSync(backendScript.draft_saved_at as string | undefined)
|
||||
if (backendScript.is_draft) {
|
||||
loadedFromDraft = true
|
||||
}
|
||||
// Per-response, NOT sticky: navigating to another path in the same editor
|
||||
// must reset this, else a later no-own-draft load wrongly enters overlay.
|
||||
const hasOwnDraft = !!backendScript.is_draft
|
||||
loadedFromDraft = hasOwnDraft
|
||||
// Pass both timestamps through for DraftEditorModals' staleness compare:
|
||||
// `created_at` is the latest deploy, `draft_saved_at` the draft's save.
|
||||
draftSavedAt = backendScript.draft_saved_at as string | undefined
|
||||
@@ -307,23 +309,55 @@
|
||||
? { ...deployedScript, ...draftFromBackend }
|
||||
: (deployedScript as EditableScript)
|
||||
savedScript = structuredClone($state.snapshot(effectiveScript))
|
||||
const parentHash = topHash ?? backendScript.hash
|
||||
// Baseline for the autosave `discardIf`: the deployed script with the
|
||||
// same `parent_hash` graft the seed below applies, so the unedited
|
||||
// draft compares equal. `undefined` when there's no deployed row.
|
||||
// same `parent_hash` graft the seed below applies, so the unedited draft
|
||||
// compares equal. `undefined` when there's no deployed row.
|
||||
deployedBaseline = backendScript.no_deployed
|
||||
? undefined
|
||||
: structuredClone(
|
||||
$state.snapshot({
|
||||
...deployedScript,
|
||||
parent_hash: topHash ?? backendScript.hash
|
||||
})
|
||||
)
|
||||
// `parent_hash` is grafted on so the editor's compile reuses the
|
||||
// deployed lock. The first cell write after `acquireEntry` is swallowed
|
||||
// by the syncer's seed guard, so this load doesn't POST.
|
||||
draftSync.draft = {
|
||||
...effectiveScript,
|
||||
parent_hash: topHash ?? backendScript.hash
|
||||
: structuredClone($state.snapshot({ ...deployedScript, parent_hash: parentHash }))
|
||||
// "Load another user's draft" handoff: show their value over the
|
||||
// deployed metadata. If WE already have a draft → overlay mode (never
|
||||
// saved until the user confirms overwriting their own draft).
|
||||
const pendingLoad = getDraft
|
||||
? OtherUserDraftLoad.takePending($workspaceStore!, 'script', draftPath)
|
||||
: undefined
|
||||
// Revisiting a path whose overlay was never confirmed/reset (e.g. the user
|
||||
// navigated away mid-load): drop the stale lock so editing our own draft
|
||||
// works again.
|
||||
if (!pendingLoad && OtherUserDraftLoad.isActive($workspaceStore!, 'script', draftPath)) {
|
||||
OtherUserDraftLoad.clear($workspaceStore!, 'script', draftPath)
|
||||
}
|
||||
if (pendingLoad) {
|
||||
const loadedValue = {
|
||||
...deployedScript,
|
||||
...(pendingLoad.value as object),
|
||||
parent_hash: parentHash
|
||||
} as EditableScript
|
||||
if (hasOwnDraft) {
|
||||
OtherUserDraftLoad.beginOverlay({
|
||||
workspace: $workspaceStore!,
|
||||
itemKind: 'script',
|
||||
path: draftPath,
|
||||
ownerLabel: pendingLoad.ownerLabel,
|
||||
loadedValue,
|
||||
onResetToOwnDraft: () => loadScript({ getDraft: true })
|
||||
})
|
||||
// Seed so the bound value updates WITHOUT a POST (the lock would
|
||||
// block it anyway, but seeding avoids tripping the edit prompt).
|
||||
UserDraft.seed('script', draftPath, loadedValue, { workspace: $workspaceStore! })
|
||||
} else {
|
||||
// No own draft: adopt their value as ours (autosaves normally).
|
||||
draftSync.draft = loadedValue
|
||||
}
|
||||
} else {
|
||||
// `parent_hash` is grafted on so the editor's compile reuses the
|
||||
// deployed lock. The first cell write after `acquireEntry` is swallowed
|
||||
// by the syncer's seed guard, so this load doesn't POST.
|
||||
draftSync.draft = {
|
||||
...effectiveScript,
|
||||
parent_hash: parentHash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +439,8 @@
|
||||
itemKind="script"
|
||||
path={page.params.path ?? ''}
|
||||
{otherDraftsUsers}
|
||||
draftOnly={(savedScript as any)?.no_deployed === true}
|
||||
hasOwnDraft={loadedFromDraft}
|
||||
onLoadFromServer={() => loadScript()}
|
||||
getLocalDraft={() => draftSync.draft}
|
||||
bind:othersModalOpen
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import TestDevHeader from './TestDevHeader.svelte'
|
||||
|
||||
let { children } = $props()
|
||||
</script>
|
||||
|
||||
<TestDevHeader />
|
||||
{@render children()}
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { UserService } from '$lib/gen'
|
||||
import { OpenAPI } from '$lib/gen/core/OpenAPI'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
// The /test_dev pages render the whitelabel SDK components, which authenticate
|
||||
// via a bearer token + workspace exactly like the React SDK's initializeClients
|
||||
// (OpenAPI.TOKEN + workspaceStore + userStore). These routes are outside the
|
||||
// (logged) layout, so none of that is wired automatically — this header is the
|
||||
// single place to log in, set the token, and pick the workspace for all of them.
|
||||
|
||||
const TOKEN_KEY = 'test_dev_token'
|
||||
const WORKSPACE_KEY = 'workspace'
|
||||
|
||||
let workspace = $state('admins')
|
||||
let email = $state('admin@windmill.dev')
|
||||
let password = $state('changeme')
|
||||
let token = $state('')
|
||||
let loading = $state(false)
|
||||
|
||||
function persistToken(t: string | undefined) {
|
||||
try {
|
||||
if (t) localStorage.setItem(TOKEN_KEY, t)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function applyToken(t: string | undefined) {
|
||||
token = t ?? ''
|
||||
OpenAPI.TOKEN = t || undefined
|
||||
persistToken(t)
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
if (!$workspaceStore) return
|
||||
$userStore = await getUserExt($workspaceStore)
|
||||
}
|
||||
|
||||
function applyWorkspace(w: string) {
|
||||
if (!w) return
|
||||
workspaceStore.set(w)
|
||||
try {
|
||||
localStorage.setItem(WORKSPACE_KEY, w)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (loading) return
|
||||
loading = true
|
||||
try {
|
||||
// Returns the session token as plaintext; the bearer token is the
|
||||
// preferred SDK auth method (the cookie is browser convenience only).
|
||||
const t = await UserService.login({ requestBody: { email, password } })
|
||||
applyToken(t)
|
||||
applyWorkspace(workspace)
|
||||
await loadUser()
|
||||
sendUserToast(`Logged in as ${$userStore?.username ?? email}`)
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Login failed: ${err?.body ?? err?.message ?? err}`, true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setTokenManually() {
|
||||
if (!token) {
|
||||
sendUserToast('Enter a token first', true)
|
||||
return
|
||||
}
|
||||
applyToken(token)
|
||||
applyWorkspace(workspace)
|
||||
await loadUser()
|
||||
sendUserToast('Token set')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
applyToken(undefined)
|
||||
$userStore = undefined
|
||||
sendUserToast('Token cleared')
|
||||
}
|
||||
|
||||
// Restore a persisted session on mount so a reload stays authenticated.
|
||||
onMount(() => {
|
||||
let storedWs: string | null = null
|
||||
let storedToken: string | null = null
|
||||
try {
|
||||
storedWs = localStorage.getItem(WORKSPACE_KEY)
|
||||
storedToken = localStorage.getItem(TOKEN_KEY)
|
||||
} catch {}
|
||||
if (storedWs) workspace = storedWs
|
||||
else if ($workspaceStore) workspace = $workspaceStore
|
||||
if (storedToken) applyToken(storedToken)
|
||||
applyWorkspace(workspace)
|
||||
if (storedToken) void loadUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="sticky top-0 z-50 flex flex-wrap items-end gap-3 border-b bg-surface-secondary px-4 py-2 text-xs"
|
||||
>
|
||||
<label class="flex flex-col gap-1">
|
||||
workspace
|
||||
<TextInput bind:value={workspace} size="sm" inputProps={{ placeholder: 'admins' }} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
email
|
||||
<TextInput bind:value={email} size="sm" inputProps={{ placeholder: 'admin@windmill.dev' }} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
password
|
||||
<TextInput bind:value={password} size="sm" inputProps={{ type: 'password' }} />
|
||||
</label>
|
||||
<Button {loading} size="xs" onclick={login}>Log in</Button>
|
||||
|
||||
<label class="flex flex-col gap-1 grow min-w-48">
|
||||
token
|
||||
<TextInput
|
||||
bind:value={token}
|
||||
size="sm"
|
||||
inputProps={{ placeholder: 'paste a token to set manually' }}
|
||||
/>
|
||||
</label>
|
||||
<Button variant="default" size="xs" onclick={setTokenManually}>Set token</Button>
|
||||
<Button variant="subtle" size="xs" onclick={logout}>Clear</Button>
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto whitespace-nowrap">
|
||||
{#if $userStore}
|
||||
<span class="text-green-600 font-semibold">●</span>
|
||||
<span class="text-secondary">{$userStore.username} @ {$workspaceStore}</span>
|
||||
{:else if token}
|
||||
<span class="text-orange-500 font-semibold">●</span>
|
||||
<span class="text-secondary">token set, no user</span>
|
||||
{:else}
|
||||
<span class="text-tertiary font-semibold">●</span>
|
||||
<span class="text-tertiary">not authenticated</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import AppWrapper from '$lib/components/AppWrapper.svelte'
|
||||
import type { App } from '$lib/components/apps/types'
|
||||
import type { Policy } from '$lib/gen'
|
||||
|
||||
// Auth (workspace + token + user) is wired by the shared TestDevHeader in the
|
||||
// test_dev layout. AppEditor self-acquires its UserDraft handle from `path`, so
|
||||
// autosave + the indicator engage once a workspace exists (AppWrapper gates the
|
||||
// editor on it). Mirrors the React SDK's create mode.
|
||||
|
||||
let app: App = $state({
|
||||
grid: [],
|
||||
fullscreen: false,
|
||||
unusedInlineScripts: [],
|
||||
hiddenInlineScripts: [],
|
||||
theme: undefined
|
||||
})
|
||||
|
||||
let policy: Policy = { execution_mode: 'publisher' }
|
||||
</script>
|
||||
|
||||
<AppWrapper {app} path="u/admin/foo_app" newPath="u/admin/foo_app" summary="" {policy} newApp />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user