From 4a57df7c04bfead838fd9073e7ce5e60abb96cbc Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 30 Jun 2026 13:18:27 +0300 Subject: [PATCH 01/23] fix(task): release runFunc when a task reaches a terminal state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A completed task kept its runFunc closure for the manager's 10-minute run-retention window. When the closure captured large state (parsed inputs, accumulators, intermediate outputs), that state stayed live for every retained task — a leak that scales with the number of past runs. Release runFunc once executeTask finishes: it is invoked nowhere else and retries are already exhausted by then. Completed tasks keep only their lightweight result and snapshot for viewing. --- task/worker.go | 10 ++++++ task/worker_release_test.go | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 task/worker_release_test.go diff --git a/task/worker.go b/task/worker.go index 05d904ba..4b649d9d 100644 --- a/task/worker.go +++ b/task/worker.go @@ -162,6 +162,16 @@ func (w *worker) executeTask(task *Task) { // Execute with retry logic w.executeWithRetry(task) + + // The task is now terminal — executeWithRetry only returns once retries are + // exhausted, and runFunc is invoked nowhere else. Release it so the state the + // task closure captured (parsed inputs, accumulators, large intermediate + // outputs) is collected immediately instead of being pinned for the manager's + // run-retention window. Completed tasks are kept only for viewing; the + // lightweight result and snapshot carry everything that surface needs. + task.mu.Lock() + task.runFunc = nil + task.mu.Unlock() } // executeWithRetry handles task execution with exponential backoff retry diff --git a/task/worker_release_test.go b/task/worker_release_test.go new file mode 100644 index 00000000..39b1f9cb --- /dev/null +++ b/task/worker_release_test.go @@ -0,0 +1,64 @@ +package task + +import ( + "runtime" + "testing" + "time" + + flanksourceContext "github.com/flanksource/commons/context" +) + +type heavyPayload struct{ buf []byte } + +// enqueueClosurePinning starts a task whose closure captures p (and nothing the +// test stack still holds). It is a helper so that, once it returns, the only +// live reference to p is the task's runFunc closure — letting the test prove +// that closure (and p) is released when the task completes. +func enqueueClosurePinning(m *Manager, p *heavyPayload) *Task { + task := m.newTask("heavy") + task.runFunc = func(flanksourceContext.Context, *Task) error { + runtime.KeepAlive(p) + return nil + } + m.enqueue(task) + return task +} + +// TestRunFuncReleasedAfterCompletion verifies the worker drops a completed +// task's runFunc, so the (potentially large) state the task closure captured is +// not pinned for the manager's run-retention window. Without the release the +// finalizer never fires and the test fails. +func TestRunFuncReleasedAfterCompletion(t *testing.T) { + manager := newTestManager(1) + manager.noRender.Store(true) + + collected := make(chan struct{}) + captured := &heavyPayload{buf: make([]byte, 8<<20)} // 8 MiB + runtime.SetFinalizer(captured, func(*heavyPayload) { close(collected) }) + + task := enqueueClosurePinning(manager, captured) + captured = nil // drop the test's own reference; only the closure holds it now + + select { + case <-task.doneChan: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for task completion") + } + + task.mu.Lock() + released := task.runFunc == nil + task.mu.Unlock() + if !released { + t.Fatal("runFunc was not released after the task completed") + } + + for i := 0; i < 50; i++ { + runtime.GC() + select { + case <-collected: + return // payload was finalized — the closure is no longer pinned + case <-time.After(20 * time.Millisecond): + } + } + t.Fatal("captured payload was not collected; a completed task still pins its closure state") +} From e9dda7e29975c0f2292518f7dd054a4ef684da31 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 5 Jul 2026 11:16:57 +0300 Subject: [PATCH 02/23] feat: add oxlint custom rules linting for clicky-ui webapp --- .gitignore | 1 + Makefile | 5 +- examples/enitity/webapp/.oxlintrc.json | 17 ++ examples/enitity/webapp/package.json | 2 + examples/enitity/webapp/pnpm-lock.yaml | 209 ++++++++++++++++++++ examples/enitity/webapp/pnpm-workspace.yaml | 1 + examples/enitity/webapp/src/App.tsx | 34 +++- examples/enitity/webapp/src/ChatWidget.tsx | 17 +- examples/enitity/webapp/tsconfig.json | 16 +- examples/enitity/webapp/vite.config.ts | 11 +- 10 files changed, 295 insertions(+), 18 deletions(-) create mode 100644 examples/enitity/webapp/.oxlintrc.json diff --git a/.gitignore b/.gitignore index bc52ba71..dff4258c 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ clicky !task/ui/tsconfig.json !examples/enitity/Taskfile.yaml !examples/enitity/webapp/index.html +!examples/enitity/webapp/.oxlintrc.json !examples/enitity/webapp/package.json !examples/enitity/webapp/pnpm-lock.yaml !examples/enitity/webapp/pnpm-workspace.yaml diff --git a/Makefile b/Makefile index 6fbd8579..7bf24f9d 100644 --- a/Makefile +++ b/Makefile @@ -60,11 +60,14 @@ install: build mv clicky /usr/local/bin/clicky # Run linter -.PHONY: lint +.PHONY: lint lint-clicky-ui lint: golangci-lint $(GOLANGCI_LINT) run ./... go vet ./... +lint-clicky-ui: + cd examples/enitity/webapp && pnpm install --frozen-lockfile && pnpm run lint:clicky-ui + .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) diff --git a/examples/enitity/webapp/.oxlintrc.json b/examples/enitity/webapp/.oxlintrc.json new file mode 100644 index 00000000..396058fa --- /dev/null +++ b/examples/enitity/webapp/.oxlintrc.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "jsPlugins": [ + { + "name": "clicky-ui", + "specifier": "../../../../clicky-ui/packages/ui/oxlint-plugins/clicky-ui.js" + } + ], + "rules": { + "clicky-ui/prefer-clicky-components": "error", + "clicky-ui/no-adhoc-overlay": "error", + "clicky-ui/prefer-tailwind-classes": "error", + "clicky-ui/prefer-theme-tokens": "error", + "clicky-ui/prefer-clicky-icons": "error" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/examples/enitity/webapp/package.json b/examples/enitity/webapp/package.json index 8494a71c..5fff551a 100644 --- a/examples/enitity/webapp/package.json +++ b/examples/enitity/webapp/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "lint:clicky-ui": "oxlint -c .oxlintrc.json src vite.config.ts --quiet", "build": "tsc -b && vite build", "preview": "vite preview" }, @@ -35,6 +36,7 @@ "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^5.0.4", + "oxlint": "catalog:", "react-grab": "catalog:", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", diff --git a/examples/enitity/webapp/pnpm-lock.yaml b/examples/enitity/webapp/pnpm-lock.yaml index 89d04cd3..b84d73d7 100644 --- a/examples/enitity/webapp/pnpm-lock.yaml +++ b/examples/enitity/webapp/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + oxlint: + specifier: ^1.60.0 + version: 1.72.0 react-grab: specifier: ^0.1.47 version: 0.1.47 @@ -545,6 +548,120 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@phosphor-icons/core@2.1.1': resolution: {integrity: sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ==} @@ -2203,6 +2320,19 @@ packages: resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} engines: {node: '>=20'} + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-event@6.0.1: resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} engines: {node: '>=16.17'} @@ -3252,6 +3382,63 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + '@phosphor-icons/core@2.1.1': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': @@ -5384,6 +5571,28 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 + oxlint@1.72.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + p-event@6.0.1: dependencies: p-timeout: 6.1.4 diff --git a/examples/enitity/webapp/pnpm-workspace.yaml b/examples/enitity/webapp/pnpm-workspace.yaml index 48983d04..8a1130d7 100644 --- a/examples/enitity/webapp/pnpm-workspace.yaml +++ b/examples/enitity/webapp/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: clsx: ^2.1.1 lucide-react: ^0.469.0 marked: ^15.0.0 + oxlint: ^1.60.0 react-grab: ^0.1.47 react-router-dom: ^7.18.1 shiki: ^1.24.0 diff --git a/examples/enitity/webapp/src/App.tsx b/examples/enitity/webapp/src/App.tsx index 2708251c..00d66fd4 100644 --- a/examples/enitity/webapp/src/App.tsx +++ b/examples/enitity/webapp/src/App.tsx @@ -1,5 +1,11 @@ import { useMemo } from "react"; -import { Link, Routes, Route, useLocation, useNavigate } from "react-router-dom"; +import { + Link, + Routes, + Route, + useLocation, + useNavigate, +} from "react-router-dom"; import { DensitySwitcher, EntityExplorerApp, @@ -8,6 +14,11 @@ import { ThemeSwitcher, type RouterAdapter, } from "@flanksource/clicky-ui"; +import { + UiLayoutDashboard, + UiLink, + UiMarkdown, +} from "@flanksource/clicky-ui/icons"; import { apiClient } from "./api"; import { ChatWidget } from "./ChatWidget"; import { LinkExampleCommandPage, LinkExamplesPage } from "./LinkExamplesPage"; @@ -42,9 +53,12 @@ export function App() {
-
Clicky Entity Example
+
+ Clicky Entity Example +
- Metadata-driven explorer plus interactive Link and LinkCommand demos + Metadata-driven explorer plus interactive Link and LinkCommand + demos
@@ -57,7 +71,10 @@ export function App() { className={topNavLinkClass(activeSection === "explorer")} aria-current={activeSection === "explorer" ? "page" : undefined} > - + Explorer - + Link Examples - + Markdown @@ -88,7 +105,10 @@ export function App() {
} /> - } /> + } + /> } /> clickyOperationsToTools(operations), [operations]); + const tools = useMemo( + () => clickyOperationsToTools(operations), + [operations], + ); return ( diff --git a/examples/enitity/webapp/tsconfig.json b/examples/enitity/webapp/tsconfig.json index f8c0dfb1..3409e401 100644 --- a/examples/enitity/webapp/tsconfig.json +++ b/examples/enitity/webapp/tsconfig.json @@ -15,11 +15,21 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "types": ["vite/client"], "baseUrl": ".", "paths": { - "@flanksource/clicky-ui": ["../../../../clicky-ui/packages/ui/dist/index.d.ts"], - "@flanksource/clicky-ui/chat": ["../../../../clicky-ui/packages/ui/dist/chat.d.ts"], - "@flanksource/clicky-ui/ai": ["../../../../clicky-ui/packages/ui/dist/ai.d.ts"], + "@flanksource/clicky-ui": [ + "../../../../clicky-ui/packages/ui/dist/index.d.ts" + ], + "@flanksource/clicky-ui/chat": [ + "../../../../clicky-ui/packages/ui/dist/chat.d.ts" + ], + "@flanksource/clicky-ui/ai": [ + "../../../../clicky-ui/packages/ui/dist/ai.d.ts" + ], + "@flanksource/clicky-ui/icons": [ + "../../../../clicky-ui/packages/ui/dist/icons.d.ts" + ], "@flanksource/clicky-ui/styles.css": [ "../../../../clicky-ui/packages/ui/dist/styles.css" ], diff --git a/examples/enitity/webapp/vite.config.ts b/examples/enitity/webapp/vite.config.ts index 2ce0ebd6..030c6a4f 100644 --- a/examples/enitity/webapp/vite.config.ts +++ b/examples/enitity/webapp/vite.config.ts @@ -7,7 +7,10 @@ import { defineConfig } from "vite"; // `entity-demo serve-ui --dev`, so Vite's proxy targets the same process. const apiTarget = process.env.CLICKY_EXAMPLE_API_URL || "http://localhost:8080"; -const clickyUiDist = path.resolve(__dirname, "../../../../clicky-ui/packages/ui/dist"); +const clickyUiDist = path.resolve( + __dirname, + "../../../../clicky-ui/packages/ui/dist", +); export default defineConfig({ // The example app is embedded and served from "/", so root-relative assets @@ -17,9 +20,13 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), - "@flanksource/clicky-ui/styles.css": path.join(clickyUiDist, "styles.css"), + "@flanksource/clicky-ui/styles.css": path.join( + clickyUiDist, + "styles.css", + ), "@flanksource/clicky-ui/chat": path.join(clickyUiDist, "chat.js"), "@flanksource/clicky-ui/ai": path.join(clickyUiDist, "ai.js"), + "@flanksource/clicky-ui/icons": path.join(clickyUiDist, "icons.js"), "@flanksource/clicky-ui": path.join(clickyUiDist, "index.js"), }, dedupe: ["react", "react-dom", "@tanstack/react-query"], From 8e4b3bc7fd1dffe466ac3cfac7bd6f6def374dfd Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 5 Jul 2026 11:17:08 +0300 Subject: [PATCH 03/23] feat: add support for hidden flags in CLI and schema generation --- flags/binding.go | 6 ++++ flags/parser.go | 1 + flags/parser_test.go | 32 ++++++++++++++++++ flags/types.go | 1 + rpc/converter.go | 8 +++-- rpc/converter_flags_test.go | 65 +++++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 rpc/converter_flags_test.go diff --git a/flags/binding.go b/flags/binding.go index 9164d257..dccb95a0 100644 --- a/flags/binding.go +++ b/flags/binding.go @@ -126,5 +126,11 @@ func BindFlag(cmd *cobra.Command, info FieldInfo) *FlagValue { _ = cmd.MarkFlagRequired(info.FlagName) } + // Hidden flags stay usable on the CLI but are dropped from --help and from + // generated tool/OpenAPI schemas (the RPC converter skips flag.Hidden). + if info.Hidden && info.FlagName != "" { + _ = cmd.Flags().MarkHidden(info.FlagName) + } + return fv } diff --git a/flags/parser.go b/flags/parser.go index e1ffeda5..e14e5141 100644 --- a/flags/parser.go +++ b/flags/parser.go @@ -73,6 +73,7 @@ func parseStructFieldsRecursive(structType reflect.Type, fieldPath []int, fields DefaultValue: field.Tag.Get("default"), ShortFlag: shortFlag, Required: field.Tag.Get("required") == "true", + Hidden: field.Tag.Get("hidden") == "true", IsStdin: isStdin, IsArgs: isArgs, } diff --git a/flags/parser_test.go b/flags/parser_test.go index de324754..607fe9a1 100644 --- a/flags/parser_test.go +++ b/flags/parser_test.go @@ -6,6 +6,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/spf13/cobra" "github.com/flanksource/commons/duration" ) @@ -75,6 +76,11 @@ type DisabledFlagOptions struct { Active int `flag:"active" help:"Active"` } +type HiddenFlagOptions struct { + DBURL string `flag:"db-url" hidden:"true" help:"Infra DSN"` + Name string `flag:"name" help:"Name"` +} + var _ = Describe("Flags", func() { Context("when parsing args struct", func() { It("should extract args field correctly", func() { @@ -125,6 +131,32 @@ var _ = Describe("Flags", func() { }) }) + Context("when parsing hidden flags", func() { + It("should mark a hidden:\"true\" field and leave others visible", func() { + fields, err := ParseStructFields(reflect.TypeOf(HiddenFlagOptions{})) + Expect(err).ToNot(HaveOccurred()) + byName := map[string]FieldInfo{} + for _, f := range fields { + byName[f.FlagName] = f + } + Expect(byName["db-url"].Hidden).To(BeTrue()) + Expect(byName["name"].Hidden).To(BeFalse()) + }) + + It("should hide the bound cobra flag so it is dropped from help and schemas", func() { + cmd := &cobra.Command{Use: "do"} + fields, err := ParseStructFields(reflect.TypeOf(HiddenFlagOptions{})) + Expect(err).ToNot(HaveOccurred()) + for _, f := range fields { + BindFlag(cmd, f) + } + dbURL := cmd.Flags().Lookup("db-url") + Expect(dbURL).ToNot(BeNil()) + Expect(dbURL.Hidden).To(BeTrue()) + Expect(cmd.Flags().Lookup("name").Hidden).To(BeFalse()) + }) + }) + Context("when parsing simple struct", func() { It("should extract all direct fields with metadata", func() { fields, err := ParseStructFields(reflect.TypeOf(BaseOptions{})) diff --git a/flags/types.go b/flags/types.go index af30d227..3fa7d772 100644 --- a/flags/types.go +++ b/flags/types.go @@ -39,6 +39,7 @@ type FieldInfo struct { DefaultValue string ShortFlag string Required bool + Hidden bool IsStdin bool IsArgs bool } diff --git a/rpc/converter.go b/rpc/converter.go index 40c2cbb4..2719dd6e 100644 --- a/rpc/converter.go +++ b/rpc/converter.go @@ -74,8 +74,12 @@ func (c *Converter) ConvertCommand(cmd *cobra.Command) (*RPCOperation, error) { } } - // Process flags - cmd.Flags().VisitAll(func(flag *pflag.Flag) { + // Process flags. Use LocalNonPersistentFlags so only this command's own + // operation flags become schema parameters — never the inherited persistent + // globals (format/log-level/properties/config/company/entity/limit/...) that + // cobra folds into Flags(), nor the command's own persistent flags which + // exist to cascade to children rather than parameterize this operation. + cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { if flag.Hidden { return } diff --git a/rpc/converter_flags_test.go b/rpc/converter_flags_test.go new file mode 100644 index 00000000..bfcc5def --- /dev/null +++ b/rpc/converter_flags_test.go @@ -0,0 +1,65 @@ +package rpc + +import ( + "testing" + + "github.com/flanksource/clicky" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConvertCommand_OmitsInheritedAndHiddenFlags pins that a plain (non-entity) +// command's tool schema carries ONLY its own operation flags — never the +// inherited persistent globals that cobra folds into Flags() (format/log-level/ +// config/company/entity/limit/...), and never a flag explicitly marked hidden +// (the mechanism xero-cli uses to keep db-url off tool/OpenAPI schemas). A +// regression here reintroduces the "wall of global-flag chips" on every tool. +func TestConvertCommand_OmitsInheritedAndHiddenFlags(t *testing.T) { + root := &cobra.Command{Use: "app"} + // Mirror a real CLI root: format/logging globals plus a couple of app + // globals live on the root's persistent flag set, inherited by every child. + clicky.BindAllFlags(root.PersistentFlags(), "format") + root.PersistentFlags().String("config", "", "Config file path") + root.PersistentFlags().String("company", "", "Select company") + root.PersistentFlags().String("entity", "", "Select entity") + root.PersistentFlags().Int("limit", 0, "Max rows") + + child := &cobra.Command{ + Use: "do-thing", + Short: "Do a thing", + RunE: func(*cobra.Command, []string) error { return nil }, + } + // Operation-specific flags: one visible, one hidden (like db-url). + child.Flags().String("name", "", "Name to act on") + child.Flags().Int("count", 0, "How many") + child.Flags().String("db-url", "", "Infra DSN") + require.NoError(t, child.Flags().MarkHidden("db-url")) + root.AddCommand(child) + + op, err := NewConverter(DefaultConfig()).ConvertCommand(child) + require.NoError(t, err) + + params := map[string]bool{} + for _, p := range op.Parameters { + params[p.Name] = true + } + + assert.True(t, params["name"], "operation-specific flag `name` must be a parameter") + assert.True(t, params["count"], "operation-specific flag `count` must be a parameter") + + for _, omitted := range []string{ + "format", "no-color", "json", "yaml", "csv", "html", "pdf", "markdown", + "pretty", "tree", "table", "filter", "dump-schema", "loglevel", + "log-level", "json-logs", "config", "company", "entity", "limit", + } { + assert.False(t, params[omitted], + "inherited global flag %q must NOT leak into the tool schema", omitted) + assert.NotContains(t, op.Schema.Properties, omitted, + "inherited global flag %q must NOT leak into the schema properties", omitted) + } + + assert.False(t, params["db-url"], "hidden flag db-url must NOT appear as a parameter") + assert.NotContains(t, op.Schema.Properties, "db-url", + "hidden flag db-url must NOT appear in schema properties") +} From 93acde3c6f9cd940495708fbfdd5c7e6e4510540 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 5 Jul 2026 11:24:15 +0300 Subject: [PATCH 04/23] feat: update model catalog to latest provider tiers --- aichat/agent.go | 6 +++--- aichat/agent_test.go | 2 +- aichat/models.go | 37 +++++++++++++++++++++---------------- aichat/models_test.go | 6 +++--- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/aichat/agent.go b/aichat/agent.go index c7feeec9..ab38fbc4 100644 --- a/aichat/agent.go +++ b/aichat/agent.go @@ -10,6 +10,7 @@ import ( _ "github.com/flanksource/captain/pkg/ai/provider" // registers the agent backends capapi "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons-db/shell" ) // AgentOptions configures the captain agent engine (EngineAgent models). The @@ -143,11 +144,10 @@ func (s *Server) agentRequest(req ChatRequest, resumeID string) capapi.Spec { return capapi.Spec{ Prompt: capapi.Prompt{User: prompt, System: s.system}, Model: capapi.Model{Effort: capapi.Effort(req.ReasoningEffort), Temperature: req.Temperature}, - Budget: capapi.Budget{Cost: req.Budget.Cost, MaxTokens: req.Budget.MaxTokens}, + Budget: capapi.Budget{Cost: req.Budget.Cost, MaxTokens: req.Budget.MaxTokens, MaxTurns: s.opts.Agent.MaxTurns}, Permissions: perms, - Context: capapi.Context{Dir: s.opts.Agent.Cwd}, + Setup: &shell.Setup{Cwd: s.opts.Agent.Cwd}, SessionID: resumeID, - MaxTurns: s.opts.Agent.MaxTurns, } } diff --git a/aichat/agent_test.go b/aichat/agent_test.go index d84c4c5e..1507c29d 100644 --- a/aichat/agent_test.go +++ b/aichat/agent_test.go @@ -401,7 +401,7 @@ func TestAgentModelsInCatalog(t *testing.T) { }{ "claude-agent-sonnet": {EngineAgent, capapi.BackendClaudeAgent, ""}, "codex-gpt-5-codex": {EngineAgent, capapi.BackendCodexCLI, "gpt-5-codex"}, - "anthropic/claude-sonnet-4-6": {EngineGenkit, "", ""}, + "anthropic/claude-sonnet-5": {EngineGenkit, "", ""}, } for id, want := range cases { m, err := LookupModel(id) diff --git a/aichat/models.go b/aichat/models.go index c33f3c3a..f6f5f271 100644 --- a/aichat/models.go +++ b/aichat/models.go @@ -81,30 +81,35 @@ type ModelInfo struct { } // DefaultModelID is the chat backend's default, mirroring captain pkg/ai -// (Anthropic claude-sonnet-4 is captain's NewAnthropic default). -const DefaultModelID = "anthropic/claude-sonnet-4-5" - -// defaultCatalog is the v1 model menu. Mirrors captain pkg/ai provider defaults -// so the chat agrees with the rest of the stack. +// (Anthropic Sonnet 5 is captain's default). +const DefaultModelID = "anthropic/claude-sonnet-5" + +// defaultCatalog is the model menu: only the latest generally-available model +// per tier for each provider — no preview or superseded entries. Mirrors +// captain pkg/ai's catalog so the chat agrees with the rest of the stack. +// +// Provider currency (reviewed 2026-07-02): +// - Anthropic: Fable 5 (most capable), Opus 4.8, Sonnet 5, Haiku 4.5. Mythos 5 +// is Project Glasswing invite-only, so it is intentionally excluded. +// - OpenAI: GPT-5.5 (flagship) and GPT-5.4 mini. GPT-5.6 is preview-only. +// - Google: Gemini 2.5 Pro is the newest GA Pro (all Gemini 3.x Pro models are +// still preview), paired with the GA Gemini 3.5 Flash. var defaultCatalog = []Model{ - {ID: "anthropic/claude-sonnet-4-6", Provider: ProviderAnthropic, Label: "Claude Sonnet 4.6", Reasoning: true, ContextWindow: 200000}, - {ID: "anthropic/claude-opus-4-8", Provider: ProviderAnthropic, Label: "Claude Opus 4.8", Reasoning: true, ContextWindow: 200000}, + {ID: "anthropic/claude-fable-5", Provider: ProviderAnthropic, Label: "Claude Fable 5", Reasoning: true, ContextWindow: 1000000}, + {ID: "anthropic/claude-opus-4-8", Provider: ProviderAnthropic, Label: "Claude Opus 4.8", Reasoning: true, ContextWindow: 1000000}, + {ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic, Label: "Claude Sonnet 5", Reasoning: true, ContextWindow: 1000000}, {ID: "anthropic/claude-haiku-4-5", Provider: ProviderAnthropic, Label: "Claude Haiku 4.5", Reasoning: true, ContextWindow: 200000}, - {ID: "anthropic/claude-sonnet-4-5", Provider: ProviderAnthropic, Label: "Claude Sonnet 4.5", Reasoning: true, ContextWindow: 200000}, - {ID: "anthropic/claude-opus-4-1", Provider: ProviderAnthropic, Label: "Claude Opus 4.1", Reasoning: true, ContextWindow: 200000}, - {ID: "anthropic/claude-3-5-haiku-latest", Provider: ProviderAnthropic, Label: "Claude 3.5 Haiku", Reasoning: false, ContextWindow: 200000}, - {ID: "openai/gpt-4o", Provider: ProviderOpenAI, Label: "GPT-4o", Reasoning: false, ContextWindow: 128000}, - {ID: "openai/o3", Provider: ProviderOpenAI, Label: "OpenAI o3", Reasoning: true, ContextWindow: 200000}, - {ID: "openai/o4-mini", Provider: ProviderOpenAI, Label: "OpenAI o4-mini", Reasoning: true, ContextWindow: 200000}, + {ID: "openai/gpt-5.5", Provider: ProviderOpenAI, Label: "GPT-5.5", Reasoning: true, ContextWindow: 1000000}, + {ID: "openai/gpt-5.4-mini", Provider: ProviderOpenAI, Label: "GPT-5.4 mini", Reasoning: true, ContextWindow: 400000}, {ID: "googleai/gemini-2.5-pro", Provider: ProviderGoogle, Label: "Gemini 2.5 Pro", Reasoning: true, ContextWindow: 1048576}, - {ID: "googleai/gemini-2.5-flash", Provider: ProviderGoogle, Label: "Gemini 2.5 Flash", Reasoning: true, ContextWindow: 1048576}, + {ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle, Label: "Gemini 3.5 Flash", Reasoning: true, ContextWindow: 1048576}, // Agent-framework models (captain pkg/ai StreamingProvider). These run a // supervised local subprocess that owns its own tools; ids carry the // backend prefix captain's InferBackend recognises, and Backend is set // explicitly so codex slugs (which look like gpt-*) are not misrouted. - {ID: "claude-agent-sonnet", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Sonnet", Reasoning: true, ContextWindow: 200000}, - {ID: "claude-agent-opus", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Opus", Reasoning: true, ContextWindow: 200000}, + {ID: "claude-agent-sonnet", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Sonnet", Reasoning: true, ContextWindow: 1000000}, + {ID: "claude-agent-opus", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Opus", Reasoning: true, ContextWindow: 1000000}, {ID: "claude-agent-haiku", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Haiku", Reasoning: true, ContextWindow: 200000}, {ID: "codex-gpt-5-codex", Engine: EngineAgent, Backend: capapi.BackendCodexCLI, AgentModel: "gpt-5-codex", Provider: "codex-cli", Label: "Codex · GPT-5", Reasoning: true, ContextWindow: 400000}, } diff --git a/aichat/models_test.go b/aichat/models_test.go index 1fc46f3e..eca16d02 100644 --- a/aichat/models_test.go +++ b/aichat/models_test.go @@ -55,7 +55,7 @@ func TestSetModelCatalogAndReset(t *testing.T) { if err := SetModelCatalog([]Model{{ID: "openai/only-model", Label: "Only"}}); err != nil { t.Fatalf("SetModelCatalog: %v", err) } - if _, err := LookupModel("openai/gpt-4o"); err == nil { + if _, err := LookupModel("openai/gpt-5.5"); err == nil { t.Error("expected built-in model to be absent after SetModelCatalog") } m, err := LookupModel("openai/only-model") @@ -67,7 +67,7 @@ func TestSetModelCatalogAndReset(t *testing.T) { } ResetModelCatalog() - if _, err := LookupModel("openai/gpt-4o"); err != nil { + if _, err := LookupModel("openai/gpt-5.5"); err != nil { t.Fatalf("built-in model should be restored: %v", err) } } @@ -84,7 +84,7 @@ func TestRegisterModelValidation(t *testing.T) { func TestAnthropicCatalogIncludesRequestedModels(t *testing.T) { for _, id := range []string{ "anthropic/claude-haiku-4-5", - "anthropic/claude-sonnet-4-6", + "anthropic/claude-sonnet-5", "anthropic/claude-opus-4-8", } { m, err := LookupModel(id) From 3b77166ca001aee2cc99c7f28c9153e8e7f6159b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 5 Jul 2026 11:27:45 +0300 Subject: [PATCH 05/23] feat Add HTTP request timing middleware and context utilities Implement request-scoped timing accumulation for HTTP handlers with Server-Timing header emission. Provides AddTiming and Track APIs for measuring operation phases within the RPC executor and custom handlers, summing durations by phase name and emitting millisecond-precision metrics in the response header. The middleware preserves http.Flusher for streaming responses and integrates without importing rpc package dependencies.: feat --- rpc/http/middleware.go | 63 +++++++++++++++++++++++++++ rpc/http/middleware_test.go | 59 +++++++++++++++++++++++++ rpc/http/timing.go | 86 +++++++++++++++++++++++++++++++++++++ rpc/http/timing_test.go | 46 ++++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 rpc/http/middleware.go create mode 100644 rpc/http/middleware_test.go create mode 100644 rpc/http/timing.go create mode 100644 rpc/http/timing_test.go diff --git a/rpc/http/middleware.go b/rpc/http/middleware.go new file mode 100644 index 00000000..c9a0a357 --- /dev/null +++ b/rpc/http/middleware.go @@ -0,0 +1,63 @@ +package rpchttp + +import ( + "net/http" + "time" +) + +// TimingMiddleware installs a request-scoped Timings accumulator into the +// request context and stamps a Server-Timing response header on the first +// WriteHeader/Write. The header carries a `total` metric plus any phase metrics +// business logic contributed via AddTiming/Track. +func TimingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, timings := WithTimings(r.Context()) + rec := &timingRecorder{ResponseWriter: w, timings: timings, start: time.Now()} + next.ServeHTTP(rec, r.WithContext(ctx)) + }) +} + +// timingRecorder stamps the Server-Timing header exactly once, before the header +// block flushes, so it coexists with headers the inner handler already set. +type timingRecorder struct { + http.ResponseWriter + timings *Timings + start time.Time + stamped bool +} + +func (rec *timingRecorder) stamp() { + if rec.stamped { + return + } + rec.stamped = true + value := formatMetric("total", time.Since(rec.start)) + if phases := rec.timings.Header(); phases != "" { + value += ", " + phases + } + rec.ResponseWriter.Header().Set("Server-Timing", value) +} + +func (rec *timingRecorder) WriteHeader(status int) { + rec.stamp() + rec.ResponseWriter.WriteHeader(status) +} + +func (rec *timingRecorder) Write(b []byte) (int, error) { + rec.stamp() + return rec.ResponseWriter.Write(b) +} + +// Flush forwards to the inner Flusher so SSE handlers that assert +// http.Flusher keep streaming. +func (rec *timingRecorder) Flush() { + rec.stamp() + if f, ok := rec.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap exposes the wrapped writer to http.ResponseController. +func (rec *timingRecorder) Unwrap() http.ResponseWriter { + return rec.ResponseWriter +} diff --git a/rpc/http/middleware_test.go b/rpc/http/middleware_test.go new file mode 100644 index 00000000..948c6afd --- /dev/null +++ b/rpc/http/middleware_test.go @@ -0,0 +1,59 @@ +package rpchttp + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" +) + +var serverTimingRe = regexp.MustCompile(`^total;dur=[0-9]+\.[0-9], find;dur=[0-9]+\.[0-9]$`) + +func TestMiddlewareStampsServerTiming(t *testing.T) { + handler := TimingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + AddTiming(r.Context(), "find", 2*time.Millisecond) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + got := rec.Header().Get("Server-Timing") + if !serverTimingRe.MatchString(got) { + t.Fatalf("Server-Timing = %q, want match %s", got, serverTimingRe) + } +} + +func TestMiddlewareTotalOnlyWhenNoPhases(t *testing.T) { + handler := TimingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if got := rec.Header().Get("Server-Timing"); !regexp.MustCompile(`^total;dur=[0-9]+\.[0-9]$`).MatchString(got) { + t.Fatalf("Server-Timing = %q", got) + } +} + +func TestMiddlewarePreservesFlush(t *testing.T) { + handler := TimingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, ok := w.(http.Flusher) + if !ok { + t.Error("recorder does not implement http.Flusher") + return + } + w.WriteHeader(http.StatusOK) + f.Flush() + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if !rec.Flushed { + t.Fatal("expected inner writer to be flushed") + } +} diff --git a/rpc/http/timing.go b/rpc/http/timing.go new file mode 100644 index 00000000..554a590e --- /dev/null +++ b/rpc/http/timing.go @@ -0,0 +1,86 @@ +// Package rpchttp provides request-scoped HTTP server timing that both the RPC +// executor path and hand-written handlers can feed, emitted once as a +// Server-Timing response header by TimingMiddleware. +// +// The package deliberately imports only the standard library (not its parent +// rpc package) so business logic can call AddTiming without pulling in rpc's +// cobra/openapi dependencies and without creating an import cycle. +package rpchttp + +import ( + "context" + "fmt" + "strings" + "sync" + "time" +) + +// timingsKey keys the request-scoped *Timings accumulator into a context. +type timingsKey struct{} + +// Timings is a request-scoped, insertion-ordered accumulator of named phase +// durations. Durations for the same name sum, so AddTiming may be called once +// per file in a loop and still report a single total for that phase. +type Timings struct { + mu sync.Mutex + order []string + totals map[string]time.Duration +} + +// WithTimings returns a context carrying a fresh accumulator and the accumulator +// itself, so a middleware can read it back after the handler returns. +func WithTimings(ctx context.Context) (context.Context, *Timings) { + t := &Timings{totals: make(map[string]time.Duration)} + return context.WithValue(ctx, timingsKey{}, t), t +} + +// TimingsFromContext returns the accumulator installed by WithTimings, if any. +func TimingsFromContext(ctx context.Context) (*Timings, bool) { + t, ok := ctx.Value(timingsKey{}).(*Timings) + return t, ok +} + +// AddTiming adds d to the named phase of the current request's accumulator. It +// is a no-op when no accumulator is present (the CLI path has no HTTP response +// to attach a header to). +func AddTiming(ctx context.Context, name string, d time.Duration) { + if t, ok := TimingsFromContext(ctx); ok { + t.add(name, d) + } +} + +// Track starts a timer for the named phase and returns a stop function that +// records the elapsed time when called. Use as `defer Track(ctx, "parse")()` or +// keep the returned func to stop explicitly inside a loop. +func Track(ctx context.Context, name string) func() { + start := time.Now() + return func() { + AddTiming(ctx, name, time.Since(start)) + } +} + +func (t *Timings) add(name string, d time.Duration) { + t.mu.Lock() + defer t.mu.Unlock() + if _, seen := t.totals[name]; !seen { + t.order = append(t.order, name) + } + t.totals[name] += d +} + +// Header renders the accumulated phases as a Server-Timing value fragment +// (`find;dur=4.1, parse;dur=6.8`), durations in milliseconds to one decimal. It +// returns an empty string when no phases were recorded. +func (t *Timings) Header() string { + t.mu.Lock() + defer t.mu.Unlock() + parts := make([]string, 0, len(t.order)) + for _, name := range t.order { + parts = append(parts, formatMetric(name, t.totals[name])) + } + return strings.Join(parts, ", ") +} + +func formatMetric(name string, d time.Duration) string { + return fmt.Sprintf("%s;dur=%.1f", name, float64(d)/float64(time.Millisecond)) +} diff --git a/rpc/http/timing_test.go b/rpc/http/timing_test.go new file mode 100644 index 00000000..ebff4bf5 --- /dev/null +++ b/rpc/http/timing_test.go @@ -0,0 +1,46 @@ +package rpchttp + +import ( + "context" + "testing" + "time" +) + +func TestAddTimingSumsByName(t *testing.T) { + ctx, timings := WithTimings(context.Background()) + AddTiming(ctx, "parse", 2*time.Millisecond) + AddTiming(ctx, "find", 1*time.Millisecond) + AddTiming(ctx, "parse", 3*time.Millisecond) + + if got := timings.Header(); got != "parse;dur=5.0, find;dur=1.0" { + t.Fatalf("Header() = %q", got) + } +} + +func TestHeaderEmptyWhenNoPhases(t *testing.T) { + _, timings := WithTimings(context.Background()) + if got := timings.Header(); got != "" { + t.Fatalf("Header() = %q, want empty", got) + } +} + +func TestTrackRecordsElapsed(t *testing.T) { + ctx, timings := WithTimings(context.Background()) + stop := Track(ctx, "enrich") + time.Sleep(5 * time.Millisecond) + stop() + + total, ok := timings.totals["enrich"] + if !ok { + t.Fatal("enrich not recorded") + } + if total < 5*time.Millisecond { + t.Fatalf("elapsed = %v, want >= 5ms", total) + } +} + +func TestAddTimingNoAccumulatorIsNoop(t *testing.T) { + // Must not panic on the CLI path where no accumulator was installed. + AddTiming(context.Background(), "parse", time.Millisecond) + Track(context.Background(), "parse")() +} From 0b8271be8f9b4dd93b07f6f0ef0564085898041a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 5 Jul 2026 11:28:06 +0300 Subject: [PATCH 06/23] fix(rpc): Fix route deduplication for wildcard parameter name differences --- CONTRIBUTING.md | 1 + rpc/serve.go | 26 ++++++++++++++++++++++++-- rpc/serve_test.go | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55c5c85e..a4483e14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ library in your own application, see [CLAUDE.md](CLAUDE.md) and [README.md](READ make build # build ./clicky from ./cmd/clicky (NEVER run `go build` directly) make test # go test -v ./... (ROOT module only — example modules are separate) make lint # golangci-lint v2.8.0 (pinned into .bin/) + go vet ./... +make lint-clicky-ui # run clicky-ui's custom oxlint rules against the entity demo webapp make fmt # gofmt -s -w . and `go mod tidy` across every module in GO_MODULES make check # fmt + lint + test make task-ui # rebuild the embedded Preact task-UI bundle (task/ui/dist/taskui.js) diff --git a/rpc/serve.go b/rpc/serve.go index 46b0c8c6..7fc69876 100644 --- a/rpc/serve.go +++ b/rpc/serve.go @@ -454,6 +454,21 @@ generated dynamically and reflects the current state of the CLI commands.`, return cmd } +// normalizeWildcardNames replaces every "{name}" path segment with "{}" so two +// patterns that differ only in wildcard name compare equal — matching Go 1.22's +// ServeMux, which treats e.g. "/config/{config}/test" and "/config/{id}/test" as +// the same (conflicting) route. Used only for duplicate detection; the original +// pattern (with its real param name) is what gets registered. +func normalizeWildcardNames(pattern string) string { + segments := strings.Split(pattern, "/") + for i, seg := range segments { + if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") { + segments[i] = "{}" + } + } + return strings.Join(segments, "/") +} + // registerExecutionRoutes registers dynamic command execution routes based on the RPC service func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { if s.executor == nil || s.executor.service == nil { @@ -472,7 +487,14 @@ func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { } pattern := method + " " + sanitized - if existingOp, found := registered[pattern]; found { + // Go 1.22's ServeMux treats two patterns that differ only in wildcard NAME + // (e.g. ".../{config}/test" vs ".../{id}/test") as conflicting and panics + // when both are registered. Dedupe on a name-normalized key so the second + // such route is skipped rather than crashing the server on startup; + // handleExecuteCommand re-matches every request against all operations + // (FindOperation), so the skipped route stays reachable via the survivor. + dedupeKey := method + " " + normalizeWildcardNames(sanitized) + if existingOp, found := registered[dedupeKey]; found { fmt.Printf("⚠️ Warning: Duplicate endpoint detected\n") fmt.Printf(" Path: %s\n", pattern) fmt.Printf(" Already registered by: %s\n", existingOp) @@ -480,7 +502,7 @@ func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { return false } - registered[pattern] = opName + registered[dedupeKey] = opName mux.HandleFunc(pattern, s.handleExecuteCommand) routeCount++ return true diff --git a/rpc/serve_test.go b/rpc/serve_test.go index c6d03e84..13ae0129 100644 --- a/rpc/serve_test.go +++ b/rpc/serve_test.go @@ -299,6 +299,29 @@ func TestSanitizePathParams(t *testing.T) { } } +func TestNormalizeWildcardNames(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/api/v1/config/{id}/test", "/api/v1/config/{}/test"}, + {"/api/v1/config/{config}/test", "/api/v1/config/{}/test"}, + {"/api/v1/users", "/api/v1/users"}, + {"/api/v1/{company_id}/plans/{plan_name}", "/api/v1/{}/plans/{}"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, normalizeWildcardNames(tt.input)) + }) + } + // The core invariant: two routes that differ only in wildcard name — which + // Go 1.22's ServeMux treats as conflicting — normalize to the same dedupe key. + assert.Equal(t, + normalizeWildcardNames("/api/v1/config/{config}/test"), + normalizeWildcardNames("/api/v1/config/{id}/test"), + "patterns differing only in wildcard name must dedupe together") +} + // Integration test that verifies the complete flow func TestSwaggerServer_Integration(t *testing.T) { // Create test server From b376f0a91e1614a7015ee6a53ca32c5cd4c4df56 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 7 Jul 2026 09:34:12 +0300 Subject: [PATCH 07/23] feat: preserve schema field order in markdown formatter output --- formatters/map_input_test.go | 30 ++++++++++++++++++++++ formatters/markdown_formatter.go | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/formatters/map_input_test.go b/formatters/map_input_test.go index a17a4d96..9b2a3339 100644 --- a/formatters/map_input_test.go +++ b/formatters/map_input_test.go @@ -112,3 +112,33 @@ func TestMapStringAnyWithNestedTable(t *testing.T) { } } } + +func TestMarkdownFormatterPreservesSchemaFieldOrder(t *testing.T) { + type orderedDocument struct { + Summary string `json:"summary" pretty:"label=Summary"` + Transactions string `json:"transactions" pretty:"label=Transactions"` + Rules string `json:"rules" pretty:"label=Business Rules"` + Eligibility string `json:"eligibility" pretty:"label=Eligibility"` + } + + out, err := NewMarkdownFormatter().Format(orderedDocument{ + Summary: "summary section", + Transactions: "transactions section", + Rules: "rules section", + Eligibility: "eligibility section", + }) + if err != nil { + t.Fatalf("MarkdownFormatter.Format returned error: %v", err) + } + + summary := strings.Index(out, "Summary:") + transactions := strings.Index(out, "Transactions:") + rules := strings.Index(out, "Business Rules:") + eligibility := strings.Index(out, "Eligibility:") + if summary < 0 || transactions < 0 || rules < 0 || eligibility < 0 { + t.Fatalf("missing expected section labels in output:\n%s", out) + } + if !(summary < transactions && transactions < rules && rules < eligibility) { + t.Fatalf("sections rendered out of schema order:\n%s", out) + } +} diff --git a/formatters/markdown_formatter.go b/formatters/markdown_formatter.go index ab64a9ed..f0bf98cf 100644 --- a/formatters/markdown_formatter.go +++ b/formatters/markdown_formatter.go @@ -2,6 +2,7 @@ package formatters import ( "fmt" + "sort" "github.com/flanksource/clicky/api" ) @@ -39,6 +40,49 @@ func (f *MarkdownFormatter) Format(data interface{}) (string, error) { // FormatPrettyData formats PrettyData as Markdown func (f *MarkdownFormatter) FormatPrettyData(data *api.PrettyData, opts FormatOptions) (string, error) { + if data == nil { + return "", nil + } + if ordered, ok := schemaOrderedMarkdown(data); ok { + return ordered.Markdown(), nil + } return data.Markdown(), nil } + +func schemaOrderedMarkdown(data *api.PrettyData) (api.TextList, bool) { + if data == nil || data.Schema == nil || data.TypedMap == nil { + return nil, false + } + + values := *data.TypedMap + seen := map[string]bool{} + out := api.TextList{} + + appendField := func(name, label string, value api.TypedValue) { + if label == "" { + label = api.PrettifyFieldName(name) + } + out = append(out, api.Text{}.Append(label+": ", "text-muted").Add(value.Value())) + seen[name] = true + } + + for _, field := range data.Schema.Fields { + if value, ok := values[field.Name]; ok { + appendField(field.Name, field.Label, value) + } + } + + extra := make([]string, 0, len(values)) + for name := range values { + if !seen[name] { + extra = append(extra, name) + } + } + sort.Strings(extra) + for _, name := range extra { + appendField(name, "", values[name]) + } + + return out, len(out) > 0 +} From 301ba8e676c6797ed7037d0385120a78d15e4941 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 7 Jul 2026 10:32:15 +0300 Subject: [PATCH 08/23] fix: Replace fmt.Printf with clicky logger and fix timing middleware edge case Gavel-Issue-Id: 6b2997e35e373dc3d3209e33280094ab Claude-Session-Id: f7ecddc5-467d-4fd4-a7b8-f0778f653659 --- examples/enitity/webapp/.oxlintrc.json | 2 +- examples/enitity/webapp/package.json | 1 + rpc/http/middleware.go | 11 ++++++++--- rpc/http/middleware_test.go | 15 +++++++++++++++ rpc/serve.go | 11 ++++------- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/examples/enitity/webapp/.oxlintrc.json b/examples/enitity/webapp/.oxlintrc.json index 396058fa..1a9e7729 100644 --- a/examples/enitity/webapp/.oxlintrc.json +++ b/examples/enitity/webapp/.oxlintrc.json @@ -3,7 +3,7 @@ "jsPlugins": [ { "name": "clicky-ui", - "specifier": "../../../../clicky-ui/packages/ui/oxlint-plugins/clicky-ui.js" + "specifier": "@flanksource/clicky-ui/oxlint-plugins" } ], "rules": { diff --git a/examples/enitity/webapp/package.json b/examples/enitity/webapp/package.json index 5fff551a..d909834c 100644 --- a/examples/enitity/webapp/package.json +++ b/examples/enitity/webapp/package.json @@ -32,6 +32,7 @@ "tailwind-merge": "^2.6.0" }, "devDependencies": { + "@flanksource/clicky-ui": "^0.3.6", "@tailwindcss/vite": "^4.2.2", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", diff --git a/rpc/http/middleware.go b/rpc/http/middleware.go index c9a0a357..2c4cc94a 100644 --- a/rpc/http/middleware.go +++ b/rpc/http/middleware.go @@ -6,14 +6,19 @@ import ( ) // TimingMiddleware installs a request-scoped Timings accumulator into the -// request context and stamps a Server-Timing response header on the first -// WriteHeader/Write. The header carries a `total` metric plus any phase metrics -// business logic contributed via AddTiming/Track. +// request context and stamps a Server-Timing response header. The header is +// written on the first WriteHeader/Write, or — for handlers that return without +// writing anything — once the handler returns. It carries a `total` metric plus +// any phase metrics business logic contributed via AddTiming/Track. func TimingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, timings := WithTimings(r.Context()) rec := &timingRecorder{ResponseWriter: w, timings: timings, start: time.Now()} next.ServeHTTP(rec, r.WithContext(ctx)) + // Handlers that return without writing never trigger the write-path + // stamp; do it here so the header is emitted regardless. stamp() is + // idempotent, so a handler that already wrote is unaffected. + rec.stamp() }) } diff --git a/rpc/http/middleware_test.go b/rpc/http/middleware_test.go index 948c6afd..a13f42d2 100644 --- a/rpc/http/middleware_test.go +++ b/rpc/http/middleware_test.go @@ -39,6 +39,21 @@ func TestMiddlewareTotalOnlyWhenNoPhases(t *testing.T) { } } +func TestMiddlewareStampsWhenHandlerDoesNotWrite(t *testing.T) { + handler := TimingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + AddTiming(r.Context(), "find", 2*time.Millisecond) + // Return without calling WriteHeader/Write/Flush. + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + got := rec.Header().Get("Server-Timing") + if !serverTimingRe.MatchString(got) { + t.Fatalf("Server-Timing = %q, want match %s", got, serverTimingRe) + } +} + func TestMiddlewarePreservesFlush(t *testing.T) { handler := TimingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f, ok := w.(http.Flusher) diff --git a/rpc/serve.go b/rpc/serve.go index 7fc69876..10e75a1a 100644 --- a/rpc/serve.go +++ b/rpc/serve.go @@ -472,7 +472,7 @@ func normalizeWildcardNames(pattern string) string { // registerExecutionRoutes registers dynamic command execution routes based on the RPC service func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { if s.executor == nil || s.executor.service == nil { - fmt.Printf("⚠️ Warning: Executor has no service; no routes registered\n") + clicky.Warnf("Executor has no service; no routes registered") return } @@ -482,7 +482,7 @@ func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { registerRoute := func(method, path, opName string) bool { sanitized, ok := sanitizePathParams(path) if !ok { - fmt.Printf("⚠️ Warning: Skipping route with invalid path params: %s %s\n", method, path) + clicky.Warnf("Skipping route with invalid path params: %s %s", method, path) return false } @@ -495,10 +495,7 @@ func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { // (FindOperation), so the skipped route stays reachable via the survivor. dedupeKey := method + " " + normalizeWildcardNames(sanitized) if existingOp, found := registered[dedupeKey]; found { - fmt.Printf("⚠️ Warning: Duplicate endpoint detected\n") - fmt.Printf(" Path: %s\n", pattern) - fmt.Printf(" Already registered by: %s\n", existingOp) - fmt.Printf(" Skipping: %s\n", opName) + clicky.Warnf("Duplicate endpoint %s already registered by %q; skipping %q", pattern, existingOp, opName) return false } @@ -526,7 +523,7 @@ func (s *SwaggerServer) registerExecutionRoutes(mux *http.ServeMux) { registerRoute(http.MethodGet, path, op.Name+" lookup") } } - fmt.Printf("✅ Registered %d executor routes\n", routeCount) + clicky.Infof("Registered %d executor routes", routeCount) } // sanitizePathParams replaces invalid characters in {param} names with underscores From 8b085cd91a83913968b4bbdda3ac5e561783afea Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 7 Jul 2026 10:34:43 +0300 Subject: [PATCH 09/23] chore: add UI and linting dependencies to webapp --- examples/enitity/webapp/pnpm-lock.yaml | 285 +++++++++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/examples/enitity/webapp/pnpm-lock.yaml b/examples/enitity/webapp/pnpm-lock.yaml index b84d73d7..a04c0684 100644 --- a/examples/enitity/webapp/pnpm-lock.yaml +++ b/examples/enitity/webapp/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: specifier: ^2.6.0 version: 2.6.1 devDependencies: + '@flanksource/clicky-ui': + specifier: ^0.3.6 + version: 0.3.9(@ai-sdk/react@3.0.203(react@18.3.1)(zod@4.4.1))(@babel/core@7.29.0)(@babel/template@7.28.6)(@shikijs/langs@1.29.2)(@shikijs/themes@1.29.2)(@shikijs/transformers@1.29.2)(@types/react@18.3.28)(ai@6.0.33(zod@4.4.1))(marked@15.0.12)(react-dom@18.3.1(react@18.3.1))(react-rnd@10.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(recharts@3.9.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1))(shiki@1.29.2)(streamdown@2.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@4.2.4) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.4(vite@7.3.6(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) @@ -105,6 +108,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.4 version: 5.2.0(vite@7.3.6(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + oxlint: + specifier: 'catalog:' + version: 1.72.0 react-grab: specifier: 'catalog:' version: 0.1.47(react@18.3.1) @@ -448,12 +454,68 @@ packages: cpu: [x64] os: [win32] + '@flanksource/clicky-ui@0.3.9': + resolution: {integrity: sha512-FGS474gYHY/0ipT6HQHTonQ8zWTmRWDFWJU437A+CB+zxaYU7px6kucnjE51wonzvML7m6HTr4BnAcmbGbmRkg==} + peerDependencies: + '@ai-sdk/react': ^3.0.0 + '@mdxeditor/editor': ^4.0.4 + '@shikijs/langs': ^1.24.0 + '@shikijs/themes': ^1.24.0 + '@shikijs/transformers': ^1.24.0 + ai: ^6.0.0 + marked: ^15.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-rnd: ^10.5.0 + recharts: ^3.0.0 + shiki: ^1.24.0 + streamdown: ^2.5.0 + tailwindcss: ^3.4.0 || ^4.0.0 + peerDependenciesMeta: + '@ai-sdk/react': + optional: true + '@mdxeditor/editor': + optional: true + '@shikijs/langs': + optional: true + '@shikijs/themes': + optional: true + '@shikijs/transformers': + optional: true + ai: + optional: true + marked: + optional: true + react-rnd: + optional: true + shiki: + optional: true + streamdown: + optional: true + + '@flanksource/icons@1.0.60': + resolution: {integrity: sha512-PH/0IFJJlyr9bNMUI2abCpiTP6pmYvwXklkw2OsoSIZHjnBvmb9ogZ0Xxd4J7QElT54T6gPZqQEQjTuAz7DISA==} + peerDependencies: + react: '*' + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} @@ -595,48 +657,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.72.0': resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.72.0': resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.72.0': resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.72.0': resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.72.0': resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.72.0': resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.72.0': resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.72.0': resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} @@ -687,6 +757,17 @@ packages: resolution: {integrity: sha512-Cc7d8mSwvoV8gpeTQbE8dMPdeXIyO6w+yIhzgi3jY06i03WLNhb/6jIxNBNF1cVRI7ujnFQXZA66BbnBNTpBSw==} hasBin: true + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@replit/codemirror-css-color-picker@6.3.0': resolution: {integrity: sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A==} peerDependencies: @@ -955,6 +1036,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} @@ -1220,6 +1304,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} @@ -1682,6 +1769,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1745,6 +1835,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.8: resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} @@ -1899,6 +1992,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@11.1.11: + resolution: {integrity: sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -1956,6 +2052,24 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jotai@2.20.1: + resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -2445,6 +2559,18 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -2480,6 +2606,22 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + recharts@3.9.2: + resolution: {integrity: sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regex-recursion@5.1.1: resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==} @@ -2525,6 +2667,9 @@ packages: remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -2671,6 +2816,9 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -2774,6 +2922,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-plugin-monaco-editor@1.1.0: resolution: {integrity: sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==} peerDependencies: @@ -3255,6 +3406,41 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@flanksource/clicky-ui@0.3.9(@ai-sdk/react@3.0.203(react@18.3.1)(zod@4.4.1))(@babel/core@7.29.0)(@babel/template@7.28.6)(@shikijs/langs@1.29.2)(@shikijs/themes@1.29.2)(@shikijs/transformers@1.29.2)(@types/react@18.3.28)(ai@6.0.33(zod@4.4.1))(marked@15.0.12)(react-dom@18.3.1(react@18.3.1))(react-rnd@10.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(recharts@3.9.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1))(shiki@1.29.2)(streamdown@2.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(tailwindcss@4.2.4)': + dependencies: + '@flanksource/icons': 1.0.60(react@18.3.1) + '@floating-ui/react': 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) + '@tanstack/react-query': 5.100.6(react@18.3.1) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dompurify: 3.4.11 + jotai: 2.20.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + recharts: 3.9.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1) + tailwind-merge: 2.6.1 + tailwindcss: 4.2.4 + optionalDependencies: + '@ai-sdk/react': 3.0.203(react@18.3.1)(zod@4.4.1) + '@shikijs/langs': 1.29.2 + '@shikijs/themes': 1.29.2 + '@shikijs/transformers': 1.29.2 + ai: 6.0.33(zod@4.4.1) + marked: 15.0.12 + react-rnd: 10.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + shiki: 1.29.2 + streamdown: 2.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + + '@flanksource/icons@1.0.60(react@18.3.1)': + dependencies: + react: 18.3.1 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -3264,6 +3450,20 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/react@0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.4.0 + '@floating-ui/utils@0.2.10': {} '@floating-ui/utils@0.2.11': {} @@ -3465,6 +3665,18 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.11 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + react: 18.3.1 + react-redux: 9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) + '@replit/codemirror-css-color-picker@6.3.0(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.1)': dependencies: '@codemirror/language': 6.12.3 @@ -3928,6 +4140,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.21': dependencies: tslib: 2.8.1 @@ -4198,6 +4412,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.21': {} @@ -4666,6 +4882,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -4740,6 +4958,8 @@ snapshots: estree-walker@2.0.2: {} + eventemitter3@5.0.4: {} + eventsource-parser@3.0.8: {} exsolve@1.0.8: {} @@ -4957,6 +5177,8 @@ snapshots: ignore@7.0.5: {} + immer@11.1.11: {} + import-meta-resolve@4.2.0: {} inline-style-parser@0.2.7: {} @@ -4995,6 +5217,13 @@ snapshots: jiti@2.6.1: {} + jotai@2.20.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1): + optionalDependencies: + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@types/react': 18.3.28 + react: 18.3.1 + js-base64@3.7.8: {} js-tokens@4.0.0: {} @@ -5719,6 +5948,15 @@ snapshots: react-is@16.13.1: {} + react-redux@9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + redux: 5.0.1 + react-refresh@0.18.0: {} react-rnd@10.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -5749,6 +5987,32 @@ snapshots: readdirp@5.0.0: {} + recharts@3.9.2(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@16.13.1)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.47.0 + eventemitter3: 5.0.4 + immer: 11.1.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 16.13.1 + react-redux: 9.3.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regex-recursion@5.1.1: dependencies: regex: 5.1.1 @@ -5837,6 +6101,8 @@ snapshots: remend@1.3.0: {} + reselect@5.2.0: {} + reserved-identifiers@1.2.0: {} restore-cursor@5.1.0: @@ -6015,6 +6281,8 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tiny-invariant@1.3.3: {} + tinyexec@1.2.4: {} tinyglobby@0.2.16: @@ -6130,6 +6398,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-plugin-monaco-editor@1.1.0(monaco-editor@0.55.1): dependencies: monaco-editor: 0.55.1 From 956ee0ad140b302c06e6dc0e11aa3292e441d5f5 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 7 Jul 2026 10:41:30 +0300 Subject: [PATCH 10/23] chore: update Go module dependencies to latest versions --- aichat/go.mod | 157 ++++++++++++++++-- aichat/go.sum | 429 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 528 insertions(+), 58 deletions(-) diff --git a/aichat/go.mod b/aichat/go.mod index e0c45664..e75cb3a5 100644 --- a/aichat/go.mod +++ b/aichat/go.mod @@ -12,22 +12,68 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/kms v1.26.0 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.59.2 // indirect + dario.cat/mergo v1.0.2 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/ClickHouse/clickhouse-go/v2 v2.46.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/TomOnTime/utfutil v1.0.0 // indirect github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/antchfx/xmlquery v1.5.1 // indirect github.com/antchfx/xpath v1.3.6 // indirect github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/service/eks v1.74.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect + github.com/aws/smithy-go v1.25.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cert-manager/cert-manager v1.20.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect @@ -41,53 +87,105 @@ require ( github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/coder/websocket v1.8.14 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eko/gocache/lib/v4 v4.2.2 // indirect + github.com/eko/gocache/store/go_cache/v4 v4.2.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/flanksource/commons v1.53.1 // indirect github.com/flanksource/gomplate/v3 v3.24.82 // indirect github.com/flanksource/is-healthy v1.0.88 // indirect github.com/flanksource/kubectl-neat v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/geoffgarside/ber v1.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.1 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/go-stack/stack v1.8.1 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.18.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosimple/slug v1.15.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf // indirect github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce // indirect + github.com/henvic/httpretty v0.1.4 // indirect + github.com/hirochachacha/go-smb2 v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/itchyny/gojq v0.12.19 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect + github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jeremywohl/flatten v1.0.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/labstack/echo/v4 v4.14.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/liamylian/jsontime/v2 v2.0.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/lmittmann/tint v1.1.3 // indirect github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -99,8 +197,10 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect + github.com/microsoft/go-mssqldb v1.10.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -115,8 +215,16 @@ require ( github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/openai/openai-go v1.12.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/sftp v1.13.6 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/playwright-community/playwright-go v0.5700.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect @@ -132,12 +240,16 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/samber/lo v1.53.0 // indirect github.com/samber/oops v1.21.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -148,8 +260,15 @@ require ( github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + github.com/vadimi/go-http-ntlm v1.0.3 // indirect + github.com/vadimi/go-http-ntlm/v2 v2.5.0 // indirect + github.com/vadimi/go-ntlm v1.2.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect @@ -162,11 +281,18 @@ require ( github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect @@ -176,20 +302,25 @@ require ( golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genai v1.51.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/gorm v1.31.0 // indirect k8s.io/api v0.36.1 // indirect k8s.io/apiextensions-apiserver v0.36.1 // indirect k8s.io/apimachinery v0.36.1 // indirect k8s.io/client-go v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/streaming v0.36.1 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf // indirect mvdan.cc/sh/v3 v3.13.0 // indirect diff --git a/aichat/go.sum b/aichat/go.sum index b6ac19d4..be4e6736 100644 --- a/aichat/go.sum +++ b/aichat/go.sum @@ -2,17 +2,78 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= +cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw= +cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= +github.com/ClickHouse/clickhouse-go/v2 v2.46.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/TomOnTime/utfutil v1.0.0 h1:/0Ivgo2OjXJxo8i7zgvs7ewSFZMLwCRGm3P5Umowb90= +github.com/TomOnTime/utfutil v1.0.0/go.mod h1:l9lZmOniizVSuIliSkEf87qivMRlSNzbdBFKjuLRg1c= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= @@ -23,6 +84,10 @@ github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= @@ -31,10 +96,14 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= @@ -47,10 +116,20 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj1 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/eks v1.74.2 h1:GKqBur7gp6rnYbMZXh2+89f8g+/bu26ZKwpXfXrno80= +github.com/aws/aws-sdk-go-v2/service/eks v1.74.2/go.mod h1:f1/1x766rRjLVUk94exobjhggT1MR3vO4wxglqOvpY4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= +github.com/aws/aws-sdk-go-v2/service/kms v1.41.2/go.mod h1:Pqd9k4TuespkireN206cK2QBsaBTL6X+VPAez5Qcijk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= @@ -69,14 +148,16 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= -github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cert-manager/cert-manager v1.20.0 h1:czZamsFJ1YdKPhpv+SopJZN9t3W68vQBnFYUevSHGqY= github.com/cert-manager/cert-manager v1.20.0/go.mod h1:3oparI7R5JyJMNXntYod9p6DucIZ84st7y/9kL6cbBE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -113,12 +194,18 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -133,20 +220,40 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eko/gocache/lib/v4 v4.2.2 h1:jUQ1EPoapmnxeDfekdu8nb2D5d5nSgxSJyPZ1PsloBM= +github.com/eko/gocache/lib/v4 v4.2.2/go.mod h1:/Lpnfie38P4Qkun24jyIVRv95GzhbC90dsq6Q7AtQ2I= +github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw= +github.com/eko/gocache/store/go_cache/v4 v4.2.2/go.mod h1:T9zkHokzr8K9EiC7RfMbDg6HSwaV6rv3UdcNu13SGcA= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/firebase/genkit/go v1.8.0 h1:jIL9xS3ZxW9sTWN2SG9RyupPd0srjXmfB1749FPIuaY= github.com/firebase/genkit/go v1.8.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= github.com/flanksource/captain v0.0.9 h1:salnAHq7R7duv6vfyDfM31Lu22JsG1gNwO1NS7qO6Tc= github.com/flanksource/captain v0.0.9/go.mod h1:QPXC+P386XbR8m5vyg/qz4L1RXdcDJXsVspUuk6PSAA= github.com/flanksource/commons v1.53.1 h1:WiMvY9XGG//L4ndYKTcmp0NjWXg4B7Wbn4hYWkITUmY= github.com/flanksource/commons v1.53.1/go.mod h1:ZII22jIDJ3fd/Mz7l0SzLFEv8aoSUg+xwfJAAVf8up4= +github.com/flanksource/commons-db v0.1.13 h1:ds+QRLiiFtHk7ewEfQoaGcBcXqcJxG0qW7yploLbrr8= +github.com/flanksource/commons-db v0.1.13/go.mod h1:EGQjugWW+QYvZcBhleb9QzDs1xhNrezf9V1NKsofQ6g= github.com/flanksource/gomplate/v3 v3.24.82 h1:22HOZYeNRMX40G8OF3qRAqRoR5Lkf8Vm4x3WDqugZkE= github.com/flanksource/gomplate/v3 v3.24.82/go.mod h1:NMMZkFsjbLy/8iY8Fip5N86Y0PP6lZeq+kmPwpVVIL0= github.com/flanksource/is-healthy v1.0.88 h1:ATQuKoNdp8Qfzf41/eMFajmT0qzOmZlZNG5eLK41RFo= @@ -159,19 +266,50 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= +github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -184,42 +322,69 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3 h1:PtLUK1Z0nr/4Bv7yjYm4qzUe/IsKGIOXc3SJWB1clNk= github.com/google/dotprompt/go v0.0.0-20260502013637-5cd4a8405ca3/go.mod h1:mjF7S9XoK7vfdpnZa49V2nQEN0UJxnejJzveZ1hnYGA= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= -github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= +github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= +github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbIt9i+hB1zfKw2mE8C12TuGxPiW7YmtLbPa4= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= +github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= +github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= +github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -228,15 +393,50 @@ github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -244,6 +444,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M= +github.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/liamylian/jsontime/v2 v2.0.0 h1:3if2kDW/boymUdO+4Qj/m4uaXMBSF6np9KEgg90cwH0= +github.com/liamylian/jsontime/v2 v2.0.0/go.mod h1:UHp1oAPqCBfspokvGmaGe0IAl2IgOpgOgDaKPcvcGGY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 h1:a0+bIffIh/HdvvgtPQLRhOef1VDSxZ+8bQiyjQlJzqc= @@ -266,16 +480,23 @@ github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEj github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU= +github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= +github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -284,6 +505,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ohler55/ojg v1.28.1 h1:Xy93DelhLSZNeWv8GPKtP6qMqkUlZlAxBP/AQcC5RfY= github.com/ohler55/ojg v1.28.1/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -304,12 +527,29 @@ github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1 github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= +github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U= github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -343,6 +583,8 @@ github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc= github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= @@ -351,8 +593,13 @@ github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -360,10 +607,19 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -372,6 +628,7 @@ github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vl github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -391,16 +648,28 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vadimi/go-http-ntlm v1.0.3 h1:o6n2vAtP1MlLT73jIXuQYryIcWzXyMN0SCQWZ2QVLLc= github.com/vadimi/go-http-ntlm v1.0.3/go.mod h1:SwhhmybQ4Yn1mC53UPmQ6MCrBX6UvJHlS1Xt89OmM9M= github.com/vadimi/go-http-ntlm/v2 v2.5.0 h1:sddEWZumD7GoeNkfFZyZq01pq6CB4U6L73EBw3X7vTU= github.com/vadimi/go-http-ntlm/v2 v2.5.0/go.mod h1:KduY1xBqaL8Q2Rh/erMvRQHKoj3VAT9GNYxe9EH+rOo= +github.com/vadimi/go-ntlm v1.0.1/go.mod h1:hPTY60eLSKGj9oUJAB+kZiLs2Cg5eKdH60aLczM9rMg= github.com/vadimi/go-ntlm v1.2.1 h1:y2xZf/a5+BJlYNJIIulP1q8F438H9bU7aGcYE53vghQ= github.com/vadimi/go-ntlm v1.2.1/go.mod h1:hPTY60eLSKGj9oUJAB+kZiLs2Cg5eKdH60aLczM9rMg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -416,9 +685,14 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= @@ -426,32 +700,56 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gocloud.dev v0.43.0 h1:aW3eq4RMyehbJ54PMsh4hsp7iX8cO/98ZRzJJOzN/5M= +gocloud.dev v0.43.0/go.mod h1:eD8rkg7LhKUHrzkEdLTZ+Ty/vgPHPCd+yMQdfelQVu4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= @@ -462,50 +760,65 @@ golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -513,62 +826,84 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI= +google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg= google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= @@ -576,6 +911,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= @@ -591,6 +928,8 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/streaming v0.36.1 h1:L+K68n4Gg940BGNNYtUBvL1WTLL0YnKT3s+P1MNAmR4= +k8s.io/streaming v0.36.1/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= From cf2e9ec572a49145af6b360c3b3d7e6fba1fd5ee Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 13:21:06 +0300 Subject: [PATCH 11/23] claude: task output review phase 1 - fix log display off-by-one, capture drain race, blank-line loss, noColor default, StartGroup race --- api/table_test.go | 2 +- task/group_race_test.go | 27 ++++++++++ task/manager.go | 35 +++++++++++-- task/manager_color_test.go | 32 ++++++++++++ task/manager_output.go | 73 ++++++++++++++------------- task/manager_output_test.go | 65 ++++++++++++++++++++++++ task/task.go | 2 +- task/task_log_display_test.go | 94 +++++++++++++++++++++++++++++++++++ 8 files changed, 289 insertions(+), 41 deletions(-) create mode 100644 task/group_race_test.go create mode 100644 task/manager_color_test.go create mode 100644 task/task_log_display_test.go diff --git a/api/table_test.go b/api/table_test.go index a26304d1..4fbc17e0 100644 --- a/api/table_test.go +++ b/api/table_test.go @@ -85,7 +85,7 @@ var _ = Describe("WithoutEmptyColumns", func() { {"col": TypedValue{Textable: Text{Content: " "}}}, } - Expect(func() { table.String() }).NotTo(Panic()) + Expect(func() { _ = table.String() }).NotTo(Panic()) Expect(table.String()).To(BeEmpty()) Expect(func() { table.ANSI() }).NotTo(Panic()) }) diff --git a/task/group_race_test.go b/task/group_race_test.go new file mode 100644 index 00000000..4bd21073 --- /dev/null +++ b/task/group_race_test.go @@ -0,0 +1,27 @@ +package task + +import ( + "fmt" + "sync" + "testing" +) + +// TestStartGroupSnapshotAllRace runs StartGroup concurrently with SnapshotAll; +// under -race it fails if global.groups is mutated without holding global.mu. +func TestStartGroupSnapshotAllRace(t *testing.T) { + withTestGlobal(t) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(2) + go func(n int) { + defer wg.Done() + StartGroup[int](fmt.Sprintf("race-group-%d", n), WithKind("race-test")) + }(i) + go func() { + defer wg.Done() + SnapshotAll() + }() + } + wg.Wait() +} diff --git a/task/manager.go b/task/manager.go index 2ccf04c8..8ec69f22 100644 --- a/task/manager.go +++ b/task/manager.go @@ -87,6 +87,8 @@ type Manager struct { stdoutWriter *os.File stderrReader *os.File stderrWriter *os.File + stdoutDone chan struct{} + stderrDone chan struct{} } var global *Manager @@ -115,6 +117,25 @@ func init() { SetNoColor(true) } + if !IsForceInteractive() && defaultNoColor(global.isInteractive.Load()) { + SetNoColor(true) + } +} + +// defaultNoColor mirrors formatters.IsNoColor conventions: color is disabled +// when output is not a terminal, NO_COLOR is set (https://no-color.org/), +// COLOR=no|false, or TERM=dumb. +func defaultNoColor(isInteractive bool) bool { + if !isInteractive { + return true + } + if os.Getenv("NO_COLOR") != "" { + return true + } + if v := strings.ToLower(os.Getenv("COLOR")); v == "no" || v == "false" { + return true + } + return os.Getenv("TERM") == "dumb" } func isTestEnvironment() bool { @@ -262,12 +283,12 @@ func newManagerWithConcurrency(maxConcurrent int) *Manager { select { case success := <-done: if success { - fmt.Fprintf(os.Stderr, "All tasks completed gracefully\n") + logger.Infof("All tasks completed gracefully") } else { - fmt.Fprintf(os.Stderr, "Task shutdown timeout reached\n") + logger.Warnf("Task shutdown timeout reached") } case <-time.After(tm.gracefulTimeout + time.Second): - fmt.Fprintf(os.Stderr, "Task shutdown timeout exceeded\n") + logger.Warnf("Task shutdown timeout exceeded") } tm.stopRender() @@ -486,8 +507,6 @@ func StartGroup[T any](name string, opts ...TaskGroupOption) TypedGroup[T] { cancel: cancel, } - global.groups = append(global.groups, group) - for _, opt := range opts { opt(group) } @@ -503,5 +522,11 @@ func StartGroup[T any](name string, opts ...TaskGroupOption) TypedGroup[T] { group.sem = semaphore.NewWeighted(int64(group.concurrency)) } + // Publish only after the group is fully constructed so concurrent + // SnapshotAll/RunsRaw readers never observe a half-initialized group. + global.mu.Lock() + global.groups = append(global.groups, group) + global.mu.Unlock() + return TypedGroup[T]{group} } diff --git a/task/manager_color_test.go b/task/manager_color_test.go new file mode 100644 index 00000000..17fcb365 --- /dev/null +++ b/task/manager_color_test.go @@ -0,0 +1,32 @@ +package task + +import "testing" + +func TestDefaultNoColor(t *testing.T) { + tests := []struct { + name string + isInteractive bool + env map[string]string + want bool + }{ + {name: "interactive with no env", isInteractive: true, want: false}, + {name: "non-interactive", isInteractive: false, want: true}, + {name: "NO_COLOR set", isInteractive: true, env: map[string]string{"NO_COLOR": "1"}, want: true}, + {name: "TERM=dumb", isInteractive: true, env: map[string]string{"TERM": "dumb"}, want: true}, + {name: "COLOR=no", isInteractive: true, env: map[string]string{"COLOR": "no"}, want: true}, + {name: "COLOR=FALSE", isInteractive: true, env: map[string]string{"COLOR": "FALSE"}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("COLOR", "") + t.Setenv("TERM", "xterm-256color") + for k, v := range tt.env { + t.Setenv(k, v) + } + if got := defaultNoColor(tt.isInteractive); got != tt.want { + t.Errorf("defaultNoColor(%v) with env %v = %v, want %v", tt.isInteractive, tt.env, got, tt.want) + } + }) + } +} diff --git a/task/manager_output.go b/task/manager_output.go index b92cd404..896d92ec 100644 --- a/task/manager_output.go +++ b/task/manager_output.go @@ -39,9 +39,6 @@ func (w *bufferingWriter) Write(p []byte) (n int, err error) { w.remainder = line break } - if line == "" { - continue - } w.manager.outputBuffer = append(w.manager.outputBuffer, OutputEntry{ Timestamp: time.Now(), Stream: w.stream, @@ -81,8 +78,17 @@ func (tm *Manager) StartCapturingOutput() { tm.outputBuffer = []OutputEntry{} // Create pipes for stdout and stderr - tm.stdoutReader, tm.stdoutWriter, _ = os.Pipe() - tm.stderrReader, tm.stderrWriter, _ = os.Pipe() + var err error + tm.stdoutReader, tm.stdoutWriter, err = os.Pipe() + if err != nil { + panic(fmt.Sprintf("task: failed to create capture pipe: %v", err)) + } + tm.stderrReader, tm.stderrWriter, err = os.Pipe() + if err != nil { + panic(fmt.Sprintf("task: failed to create capture pipe: %v", err)) + } + tm.stdoutDone = make(chan struct{}) + tm.stderrDone = make(chan struct{}) os.Stdout = tm.stdoutWriter os.Stderr = tm.stderrWriter @@ -90,39 +96,31 @@ func (tm *Manager) StartCapturingOutput() { // Start goroutines to read from pipes and buffer output. // Each goroutine owns a single bufferingWriter so that partial lines // are correctly accumulated across successive reads. - go func() { - w := &bufferingWriter{stream: "stdout", manager: tm} - buf := make([]byte, 4096) - for { - n, err := tm.stdoutReader.Read(buf) - if n > 0 { - _, _ = w.Write(buf[:n]) - } - if err != nil { - w.Flush() - return - } - } - }() - - go func() { - w := &bufferingWriter{stream: "stderr", manager: tm} - buf := make([]byte, 4096) - for { - n, err := tm.stderrReader.Read(buf) - if n > 0 { - _, _ = w.Write(buf[:n]) - } - if err != nil { - w.Flush() - return - } - } - }() + go tm.drainPipe(tm.stdoutReader, "stdout", tm.stdoutDone) + go tm.drainPipe(tm.stderrReader, "stderr", tm.stderrDone) tm.capturingOutput = true } +// drainPipe reads a capture pipe until EOF, buffering complete lines, +// then flushes the trailing partial line and closes done so +// StopCapturingOutput can join it before snapshotting the buffer. +func (tm *Manager) drainPipe(r *os.File, stream string, done chan struct{}) { + defer close(done) + w := &bufferingWriter{stream: stream, manager: tm} + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + _, _ = w.Write(buf[:n]) + } + if err != nil { + w.Flush() + return + } + } +} + // StartCapturingOutput replaces os.Stdout and os.Stderr on the global // task manager with pipes that buffer everything written until // StopCapturingOutput is called. The live task renderer is unaffected @@ -168,6 +166,13 @@ func (tm *Manager) StopCapturingOutput() { _ = tm.stderrWriter.Close() } + // Join the drain goroutines so every buffered byte (including the + // final partial line) lands in outputBuffer before the snapshot. + // bufferMutex must not be held here: the drainers' final Write/Flush + // acquire it. + <-tm.stdoutDone + <-tm.stderrDone + // Restore original stdout/stderr os.Stdout = tm.originalStdout os.Stderr = tm.originalStderr diff --git a/task/manager_output_test.go b/task/manager_output_test.go index 26941fb7..6283f574 100644 --- a/task/manager_output_test.go +++ b/task/manager_output_test.go @@ -69,6 +69,71 @@ func TestCaptureAndStopFlushesInOrder(t *testing.T) { } } +// swapTestPipes replaces os.Stdout/os.Stderr with test-owned pipes so the +// flush performed by StopCapturingOutput lands somewhere readable. The +// returned closeWriters must be called after Stop so io.ReadAll sees EOF. +func swapTestPipes(t *testing.T) (outR, errR *os.File, closeWriters func()) { + t.Helper() + realStdout := os.Stdout + realStderr := os.Stderr + t.Cleanup(func() { + os.Stdout = realStdout + os.Stderr = realStderr + }) + + outR, outW, err := os.Pipe() + if err != nil { + t.Fatalf("make stdout pipe: %v", err) + } + errR, errW, err := os.Pipe() + if err != nil { + t.Fatalf("make stderr pipe: %v", err) + } + os.Stdout = outW + os.Stderr = errW + return outR, errR, func() { + outW.Close() + errW.Close() + } +} + +// TestStopFlushesTrailingOutputWithoutDelay verifies that Stop joins the +// drain goroutines: a line written immediately before StopCapturingOutput +// (no sleep) must still appear in the flushed output. +func TestStopFlushesTrailingOutputWithoutDelay(t *testing.T) { + outR, errR, closeWriters := swapTestPipes(t) + + StartCapturingOutput() + fmt.Println("trailing-sentinel") + StopCapturingOutput() + closeWriters() + + stdoutBytes, _ := io.ReadAll(outR) + _, _ = io.ReadAll(errR) + + if !bytes.Contains(stdoutBytes, []byte("trailing-sentinel")) { + t.Errorf("Stop must flush output written just before it, got %q", stdoutBytes) + } +} + +// TestBlankLinesArePreserved verifies that empty lines round-trip through +// the capture buffer: "a\n\nb\n" must flush with the blank line intact. +func TestBlankLinesArePreserved(t *testing.T) { + outR, errR, closeWriters := swapTestPipes(t) + + StartCapturingOutput() + fmt.Print("a\n\nb\n") + StopCapturingOutput() + closeWriters() + + stdoutBytes, _ := io.ReadAll(outR) + _, _ = io.ReadAll(errR) + + if !bytes.Contains(stdoutBytes, []byte("a\n\nb\n")) { + t.Errorf("blank line between a and b must be preserved, got %q", stdoutBytes) + } +} + // TestStopWithoutStartIsSafe ensures StopCapturingOutput is a no-op when // StartCapturingOutput was never called — gavel relies on this to use // defer StopCapturingOutput() across code paths that may not have diff --git a/task/task.go b/task/task.go index 3df75e95..85db174d 100644 --- a/task/task.go +++ b/task/task.go @@ -732,7 +732,7 @@ func (t *Task) Pretty() api.Text { bufferedLogs = append(bufferedLogs, logger.BufferedLogEntry{Message: fmt.Sprintf("\t... %d more log lines ...", excess)}) } for _, log := range bufferedLogs { - if level <= log.Level { + if level < log.Level { continue } // Hide info logs when task completed successfully and log level is Info (0) or higher diff --git a/task/task_log_display_test.go b/task/task_log_display_test.go new file mode 100644 index 00000000..0e7e0e9a --- /dev/null +++ b/task/task_log_display_test.go @@ -0,0 +1,94 @@ +package task + +import ( + "strings" + "testing" + + "github.com/flanksource/commons/logger" + + "github.com/flanksource/clicky/text" +) + +const logDisplayMessage = "log-display-probe" + +// newTaskAtLogLevel creates an unqueued task whose context logger (which +// Pretty() consults for the display filter) and buffered logger (which gates +// Debugf/Tracef at append time) are both set to the given level, without +// touching global logger state. +func newTaskAtLogLevel(tm *Manager, name string, level logger.LogLevel) *Task { + task := tm.newTask(name) + ctxLogger := logger.NewBufferedLogger(1) + ctxLogger.SetLogLevel(level) + task.ctx.Logger = ctxLogger + return task +} + +func TestTaskLogDisplay(t *testing.T) { + tests := []struct { + name string + level logger.LogLevel + status Status + logFn func(*Task) + wantVisible bool + }{ + { + name: "failed task renders Infof entry at Info level", + level: logger.Info, + status: StatusFailed, + logFn: func(task *Task) { task.Infof("%s", logDisplayMessage) }, + wantVisible: true, + }, + { + name: "successful task hides Infof entry at Info level", + level: logger.Info, + status: StatusSuccess, + logFn: func(task *Task) { task.Infof("%s", logDisplayMessage) }, + wantVisible: false, + }, + { + name: "successful task renders Debugf entry at Debug level", + level: logger.Debug, + status: StatusSuccess, + logFn: func(task *Task) { task.Debugf("%s", logDisplayMessage) }, + wantVisible: true, + }, + { + name: "successful task renders Infof entry at Debug level", + level: logger.Debug, + status: StatusSuccess, + logFn: func(task *Task) { task.Infof("%s", logDisplayMessage) }, + wantVisible: true, + }, + { + name: "failed task renders Warnf entry at Info level", + level: logger.Info, + status: StatusFailed, + logFn: func(task *Task) { task.Warnf("%s", logDisplayMessage) }, + wantVisible: true, + }, + { + name: "failed task renders Errorf entry at Info level", + level: logger.Info, + status: StatusFailed, + logFn: func(task *Task) { task.Errorf("%s", logDisplayMessage) }, + wantVisible: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tm := newTestManager(1) + t.Cleanup(func() { close(tm.shutdown) }) + + task := newTaskAtLogLevel(tm, "log-display", tt.level) + tt.logFn(task) + task.status = tt.status + + rendered := text.StripANSI(task.Pretty().String()) + if got := strings.Contains(rendered, logDisplayMessage); got != tt.wantVisible { + t.Errorf("log message visible=%v, want %v at level %d with status %s; rendered:\n%s", + got, tt.wantVisible, tt.level, tt.status, rendered) + } + }) + } +} From dfc0aa3ac4c56819f9b49428b1f7fa91ad490c21 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 14:00:03 +0300 Subject: [PATCH 12/23] feat(entity): add MCPToolHints for tool metadata and annotations Introduce MCPToolHints struct to hold tool-level metadata including title, icon, group, parent, boolean hints (readOnly, destructive, idempotent, openWorld), default permission, and strict. Update entity building, action specs, and command annotation to use the new hints instead of a plain tool-group string. This enables richer MCP tool annotations and Clicky UI metadata inheritance. --- entity/annotations.go | 117 ++++++++++++++++++++++++++++++++++++--- entity/builder.go | 11 ++++ entity/command.go | 2 +- entity/entity.go | 44 +++++++++++---- entity/operation.go | 37 +++++++------ entity/sub_command.go | 2 +- entity/tool_hints.go | 88 +++++++++++++++++++++++++++++ entity/toolgroup_test.go | 74 ++++++++++++++++++++++++- entity_aliases.go | 21 +++++++ 9 files changed, 357 insertions(+), 39 deletions(-) create mode 100644 entity/tool_hints.go diff --git a/entity/annotations.go b/entity/annotations.go index a88c475a..fce84832 100644 --- a/entity/annotations.go +++ b/entity/annotations.go @@ -23,6 +23,28 @@ const ( annotationClickySupportsFilterMode = "clicky/supports-filter-mode" annotationClickyOptionalID = "clicky/operation-optional-id" annotationClickyToolGroup = "clicky/tool-group" + annotationClickyToolTitle = "clicky/tool-title" + annotationClickyToolIcon = "clicky/tool-icon" + annotationClickyToolParent = "clicky/tool-parent" + annotationClickyToolReadOnlyHint = "clicky/tool-read-only-hint" + annotationClickyToolDestructive = "clicky/tool-destructive-hint" + annotationClickyToolIdempotent = "clicky/tool-idempotent-hint" + annotationClickyToolOpenWorld = "clicky/tool-open-world-hint" + annotationClickyToolPermission = "clicky/tool-default-permission" + annotationClickyToolStrict = "clicky/tool-strict" +) + +const ( + AnnotationClickyToolGroup = annotationClickyToolGroup + AnnotationClickyToolTitle = annotationClickyToolTitle + AnnotationClickyToolIcon = annotationClickyToolIcon + AnnotationClickyToolParent = annotationClickyToolParent + AnnotationClickyToolReadOnlyHint = annotationClickyToolReadOnlyHint + AnnotationClickyToolDestructiveHint = annotationClickyToolDestructive + AnnotationClickyToolIdempotentHint = annotationClickyToolIdempotent + AnnotationClickyToolOpenWorldHint = annotationClickyToolOpenWorld + AnnotationClickyToolDefaultPermission = annotationClickyToolPermission + AnnotationClickyToolStrict = annotationClickyToolStrict ) // CommandOpenAPIMeta is the clicky-specific metadata attached to generated @@ -55,6 +77,7 @@ type CommandOpenAPIMeta struct { // AI tool-preference layers use it to enable/disable a set of related tools // as one unit. ToolGroup string + ToolHints MCPToolHints } func GetCommandOpenAPIMeta(cmd *cobra.Command) *CommandOpenAPIMeta { @@ -62,6 +85,31 @@ func GetCommandOpenAPIMeta(cmd *cobra.Command) *CommandOpenAPIMeta { return nil } + toolIcon := cmd.Annotations[annotationClickyToolIcon] + if toolIcon == "" { + toolIcon = cmd.Annotations[annotationClickyEntityIcon] + } + toolTitle := cmd.Annotations[annotationClickyToolTitle] + if toolTitle == "" { + toolTitle = cmd.Annotations[annotationClickyEntityTitle] + } + toolParent := cmd.Annotations[annotationClickyToolParent] + if toolParent == "" { + toolParent = cmd.Annotations[annotationClickyEntityParent] + } + toolHints := MCPToolHints{ + Title: toolTitle, + ReadOnlyHint: parseAnnotationBoolPtr(cmd.Annotations[annotationClickyToolReadOnlyHint]), + DestructiveHint: parseAnnotationBoolPtr(cmd.Annotations[annotationClickyToolDestructive]), + IdempotentHint: parseAnnotationBoolPtr(cmd.Annotations[annotationClickyToolIdempotent]), + OpenWorldHint: parseAnnotationBoolPtr(cmd.Annotations[annotationClickyToolOpenWorld]), + Icon: toolIcon, + Group: cmd.Annotations[annotationClickyToolGroup], + Parent: toolParent, + DefaultPermission: normalizeToolPermission(ToolPermission(cmd.Annotations[annotationClickyToolPermission])), + Strict: parseAnnotationBoolPtr(cmd.Annotations[annotationClickyToolStrict]), + } + meta := &CommandOpenAPIMeta{ Entity: cmd.Annotations[annotationClickyEntityName], Parent: cmd.Annotations[annotationClickyEntityParent], @@ -77,10 +125,11 @@ func GetCommandOpenAPIMeta(cmd *cobra.Command) *CommandOpenAPIMeta { SupportsLookup: parseAnnotationBool(cmd.Annotations[annotationClickySupportsLookup]), SupportsFilterMode: parseAnnotationBool(cmd.Annotations[annotationClickySupportsFilterMode]), OptionalID: parseAnnotationBool(cmd.Annotations[annotationClickyOptionalID]), - ToolGroup: cmd.Annotations[annotationClickyToolGroup], + ToolGroup: toolHints.Group, + ToolHints: toolHints, } - if meta.Entity == "" && meta.ToolGroup == "" { + if meta.Entity == "" && meta.ToolHints.isZero() { return nil } @@ -98,7 +147,20 @@ func annotateEntityCommand(cmd *cobra.Command, entity EntityInfo) { setCommandAnnotation(cmd, annotationClickyEntityAdmin, strconv.FormatBool(entity.IsAdmin)) setCommandAnnotation(cmd, annotationClickyEntityIcon, entity.Icon) setCommandAnnotation(cmd, annotationClickyEntityTitle, entity.Title) - setCommandAnnotation(cmd, annotationClickyToolGroup, entity.ToolGroup) + hints := entity.ToolHints + if hints.Group == "" { + hints.Group = entity.ToolGroup + } + if hints.Parent == "" { + hints.Parent = entity.Parent + } + if hints.Icon == "" { + hints.Icon = entity.Icon + } + if hints.Title == "" { + hints.Title = entity.Title + } + AnnotateTool(cmd, hints) } func annotateEntityOperationCommand( @@ -112,7 +174,7 @@ func annotateEntityOperationCommand( supportsLookup bool, supportsFilterMode bool, optionalID bool, - toolGroup string, + toolHints MCPToolHints, ) { if cmd == nil { return @@ -127,9 +189,9 @@ func annotateEntityOperationCommand( setCommandAnnotation(cmd, annotationClickySupportsLookup, strconv.FormatBool(supportsLookup)) setCommandAnnotation(cmd, annotationClickySupportsFilterMode, strconv.FormatBool(supportsFilterMode)) setCommandAnnotation(cmd, annotationClickyOptionalID, strconv.FormatBool(optionalID)) - // Applied after inheritance: a non-empty per-action override replaces the - // inherited entity group; empty is a no-op (setCommandAnnotation skips ""). - setCommandAnnotation(cmd, annotationClickyToolGroup, toolGroup) + // Applied after inheritance: non-empty per-action hints replace inherited + // entity hints; empty values are no-ops. + AnnotateTool(cmd, toolHints) } func inheritEntityAnnotations(cmd *cobra.Command, parent *cobra.Command) { @@ -144,7 +206,35 @@ func inheritEntityAnnotations(cmd *cobra.Command, parent *cobra.Command) { setCommandAnnotation(cmd, annotationClickyEntityAdmin, strconv.FormatBool(meta.Admin)) setCommandAnnotation(cmd, annotationClickyEntityIcon, meta.Icon) setCommandAnnotation(cmd, annotationClickyEntityTitle, meta.Title) - setCommandAnnotation(cmd, annotationClickyToolGroup, meta.ToolGroup) + AnnotateTool(cmd, meta.ToolHints) +} + +// AnnotateTool attaches MCP-facing tool hints to cmd. It is the public helper +// for commands that are not generated from an EntityBuilder. +func AnnotateTool(cmd *cobra.Command, hints MCPToolHints) { + if cmd == nil { + return + } + setCommandAnnotation(cmd, annotationClickyToolTitle, hints.Title) + setCommandAnnotation(cmd, annotationClickyToolIcon, hints.Icon) + setCommandAnnotation(cmd, annotationClickyToolParent, hints.Parent) + setCommandAnnotation(cmd, annotationClickyToolGroup, hints.Group) + if hints.ReadOnlyHint != nil { + setCommandAnnotation(cmd, annotationClickyToolReadOnlyHint, strconv.FormatBool(*hints.ReadOnlyHint)) + } + if hints.DestructiveHint != nil { + setCommandAnnotation(cmd, annotationClickyToolDestructive, strconv.FormatBool(*hints.DestructiveHint)) + } + if hints.IdempotentHint != nil { + setCommandAnnotation(cmd, annotationClickyToolIdempotent, strconv.FormatBool(*hints.IdempotentHint)) + } + if hints.OpenWorldHint != nil { + setCommandAnnotation(cmd, annotationClickyToolOpenWorld, strconv.FormatBool(*hints.OpenWorldHint)) + } + setCommandAnnotation(cmd, annotationClickyToolPermission, string(normalizeToolPermission(hints.DefaultPermission))) + if hints.Strict != nil { + setCommandAnnotation(cmd, annotationClickyToolStrict, strconv.FormatBool(*hints.Strict)) + } } func setCommandAnnotation(cmd *cobra.Command, key string, value string) { @@ -162,6 +252,17 @@ func parseAnnotationBool(value string) bool { return err == nil && parsed } +func parseAnnotationBoolPtr(value string) *bool { + if value == "" { + return nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return nil + } + return &parsed +} + func splitAnnotationList(value string) []string { if value == "" { return nil diff --git a/entity/builder.go b/entity/builder.go index 3eb32b2a..2624dcee 100644 --- a/entity/builder.go +++ b/entity/builder.go @@ -37,6 +37,17 @@ func (b *EntityBuilder[T, ListOpts, R]) Aliases(aliases ...string) *EntityBuilde // tool-preference layers use the group to enable/disable related tools together. func (b *EntityBuilder[T, ListOpts, R]) ToolGroup(group string) *EntityBuilder[T, ListOpts, R] { b.entity.ToolGroup = group + b.entity.ToolHints.Group = group + return b +} + +// ToolHints sets MCP-facing annotations and Clicky UI metadata inherited by all +// generated operations. Per-action hints can override these values. +func (b *EntityBuilder[T, ListOpts, R]) ToolHints(hints MCPToolHints) *EntityBuilder[T, ListOpts, R] { + b.entity.ToolHints = b.entity.ToolHints.merge(hints) + if hints.Group != "" { + b.entity.ToolGroup = hints.Group + } return b } diff --git a/entity/command.go b/entity/command.go index eb512129..d6ca36dc 100644 --- a/entity/command.go +++ b/entity/command.go @@ -210,7 +210,7 @@ func addNamedCommand[T any, R any]( subFilters = carrier.Filters() } if meta := GetCommandOpenAPIMeta(parent); meta != nil { - annotateEntityOperationCommand(cmd, parent, "action", "", "collection", name, "", len(subFilters) > 0, false, false, "") + annotateEntityOperationCommand(cmd, parent, "action", "", "collection", name, "", len(subFilters) > 0, false, false, MCPToolHints{}) } if len(subFilters) > 0 { lookupFuncRegistry.Store(cmd, buildLookupFunc[T](subFilters)) diff --git a/entity/entity.go b/entity/entity.go index 8ae25324..b28b1220 100644 --- a/entity/entity.go +++ b/entity/entity.go @@ -54,9 +54,9 @@ type EntityItem interface { // EntityInfo is the type-erased representation stored in the registry. type EntityInfo struct { - Name string - Parent string - Aliases []string + Name string + Parent string + Aliases []string // Icon is an opaque UI icon name carried through to the OpenAPI surface // (x-clicky.surfaces[].icon). Empty for entities that declare no icon. Icon string @@ -78,6 +78,7 @@ type EntityInfo struct { // inherited by every generated operation (an action may override it) and is // used by AI tool-preference layers to toggle related tools together. ToolGroup string + ToolHints MCPToolHints } // EntityOperation represents a single CRUD operation. @@ -130,6 +131,7 @@ type ActionInfo struct { // ToolGroup overrides the entity's tool group for this action only. Empty // means the action inherits the entity's group. ToolGroup string + ToolHints MCPToolHints } // BulkActionInfo is the type-erased representation of a bulk action. @@ -315,6 +317,7 @@ type ActionSpec[R any] struct { flags ActionFlags optionalID bool toolGroup string + toolHints MCPToolHints } // Action creates a typed custom operation on a single entity by ID. @@ -368,6 +371,16 @@ func (a *ActionSpec[R]) WithOptionalID() *ActionSpec[R] { // (the default) means the action inherits the entity's group. func (a *ActionSpec[R]) WithToolGroup(group string) *ActionSpec[R] { a.toolGroup = group + a.toolHints.Group = group + return a +} + +// WithToolHints overrides this action's inherited MCP tool hints. +func (a *ActionSpec[R]) WithToolHints(hints MCPToolHints) *ActionSpec[R] { + a.toolHints = a.toolHints.merge(hints) + if hints.Group != "" { + a.toolGroup = hints.Group + } return a } @@ -391,6 +404,7 @@ func (a *ActionSpec[R]) actionInfo() ActionInfo { ResponseType: responseTypeOf[R](), OptionalID: a.optionalID, ToolGroup: a.toolGroup, + ToolHints: a.toolHints, } if a.runCtx != nil { info.ContextDataFunc = func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { @@ -556,6 +570,7 @@ type Entity[T EntityItem, ListOpts any, R any] struct { // WithToolGroup. AI tool-preference layers use it to toggle related tools // together. ToolGroup string + ToolHints MCPToolHints } // entityIDFrom resolves the entity id from the `id` flag or the first @@ -612,6 +627,13 @@ func RegisterEntity[T EntityItem, ListOpts any, R any](e Entity[T, ListOpts, R]) ValidArgs: e.ValidArgs, FilterRefs: entityFilterRefs(e.Filters), ToolGroup: e.ToolGroup, + ToolHints: e.ToolHints, + } + if info.ToolHints.Group == "" { + info.ToolHints.Group = info.ToolGroup + } + if info.ToolGroup == "" { + info.ToolGroup = info.ToolHints.Group } if e.ListPagedWithContext != nil || e.ListPaged != nil || e.ListWithContext != nil || e.List != nil { @@ -1022,7 +1044,7 @@ func generateEntityCLI(parent *cobra.Command, entity EntityInfo) { ContextDataFunc: action.ContextDataFunc, FlagsType: action.FlagsType, ResponseType: action.ResponseType, - }, entity.ValidArgs, "action", "", "entity", action.Name, "id", false, false, action.OptionalID, action.ToolGroup) + }, entity.ValidArgs, "action", "", "entity", action.Name, "id", false, false, action.OptionalID, action.ToolHints) } for _, ba := range entity.BulkActions { @@ -1103,7 +1125,7 @@ func generateEntitySubcommand(parent *cobra.Command, entity EntityInfo, op Entit false, false, false, - "", + MCPToolHints{}, ) case "create": generateBodyCommand(parent, "create", fmt.Sprintf("Create a %s", entity.Name), op) @@ -1124,7 +1146,7 @@ func generateEntitySubcommand(parent *cobra.Command, entity EntityInfo, op Entit false, false, false, - "", + MCPToolHints{}, ) } } @@ -1185,7 +1207,7 @@ func generateListCommand(parent *cobra.Command, entity EntityInfo, op EntityOper op.BindCompletions(cmd) } - annotateEntityOperationCommand(cmd, parent, "list", "", "collection", "", "", op.LookupFunc != nil, false, false, "") + annotateEntityOperationCommand(cmd, parent, "list", "", "collection", "", "", op.LookupFunc != nil, false, false, MCPToolHints{}) parent.AddCommand(cmd) storeEntityDataFuncs(cmd, op) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{ @@ -1216,7 +1238,7 @@ func generateIDCommand( supportsLookup bool, supportsFilterMode bool, optionalID bool, - toolGroup string, + toolHints MCPToolHints, ) { hasFlags := op.FlagsType != nil idToken := "" @@ -1263,7 +1285,7 @@ func generateIDCommand( if method == "" { method = op.Method } - annotateEntityOperationCommand(cmd, parent, metaVerb, method, scope, actionName, idParam, supportsLookup, supportsFilterMode, optionalID, toolGroup) + annotateEntityOperationCommand(cmd, parent, metaVerb, method, scope, actionName, idParam, supportsLookup, supportsFilterMode, optionalID, toolHints) parent.AddCommand(cmd) storeEntityDataFuncs(cmd, op) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{ @@ -1344,7 +1366,7 @@ func generateBodyCommand(parent *cobra.Command, verb, short string, op EntityOpe scope = "entity" idParam = "id" } - annotateEntityOperationCommand(cmd, parent, verb, "", scope, "", idParam, false, false, false, "") + annotateEntityOperationCommand(cmd, parent, verb, "", scope, "", idParam, false, false, false, MCPToolHints{}) parent.AddCommand(cmd) storeEntityDataFuncs(cmd, op) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{ @@ -1392,7 +1414,7 @@ func generateBulkActionCommand(parent *cobra.Command, ba BulkActionInfo) { } } - annotateEntityOperationCommand(cmd, parent, "action", "", "collection", ba.Name, "id", ba.LookupFunc != nil, ba.FilterFunc != nil, false, "") + annotateEntityOperationCommand(cmd, parent, "action", "", "collection", ba.Name, "id", ba.LookupFunc != nil, ba.FilterFunc != nil, false, MCPToolHints{}) parent.AddCommand(cmd) dataFuncRegistry.Store(cmd, execute) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{Type: ba.ResponseType}) diff --git a/entity/operation.go b/entity/operation.go index 971f3ec3..520e1150 100644 --- a/entity/operation.go +++ b/entity/operation.go @@ -20,6 +20,7 @@ type RPCOperation struct { Method string `json:"method,omitempty"` // HTTP method Tags []string `json:"tags,omitempty"` // OpenAPI tags (auto-derived from the parent command) Group string `json:"group,omitempty"` // tool-group this operation belongs to (clicky/tool-group) + ToolHints MCPToolHints `json:"-"` // MCP annotations and Clicky-specific tool metadata DataFunc DataFunc `json:"-"` // Direct data provider, bypasses stdout capture ContextDataFunc ContextDataFunc `json:"-"` // Context-aware data provider; preferred over DataFunc when set LookupFunc DataFunc `json:"-"` // Direct filter metadata provider @@ -54,23 +55,25 @@ type ClickySurface struct { // extension. SurfaceID/Aliases are internal-only fields used while building // the final surface list. type ClickyOperationMeta struct { - SurfaceID string `json:"-"` - Command string `json:"command,omitempty"` - Surface string `json:"surface,omitempty"` - Entity string `json:"-"` - Parent string `json:"-"` - Aliases []string `json:"-"` - Admin bool `json:"-"` - Icon string `json:"-"` - Title string `json:"-"` - Verb string `json:"verb,omitempty"` - Scope string `json:"scope,omitempty"` - ActionName string `json:"actionName,omitempty"` - IDParam string `json:"idParam,omitempty"` - SupportsLookup bool `json:"supportsLookup,omitempty"` - SupportsFilterMode bool `json:"supportsFilterMode,omitempty"` - Group string `json:"group,omitempty"` - Order int `json:"-"` + SurfaceID string `json:"-"` + Command string `json:"command,omitempty"` + Surface string `json:"surface,omitempty"` + Entity string `json:"-"` + Parent string `json:"-"` + Aliases []string `json:"-"` + Admin bool `json:"-"` + Icon string `json:"-"` + Title string `json:"-"` + Verb string `json:"verb,omitempty"` + Scope string `json:"scope,omitempty"` + ActionName string `json:"actionName,omitempty"` + IDParam string `json:"idParam,omitempty"` + SupportsLookup bool `json:"supportsLookup,omitempty"` + SupportsFilterMode bool `json:"supportsFilterMode,omitempty"` + Group string `json:"group,omitempty"` + ToolHints MCPToolHints `json:"-"` + OpenAPIToolHints *MCPToolHints `json:"toolHints,omitempty"` + Order int `json:"-"` } // RPCParameter represents a parameter in an RPC operation. diff --git a/entity/sub_command.go b/entity/sub_command.go index 9ca17a7e..6ad1187e 100644 --- a/entity/sub_command.go +++ b/entity/sub_command.go @@ -75,7 +75,7 @@ func flushPendingSubCommands(root *cobra.Command) { if actionName == "" { actionName = p.cmd.Use } - annotateEntityOperationCommand(p.cmd, parent, "action", "", "collection", actionName, "", false, false, false, "") + annotateEntityOperationCommand(p.cmd, parent, "action", "", "collection", actionName, "", false, false, false, MCPToolHints{}) } parent.AddCommand(p.cmd) } diff --git a/entity/tool_hints.go b/entity/tool_hints.go new file mode 100644 index 00000000..81b5bd6d --- /dev/null +++ b/entity/tool_hints.go @@ -0,0 +1,88 @@ +package entity + +// ToolPermission is the default approval mode a tool advertises to clients. +type ToolPermission string + +const ( + ToolPermissionAsk ToolPermission = "ask" + ToolPermissionOff ToolPermission = "off" + ToolPermissionOn ToolPermission = "on" + ToolPermissionAuto ToolPermission = "auto" +) + +// MCPToolHints describes MCP tool annotations plus Clicky-specific UI and +// approval metadata. Boolean fields are pointers because both true and false +// are meaningful MCP hint values. +type MCPToolHints struct { + Title string `json:"title,omitempty"` + ReadOnlyHint *bool `json:"readOnlyHint,omitempty"` + DestructiveHint *bool `json:"destructiveHint,omitempty"` + IdempotentHint *bool `json:"idempotentHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` + Icon string `json:"icon,omitempty"` + Group string `json:"group,omitempty"` + Parent string `json:"parent,omitempty"` + DefaultPermission ToolPermission `json:"defaultPermission,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + +func (h MCPToolHints) isZero() bool { + return h.Title == "" && + h.ReadOnlyHint == nil && + h.DestructiveHint == nil && + h.IdempotentHint == nil && + h.OpenWorldHint == nil && + h.Icon == "" && + h.Group == "" && + h.Parent == "" && + h.DefaultPermission == "" && + h.Strict == nil +} + +// IsZero reports whether no tool metadata or MCP annotations are set. +func (h MCPToolHints) IsZero() bool { + return h.isZero() +} + +func (h MCPToolHints) merge(override MCPToolHints) MCPToolHints { + if override.Title != "" { + h.Title = override.Title + } + if override.ReadOnlyHint != nil { + h.ReadOnlyHint = override.ReadOnlyHint + } + if override.DestructiveHint != nil { + h.DestructiveHint = override.DestructiveHint + } + if override.IdempotentHint != nil { + h.IdempotentHint = override.IdempotentHint + } + if override.OpenWorldHint != nil { + h.OpenWorldHint = override.OpenWorldHint + } + if override.Icon != "" { + h.Icon = override.Icon + } + if override.Group != "" { + h.Group = override.Group + } + if override.Parent != "" { + h.Parent = override.Parent + } + if override.DefaultPermission != "" { + h.DefaultPermission = override.DefaultPermission + } + if override.Strict != nil { + h.Strict = override.Strict + } + return h +} + +func normalizeToolPermission(permission ToolPermission) ToolPermission { + switch permission { + case ToolPermissionAsk, ToolPermissionOff, ToolPermissionOn, ToolPermissionAuto: + return permission + default: + return "" + } +} diff --git a/entity/toolgroup_test.go b/entity/toolgroup_test.go index 49657fbe..dd6eb849 100644 --- a/entity/toolgroup_test.go +++ b/entity/toolgroup_test.go @@ -15,7 +15,7 @@ func toolGroupOf(t *testing.T, entityGroup, override string) string { annotateEntityCommand(entityCmd, EntityInfo{Name: "invoice", ToolGroup: entityGroup}) opCmd := &cobra.Command{Use: "list"} - annotateEntityOperationCommand(opCmd, entityCmd, "list", "", "collection", "", "", false, false, false, override) + annotateEntityOperationCommand(opCmd, entityCmd, "list", "", "collection", "", "", false, false, false, MCPToolHints{Group: override}) meta := GetCommandOpenAPIMeta(opCmd) if meta == nil { @@ -83,6 +83,9 @@ func TestToolGroupBuilderAndAction(t *testing.T) { if e.ToolGroup != "billing" { t.Errorf("Entity.ToolGroup = %q, want billing", e.ToolGroup) } + if e.ToolHints.Group != "billing" { + t.Errorf("Entity.ToolHints.Group = %q, want billing", e.ToolHints.Group) + } action := Action("recalculate", func(id string, _ map[string]string) (sampleTableEntity, error) { return sampleTableEntity{ID: id}, nil @@ -90,4 +93,73 @@ func TestToolGroupBuilderAndAction(t *testing.T) { if got := action.actionInfo().ToolGroup; got != "ops" { t.Errorf("ActionInfo.ToolGroup = %q, want ops", got) } + if got := action.actionInfo().ToolHints.Group; got != "ops" { + t.Errorf("ActionInfo.ToolHints.Group = %q, want ops", got) + } +} + +func TestToolHintsAnnotationFlow(t *testing.T) { + readOnly := true + destructive := false + idempotent := true + openWorld := false + strict := true + cmd := &cobra.Command{Use: "sync"} + AnnotateTool(cmd, MCPToolHints{ + Title: "Sync invoices", + Icon: "refresh-cw", + Group: "billing", + Parent: "invoice", + ReadOnlyHint: &readOnly, + DestructiveHint: &destructive, + IdempotentHint: &idempotent, + OpenWorldHint: &openWorld, + DefaultPermission: ToolPermissionAsk, + Strict: &strict, + }) + + meta := GetCommandOpenAPIMeta(cmd) + if meta == nil { + t.Fatal("GetCommandOpenAPIMeta returned nil for a command with tool hints") + } + hints := meta.ToolHints + if hints.Title != "Sync invoices" || hints.Icon != "refresh-cw" || hints.Group != "billing" || hints.Parent != "invoice" { + t.Fatalf("unexpected string hints: %+v", hints) + } + if hints.ReadOnlyHint == nil || !*hints.ReadOnlyHint { + t.Fatalf("ReadOnlyHint = %v, want true", hints.ReadOnlyHint) + } + if hints.DestructiveHint == nil || *hints.DestructiveHint { + t.Fatalf("DestructiveHint = %v, want false", hints.DestructiveHint) + } + if hints.IdempotentHint == nil || !*hints.IdempotentHint { + t.Fatalf("IdempotentHint = %v, want true", hints.IdempotentHint) + } + if hints.OpenWorldHint == nil || *hints.OpenWorldHint { + t.Fatalf("OpenWorldHint = %v, want false", hints.OpenWorldHint) + } + if hints.DefaultPermission != ToolPermissionAsk { + t.Fatalf("DefaultPermission = %q, want ask", hints.DefaultPermission) + } + if hints.Strict == nil || !*hints.Strict { + t.Fatalf("Strict = %v, want true", hints.Strict) + } +} + +func TestToolHintsBuilderAndAction(t *testing.T) { + strict := true + e := NewEntity[sampleTableEntity, toolGroupOpts, sampleTableEntity]("invoice"). + ToolHints(MCPToolHints{Group: "billing", Parent: "accounting", DefaultPermission: ToolPermissionAuto, Strict: &strict}). + Build() + if e.ToolGroup != "billing" || e.ToolHints.Parent != "accounting" || e.ToolHints.DefaultPermission != ToolPermissionAuto { + t.Fatalf("entity hints = %+v, group=%q", e.ToolHints, e.ToolGroup) + } + + action := Action("recalculate", func(id string, _ map[string]string) (sampleTableEntity, error) { + return sampleTableEntity{ID: id}, nil + }).WithToolHints(MCPToolHints{Group: "ops", DefaultPermission: ToolPermissionOn}) + info := action.actionInfo() + if info.ToolGroup != "ops" || info.ToolHints.Group != "ops" || info.ToolHints.DefaultPermission != ToolPermissionOn { + t.Fatalf("action hints = %+v, group=%q", info.ToolHints, info.ToolGroup) + } } diff --git a/entity_aliases.go b/entity_aliases.go index a31503e7..4cfbdbd6 100644 --- a/entity_aliases.go +++ b/entity_aliases.go @@ -37,6 +37,8 @@ type ( EntityBulkAction = entity.EntityBulkAction CommandOpenAPIMeta = entity.CommandOpenAPIMeta ResponseOpenAPIMeta = entity.ResponseOpenAPIMeta + MCPToolHints = entity.MCPToolHints + ToolPermission = entity.ToolPermission DynamicFilter = entity.DynamicFilter DynamicEntitySpec = entity.DynamicEntitySpec ContextDataFunc = entity.ContextDataFunc @@ -78,6 +80,25 @@ var ( GetCommandOpenAPIMeta = entity.GetCommandOpenAPIMeta GetCommandResponseMeta = entity.GetCommandResponseMeta SetCommandResponseMeta = entity.SetCommandResponseMeta + AnnotateTool = entity.AnnotateTool +) + +const ( + ToolPermissionAsk = entity.ToolPermissionAsk + ToolPermissionOff = entity.ToolPermissionOff + ToolPermissionOn = entity.ToolPermissionOn + ToolPermissionAuto = entity.ToolPermissionAuto + + AnnotationClickyToolGroup = entity.AnnotationClickyToolGroup + AnnotationClickyToolTitle = entity.AnnotationClickyToolTitle + AnnotationClickyToolIcon = entity.AnnotationClickyToolIcon + AnnotationClickyToolParent = entity.AnnotationClickyToolParent + AnnotationClickyToolReadOnlyHint = entity.AnnotationClickyToolReadOnlyHint + AnnotationClickyToolDestructiveHint = entity.AnnotationClickyToolDestructiveHint + AnnotationClickyToolIdempotentHint = entity.AnnotationClickyToolIdempotentHint + AnnotationClickyToolOpenWorldHint = entity.AnnotationClickyToolOpenWorldHint + AnnotationClickyToolDefaultPermission = entity.AnnotationClickyToolDefaultPermission + AnnotationClickyToolStrict = entity.AnnotationClickyToolStrict ) // --- generic function wrappers (a generic func cannot be a var alias) --- From 4dc6c6dc4e9e64886182457fb2188e8aae99363e Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 14:00:12 +0300 Subject: [PATCH 13/23] feat(aichat): Add default permission and tool metadata support with canonical mode names Introduce canonical tool permission modes (on/off/ask/auto) with backward compatibility for legacy labels. Add DefaultPermission, Parent, Icon, and Strict fields to ToolInfo and ToolCatalogEntry to control tool behavior without user preferences. Refactor effortConfig to delegate to captain's shared EffortConfig, removing duplicate provider-specific logic. Update tests to verify mode normalization, default permission filtering, and temperature gating. --- aichat/approval.go | 52 +++++++++--- aichat/approval_test.go | 46 ++++++++++ aichat/genkit.go | 94 +++++++------------- aichat/models.go | 9 +- aichat/models_test.go | 29 +++++-- aichat/tool_catalog.go | 160 ++++++++++++++++++++++++----------- aichat/tool_registry.go | 79 ++++++++++++----- aichat/tool_registry_test.go | 15 +++- 8 files changed, 323 insertions(+), 161 deletions(-) diff --git a/aichat/approval.go b/aichat/approval.go index 57737e64..e53bffe1 100644 --- a/aichat/approval.go +++ b/aichat/approval.go @@ -15,24 +15,31 @@ const approvalKey = "approval" type ToolMode string const ( - ToolModeEnabled ToolMode = "enabled" - ToolModeAsk ToolMode = "ask" - ToolModeDisabled ToolMode = "disabled" + ToolModeOn ToolMode = "on" + ToolModeAsk ToolMode = "ask" + ToolModeOff ToolMode = "off" + ToolModeAuto ToolMode = "auto" + + // Backward-compatible aliases for the labels older clients send. + ToolModeEnabled ToolMode = ToolModeOn + ToolModeDisabled ToolMode = ToolModeOff ) // ToolPreferences carries the clicky-ui tool preference payload. The UI sends -// "enabled", "ask", or "disabled"; "auto" and "off" are accepted for older -// callers that used those labels. +// "on", "ask", "off", or "auto"; "enabled" and "disabled" are accepted for +// older callers that used those labels. type ToolPreferences map[string]ToolMode func normalizeToolMode(mode ToolMode) (ToolMode, bool) { switch ToolMode(strings.ToLower(strings.TrimSpace(string(mode)))) { - case ToolModeEnabled, "auto": - return ToolModeEnabled, true + case ToolModeOn, "enabled": + return ToolModeOn, true case ToolModeAsk: return ToolModeAsk, true - case ToolModeDisabled, "off": - return ToolModeDisabled, true + case ToolModeOff, "disabled": + return ToolModeOff, true + case ToolModeAuto: + return ToolModeAuto, true default: return "", false } @@ -49,8 +56,12 @@ type ToolInfo struct { // Group is the tool-group this tool belongs to (clicky/tool-group). When // non-empty the preferences UI presents the group as a single entry and the // group's preference governs every member tool. - Group string - Operation *rpc.RPCOperation + Group string + Parent string + Icon string + DefaultPermission ToolMode + Strict *bool + Operation *rpc.RPCOperation } func toolInfo(name string, op *rpc.RPCOperation) ToolInfo { @@ -62,9 +73,28 @@ func toolInfo(name string, op *rpc.RPCOperation) ToolInfo { info.Method = strings.ToUpper(op.Method) info.Path = op.Path info.Group = op.Group + info.DefaultPermission = ToolMode(op.ToolHints.DefaultPermission) + info.Parent = op.ToolHints.Parent + info.Icon = op.ToolHints.Icon + info.Strict = op.ToolHints.Strict if op.Clicky != nil { info.ClickyVerb = op.Clicky.Verb info.ClickyScope = op.Clicky.Scope + if info.Group == "" { + info.Group = op.Clicky.Group + } + if info.DefaultPermission == "" { + info.DefaultPermission = ToolMode(op.Clicky.ToolHints.DefaultPermission) + } + if info.Parent == "" { + info.Parent = op.Clicky.ToolHints.Parent + } + if info.Icon == "" { + info.Icon = op.Clicky.ToolHints.Icon + } + if info.Strict == nil { + info.Strict = op.Clicky.ToolHints.Strict + } } return info } diff --git a/aichat/approval_test.go b/aichat/approval_test.go index c09026f7..2ea0c7e8 100644 --- a/aichat/approval_test.go +++ b/aichat/approval_test.go @@ -47,6 +47,41 @@ func TestToolPreferencesOverrideDefaultApproval(t *testing.T) { } } +func TestToolModeNormalizesCanonicalAndLegacyLabels(t *testing.T) { + tests := map[ToolMode]ToolMode{ + "enabled": ToolModeOn, + "disabled": ToolModeOff, + "on": ToolModeOn, + "off": ToolModeOff, + "ask": ToolModeAsk, + "auto": ToolModeAuto, + } + for input, want := range tests { + got, ok := normalizeToolMode(input) + if !ok || got != want { + t.Fatalf("normalizeToolMode(%q) = (%q,%v), want (%q,true)", input, got, ok, want) + } + } +} + +func TestDefaultPermissionModes(t *testing.T) { + defaultGate := func(ToolInfo, any) bool { return true } + ctx := withToolRuntime(context.Background(), toolRuntimeConfig{defaultApproval: defaultGate}) + + if shouldRequireApproval(ctx, nil, ToolInfo{Name: "auto_run", DefaultPermission: ToolModeOn}, nil) { + t.Error("default permission on should bypass approval") + } + if !shouldRequireApproval(ctx, nil, ToolInfo{Name: "manual", DefaultPermission: ToolModeAsk}, nil) { + t.Error("default permission ask should require approval") + } + if shouldRequireApproval(ctx, nil, ToolInfo{Name: "hidden", DefaultPermission: ToolModeOff}, nil) { + t.Error("default permission off should not require approval") + } + if !shouldRequireApproval(ctx, nil, ToolInfo{Name: "policy", DefaultPermission: ToolModeAuto}, nil) { + t.Error("default permission auto should defer to the runtime default approval policy") + } +} + func TestToolsForRequestDropsDisabledTools(t *testing.T) { tools := []registeredTool{ {ref: namedTool("stack_list"), info: ToolInfo{Name: "stack_list"}}, @@ -58,6 +93,17 @@ func TestToolsForRequestDropsDisabledTools(t *testing.T) { } } +func TestToolsForRequestDropsDefaultOffTools(t *testing.T) { + tools := []registeredTool{ + {ref: namedTool("visible"), info: ToolInfo{Name: "visible", DefaultPermission: ToolModeAuto}}, + {ref: namedTool("hidden"), info: ToolInfo{Name: "hidden", DefaultPermission: ToolModeOff}}, + } + refs := toolsForRequest(tools, nil) + if len(refs) != 1 || refs[0].Name() != "visible" { + t.Fatalf("refs = %+v, want only visible", refs) + } +} + func TestCatalogInfoMarksConfiguredProviders(t *testing.T) { info := CatalogInfo([]Provider{ProviderAnthropic}) if len(info) != len(catalog) { diff --git a/aichat/genkit.go b/aichat/genkit.go index 4852bba7..be8c8098 100644 --- a/aichat/genkit.go +++ b/aichat/genkit.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core/api" @@ -11,6 +12,9 @@ import ( "github.com/firebase/genkit/go/plugins/anthropic" "github.com/firebase/genkit/go/plugins/compat_oai/openai" "github.com/firebase/genkit/go/plugins/googlegenai" + + capai "github.com/flanksource/captain/pkg/ai" + capapi "github.com/flanksource/captain/pkg/api" ) // ProviderCredential is a request-scoped API key for one upstream AI provider. @@ -124,41 +128,16 @@ func firstNonEmptyString(vals ...string) string { return "" } -const defaultMaxOutputTokens = 4096 - -// effortConfig builds the provider-specific generation config that translates -// an Effort value into the provider's native reasoning control. Anthropic also -// requires max_tokens on every request, so return a config for Anthropic even -// when no reasoning effort is selected. +// effortConfig delegates the effort/temperature translation to captain's shared +// EffortConfig — the single source of truth for the adaptive-vs-enabled thinking +// schema and per-model capability gating (reasoning, temperature) — then adds +// the Genkit non-Anthropic maxOutputTokens cap. func effortConfig(m Model, e Effort, budget ChatBudget, temperature *float64) map[string]any { - cfg := map[string]any{} - switch m.Provider { - case ProviderOpenAI: - if m.Reasoning && e != EffortNone { - // OpenAI o-series: reasoning_effort low|medium|high. - cfg["reasoning_effort"] = string(e) - } - case ProviderGoogle: - if m.Reasoning && e != EffortNone { - // Gemini 2.5+: thinkingConfig.thinkingBudget (token budget). - cfg["thinkingConfig"] = map[string]any{"thinkingBudget": geminiThinkingBudget(e)} + cfg := capai.EffortConfig(genkitBackend(m.Provider), bareModelID(m.ID), capapi.Effort(e), budget.MaxTokens, temperature) + if m.Provider != ProviderAnthropic && budget.MaxTokens > 0 { + if cfg == nil { + cfg = map[string]any{} } - case ProviderAnthropic: - cfg["max_tokens"] = anthropicMaxTokens(e, budget.MaxTokens) - if m.Reasoning && e != EffortNone { - // Anthropic: extended-thinking budget tokens. - cfg["thinking"] = map[string]any{ - "type": "enabled", - "budget_tokens": anthropicThinkingBudget(e), - } - } - default: - return nil - } - if temperature != nil { - cfg["temperature"] = *temperature - } - if budget.MaxTokens > 0 && m.Provider != ProviderAnthropic { cfg["maxOutputTokens"] = budget.MaxTokens } if len(cfg) == 0 { @@ -167,43 +146,28 @@ func effortConfig(m Model, e Effort, budget ChatBudget, temperature *float64) ma return cfg } -func anthropicThinkingBudget(e Effort) int { - switch e { - case EffortLow: - return 2048 - case EffortMedium: - return 8192 - case EffortHigh: - return 24576 +// genkitBackend maps a Genkit provider to captain's ai.Backend so EffortConfig +// can resolve the model's capabilities from captain's registry. +func genkitBackend(p Provider) capapi.Backend { + switch p { + case ProviderAnthropic: + return capapi.BackendAnthropic + case ProviderOpenAI: + return capapi.BackendOpenAI + case ProviderGoogle: + return capapi.BackendGemini default: - return 0 + return "" } } -func anthropicMaxTokens(e Effort, maxOutputTokens int) int { - if maxOutputTokens <= 0 { - maxOutputTokens = defaultMaxOutputTokens - } - budget := anthropicThinkingBudget(e) - if budget == 0 { - return maxOutputTokens - } - // Anthropic's thinking budget is counted inside max_tokens. Leave room for - // the visible answer in addition to the hidden thinking budget. - return budget + maxOutputTokens -} - -func geminiThinkingBudget(e Effort) int { - switch e { - case EffortLow: - return 2048 - case EffortMedium: - return 8192 - case EffortHigh: - return 24576 - default: - return 0 +// bareModelID drops the "provider/" prefix from a Genkit model id so captain's +// registry lookup hits the exact model. +func bareModelID(id string) string { + if i := strings.IndexByte(id, '/'); i >= 0 { + return id[i+1:] } + return id } // generateOptions assembles the Generate options for a chat turn: model, diff --git a/aichat/models.go b/aichat/models.go index f6f5f271..ef5ab7c4 100644 --- a/aichat/models.go +++ b/aichat/models.go @@ -53,6 +53,7 @@ type Model struct { Provider Provider Label string // human-friendly menu label Reasoning bool // model honours Effort + Temperature bool // model honours the temperature sampling control ContextWindow int // max context tokens, for a usage gauge's denominator // Engine selects the execution path. The zero value (EngineGenkit) is the @@ -76,6 +77,7 @@ type ModelInfo struct { Provider string `json:"provider"` Label string `json:"label"` Reasoning bool `json:"reasoning"` + Temperature bool `json:"temperature"` Configured bool `json:"configured"` ContextWindow int `json:"contextWindow"` } @@ -98,11 +100,11 @@ var defaultCatalog = []Model{ {ID: "anthropic/claude-fable-5", Provider: ProviderAnthropic, Label: "Claude Fable 5", Reasoning: true, ContextWindow: 1000000}, {ID: "anthropic/claude-opus-4-8", Provider: ProviderAnthropic, Label: "Claude Opus 4.8", Reasoning: true, ContextWindow: 1000000}, {ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic, Label: "Claude Sonnet 5", Reasoning: true, ContextWindow: 1000000}, - {ID: "anthropic/claude-haiku-4-5", Provider: ProviderAnthropic, Label: "Claude Haiku 4.5", Reasoning: true, ContextWindow: 200000}, + {ID: "anthropic/claude-haiku-4-5", Provider: ProviderAnthropic, Label: "Claude Haiku 4.5", Reasoning: true, Temperature: true, ContextWindow: 200000}, {ID: "openai/gpt-5.5", Provider: ProviderOpenAI, Label: "GPT-5.5", Reasoning: true, ContextWindow: 1000000}, {ID: "openai/gpt-5.4-mini", Provider: ProviderOpenAI, Label: "GPT-5.4 mini", Reasoning: true, ContextWindow: 400000}, - {ID: "googleai/gemini-2.5-pro", Provider: ProviderGoogle, Label: "Gemini 2.5 Pro", Reasoning: true, ContextWindow: 1048576}, - {ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle, Label: "Gemini 3.5 Flash", Reasoning: true, ContextWindow: 1048576}, + {ID: "googleai/gemini-2.5-pro", Provider: ProviderGoogle, Label: "Gemini 2.5 Pro", Reasoning: true, Temperature: true, ContextWindow: 1048576}, + {ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle, Label: "Gemini 3.5 Flash", Reasoning: true, Temperature: true, ContextWindow: 1048576}, // Agent-framework models (captain pkg/ai StreamingProvider). These run a // supervised local subprocess that owns its own tools; ids carry the @@ -245,6 +247,7 @@ func CatalogInfo(registered []Provider) []ModelInfo { Provider: string(m.Provider), Label: m.Label, Reasoning: m.Reasoning, + Temperature: m.Temperature, Configured: configured, ContextWindow: m.ContextWindow, } diff --git a/aichat/models_test.go b/aichat/models_test.go index eca16d02..3bca3486 100644 --- a/aichat/models_test.go +++ b/aichat/models_test.go @@ -97,6 +97,8 @@ func TestAnthropicCatalogIncludesRequestedModels(t *testing.T) { } } +// effortConfig now delegates capability gating to captain's registry, so cases +// use real model ids that resolve there. func TestEffortConfigPerProvider(t *testing.T) { cases := []struct { name string @@ -105,11 +107,11 @@ func TestEffortConfigPerProvider(t *testing.T) { wantKey string wantNil bool }{ - {"openai-high", Model{Provider: ProviderOpenAI, Reasoning: true}, EffortHigh, "reasoning_effort", false}, - {"gemini-medium", Model{Provider: ProviderGoogle, Reasoning: true}, EffortMedium, "thinkingConfig", false}, - {"anthropic-low", Model{Provider: ProviderAnthropic, Reasoning: true}, EffortLow, "thinking", false}, - {"anthropic-no-effort-still-sets-max-tokens", Model{Provider: ProviderAnthropic, Reasoning: true}, EffortNone, "max_tokens", false}, - {"non-reasoning", Model{Provider: ProviderOpenAI, Reasoning: false}, EffortHigh, "", true}, + {"openai-high", Model{ID: "openai/gpt-5.5", Provider: ProviderOpenAI}, EffortHigh, "reasoning_effort", false}, + {"gemini-medium", Model{ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle}, EffortMedium, "thinkingConfig", false}, + {"anthropic-low", Model{ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic}, EffortLow, "thinking", false}, + {"anthropic-no-effort-still-sets-max-tokens", Model{ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic}, EffortNone, "max_tokens", false}, + {"unknown-model-no-effort-config", Model{ID: "openai/gpt-4o-mini", Provider: ProviderOpenAI}, EffortHigh, "", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -128,15 +130,17 @@ func TestEffortConfigPerProvider(t *testing.T) { } func TestAnthropicEffortConfigSetsMaxTokens(t *testing.T) { - cfg := effortConfig(Model{Provider: ProviderAnthropic, Reasoning: true}, EffortHigh, ChatBudget{MaxTokens: 1000}, nil) - if got := cfg["max_tokens"]; got != anthropicThinkingBudget(EffortHigh)+1000 { + cfg := effortConfig(Model{ID: "anthropic/claude-sonnet-4-6", Provider: ProviderAnthropic}, EffortHigh, ChatBudget{MaxTokens: 1000}, nil) + if got := cfg["max_tokens"]; got != 24576+1000 { t.Errorf("max_tokens = %v, want thinking budget plus visible output budget", got) } } func TestEffortConfigAppliesTemperatureAndMaxTokens(t *testing.T) { temp := 0.4 - cfg := effortConfig(Model{Provider: ProviderOpenAI}, EffortNone, ChatBudget{MaxTokens: 1200}, &temp) + // gemini-3.5-flash supports temperature; the openai/anthropic adaptive models + // do not, so temperature would be gated out for them. + cfg := effortConfig(Model{ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle}, EffortNone, ChatBudget{MaxTokens: 1200}, &temp) if got := cfg["temperature"]; got != temp { t.Errorf("temperature = %v, want %v", got, temp) } @@ -145,6 +149,15 @@ func TestEffortConfigAppliesTemperatureAndMaxTokens(t *testing.T) { } } +func TestEffortConfigGatesTemperatureForIncapableModel(t *testing.T) { + temp := 0.4 + // claude-sonnet-5 uses adaptive thinking and does not accept temperature. + cfg := effortConfig(Model{ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic}, EffortNone, ChatBudget{}, &temp) + if _, ok := cfg["temperature"]; ok { + t.Errorf("temperature should be gated out for claude-sonnet-5, got %v", cfg) + } +} + func TestDefaultModelPrefersConfiguredDefault(t *testing.T) { m, ok := defaultModel([]Provider{ProviderAnthropic, ProviderGoogle}) if !ok { diff --git a/aichat/tool_catalog.go b/aichat/tool_catalog.go index d6f2e644..b1ec7d37 100644 --- a/aichat/tool_catalog.go +++ b/aichat/tool_catalog.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "sort" + "strings" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" @@ -16,19 +17,22 @@ type ToolCatalog struct { } type ToolCatalogEntry struct { - Name string `json:"name"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Source string `json:"source"` - Server string `json:"server,omitempty"` - Group string `json:"group,omitempty"` - PreferenceKey string `json:"preferenceKey,omitempty"` - DefaultMode ToolMode `json:"defaultMode,omitempty"` - Method string `json:"method,omitempty"` - Path string `json:"path,omitempty"` - OperationName string `json:"operationName,omitempty"` - InputSchema map[string]any `json:"inputSchema"` - OutputSchema map[string]any `json:"outputSchema,omitempty"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Source string `json:"source"` + Server string `json:"server,omitempty"` + Group string `json:"group,omitempty"` + Parent string `json:"parent,omitempty"` + Icon string `json:"icon,omitempty"` + PreferenceKey string `json:"preferenceKey,omitempty"` + DefaultPermission ToolMode `json:"defaultPermission,omitempty"` + Strict *bool `json:"strict,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + OperationName string `json:"operationName,omitempty"` + InputSchema map[string]any `json:"inputSchema"` + OutputSchema map[string]any `json:"outputSchema,omitempty"` } type toolWithDefinition interface { @@ -76,16 +80,19 @@ func toolCatalog(tools []registeredTool) ToolCatalog { func clickyCatalogEntry(name string, op *rpc.RPCOperation, schema map[string]any) ToolCatalogEntry { info := toolInfo(name, op) entry := ToolCatalogEntry{ - Name: name, - Title: info.OperationName, - Source: "clicky", - Group: info.Group, - PreferenceKey: preferenceKey(info), - DefaultMode: ToolModeEnabled, - Method: info.Method, - Path: info.Path, - OperationName: info.OperationName, - InputSchema: objectSchema(schema), + Name: name, + Title: info.OperationName, + Source: "clicky", + Group: info.Group, + Parent: info.Parent, + Icon: info.Icon, + PreferenceKey: preferenceKey(info), + DefaultPermission: defaultPermissionMode(info.DefaultPermission), + Strict: info.Strict, + Method: info.Method, + Path: info.Path, + OperationName: info.OperationName, + InputSchema: objectSchema(schema), } if op != nil { entry.Description = op.Description @@ -95,26 +102,33 @@ func clickyCatalogEntry(name string, op *rpc.RPCOperation, schema map[string]any func customCatalogEntry(def ToolDefinition, name string, schema map[string]any) ToolCatalogEntry { info := ToolInfo{ - Name: name, - OperationName: def.Name, - Method: def.Method, - Path: def.Path, - ClickyVerb: def.Verb, - ClickyScope: def.Scope, - Group: def.Group, + Name: name, + OperationName: def.Name, + Method: def.Method, + Path: def.Path, + ClickyVerb: def.Verb, + ClickyScope: def.Scope, + Group: def.Group, + Parent: def.Parent, + Icon: def.Icon, + DefaultPermission: defaultPermissionMode(def.DefaultPermission), + Strict: def.Strict, } return ToolCatalogEntry{ - Name: name, - Title: def.Name, - Description: def.Description, - Source: "custom", - Group: def.Group, - PreferenceKey: preferenceKey(info), - DefaultMode: ToolModeEnabled, - Method: def.Method, - Path: def.Path, - OperationName: def.Name, - InputSchema: objectSchema(schema), + Name: name, + Title: def.Name, + Description: def.Description, + Source: "custom", + Group: def.Group, + Parent: def.Parent, + Icon: def.Icon, + PreferenceKey: preferenceKey(info), + DefaultPermission: defaultPermissionMode(def.DefaultPermission), + Strict: def.Strict, + Method: def.Method, + Path: def.Path, + OperationName: def.Name, + InputSchema: objectSchema(schema), } } @@ -124,15 +138,18 @@ func mcpCatalogEntry(tool ai.ToolRef) ToolCatalogEntry { func catalogEntryFromToolRef(source string, ref ai.ToolRef, info ToolInfo) ToolCatalogEntry { entry := ToolCatalogEntry{ - Name: ref.Name(), - Source: source, - Group: info.Group, - PreferenceKey: preferenceKey(info), - DefaultMode: ToolModeEnabled, - Method: info.Method, - Path: info.Path, - OperationName: info.OperationName, - InputSchema: objectSchema(nil), + Name: ref.Name(), + Source: source, + Group: info.Group, + Parent: info.Parent, + Icon: info.Icon, + PreferenceKey: preferenceKey(info), + DefaultPermission: defaultPermissionMode(info.DefaultPermission), + Strict: info.Strict, + Method: info.Method, + Path: info.Path, + OperationName: info.OperationName, + InputSchema: objectSchema(nil), } if withDef, ok := ref.(toolWithDefinition); ok { if def := withDef.Definition(); def != nil { @@ -146,6 +163,7 @@ func catalogEntryFromToolRef(source string, ref ai.ToolRef, info ToolInfo) ToolC if server, ok := stringMetadata(def.Metadata, "mcp_server", "server", "serverName"); ok { entry.Server = server } + applyToolMetadata(&entry, def.Metadata) } } if entry.PreferenceKey == "" { @@ -187,3 +205,45 @@ func stringMetadata(meta map[string]any, keys ...string) (string, bool) { } return "", false } + +func applyToolMetadata(entry *ToolCatalogEntry, meta map[string]any) { + if entry == nil || len(meta) == 0 { + return + } + if group, ok := stringMetadata(meta, "group", "toolGroup"); ok && entry.Group == "" { + entry.Group = group + entry.PreferenceKey = group + } + if parent, ok := stringMetadata(meta, "parent", "toolParent"); ok && entry.Parent == "" { + entry.Parent = parent + } + if icon, ok := stringMetadata(meta, "icon", "toolIcon"); ok && entry.Icon == "" { + entry.Icon = icon + } + if permission, ok := stringMetadata(meta, "defaultPermission", "permission", "defaultMode"); ok { + entry.DefaultPermission = defaultPermissionMode(ToolMode(permission)) + } + if strict, ok := boolMetadata(meta, "strict"); ok && entry.Strict == nil { + entry.Strict = &strict + } + if nested, ok := meta["com.flanksource.clicky/tool"].(map[string]any); ok { + applyToolMetadata(entry, nested) + } +} + +func boolMetadata(meta map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + switch v := meta[key].(type) { + case bool: + return v, true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true": + return true, true + case "false": + return false, true + } + } + } + return false, false +} diff --git a/aichat/tool_registry.go b/aichat/tool_registry.go index 72fcd66b..1b7b2fe6 100644 --- a/aichat/tool_registry.go +++ b/aichat/tool_registry.go @@ -25,13 +25,17 @@ type toolRuntimeContextKey struct{} // ToolDefinition describes an app-owned tool registered alongside clicky RPC // and MCP tools. Handlers should return JSON-serializable values. type ToolDefinition struct { - Name string - Description string - InputSchema map[string]any - Method string - Path string - Verb string - Scope string + Name string + Description string + InputSchema map[string]any + Method string + Path string + Verb string + Scope string + Parent string + Icon string + DefaultPermission ToolMode + Strict *bool // Group, when set, places this custom tool in a tool-group so the // preferences UI presents it under the group rather than individually. Group string @@ -61,13 +65,17 @@ func DefineCustomTools(g *genkit.Genkit, defs []ToolDefinition) ([]registeredToo schema = map[string]any{"type": "object", "properties": map[string]any{}} } info := ToolInfo{ - Name: name, - OperationName: def.Name, - Method: def.Method, - Path: def.Path, - ClickyVerb: def.Verb, - ClickyScope: def.Scope, - Group: def.Group, + Name: name, + OperationName: def.Name, + Method: def.Method, + Path: def.Path, + ClickyVerb: def.Verb, + ClickyScope: def.Scope, + Group: def.Group, + Parent: def.Parent, + Icon: def.Icon, + DefaultPermission: defaultPermissionMode(def.DefaultPermission), + Strict: def.Strict, } tool := genkit.DefineTool[any, any](g, name, def.Description, func(tc *ai.ToolContext, input any) (any, error) { @@ -110,7 +118,11 @@ func toolsForRequest(tools []registeredTool, prefs ToolPreferences) []ai.ToolRef } refs := make([]ai.ToolRef, 0, len(tools)) for _, tool := range tools { - if mode, ok := effectivePreference(prefs, tool.info); ok && mode == ToolModeDisabled { + mode, ok := effectivePreference(prefs, tool.info) + if !ok { + mode = defaultPermissionMode(tool.info.DefaultPermission) + } + if mode == ToolModeOff { continue } refs = append(refs, tool.ref) @@ -186,22 +198,45 @@ func shouldRequireApproval(ctx context.Context, fallback approvalPredicate, tool if ctx != nil { if cfg, ok := toolRuntime(ctx); ok { if mode, ok := effectivePreference(cfg.preferences, tool); ok { - switch mode { - case ToolModeEnabled: - return false - case ToolModeAsk: - return true - case ToolModeDisabled: - return false + if decision, handled := approvalDecisionForMode(mode); handled { + return decision } } + if decision, handled := approvalDecisionForMode(defaultPermissionMode(tool.DefaultPermission)); handled { + return decision + } if cfg.defaultApproval != nil { return cfg.defaultApproval(tool, input) } } } + if decision, handled := approvalDecisionForMode(defaultPermissionMode(tool.DefaultPermission)); handled { + return decision + } if fallback == nil { return false } return fallback(tool, input) } + +func defaultPermissionMode(mode ToolMode) ToolMode { + if normalized, ok := normalizeToolMode(mode); ok { + return normalized + } + return ToolModeAuto +} + +func approvalDecisionForMode(mode ToolMode) (bool, bool) { + switch defaultPermissionMode(mode) { + case ToolModeOn: + return false, true + case ToolModeAsk: + return true, true + case ToolModeOff: + return false, true + case ToolModeAuto: + return false, false + default: + return false, false + } +} diff --git a/aichat/tool_registry_test.go b/aichat/tool_registry_test.go index da0d1adf..c00e488e 100644 --- a/aichat/tool_registry_test.go +++ b/aichat/tool_registry_test.go @@ -38,6 +38,7 @@ func TestDefineCustomToolsRegistersAndFilters(t *testing.T) { } func TestHandleToolsServesCustomToolCatalog(t *testing.T) { + strict := false s := NewServer(Options{ CustomTools: []ToolDefinition{{ Name: "formula patch", @@ -47,8 +48,12 @@ func TestHandleToolsServesCustomToolCatalog(t *testing.T) { "properties": map[string]any{"formula": map[string]any{"type": "string"}}, "required": []any{"formula"}, }, - Group: "Formula", - Handler: func(context.Context, any) (any, error) { return map[string]any{"ok": true}, nil }, + Group: "Formula", + Parent: "Rules", + Icon: "square-function", + DefaultPermission: ToolModeAsk, + Strict: &strict, + Handler: func(context.Context, any) (any, error) { return map[string]any{"ok": true}, nil }, }}, }) defer s.Close() @@ -70,6 +75,12 @@ func TestHandleToolsServesCustomToolCatalog(t *testing.T) { if tool.Name != "formula_patch" || tool.Source != "custom" || tool.PreferenceKey != "Formula" { t.Fatalf("tool = %+v, want sanitized custom tool in Formula group", tool) } + if tool.Parent != "Rules" || tool.Icon != "square-function" || tool.DefaultPermission != ToolModeAsk { + t.Fatalf("tool metadata = %+v, want parent/icon/default permission", tool) + } + if tool.Strict == nil || *tool.Strict { + t.Fatalf("tool.Strict = %v, want false pointer", tool.Strict) + } props := tool.InputSchema["properties"].(map[string]any) if _, ok := props["formula"]; !ok { t.Fatalf("input schema lost formula property: %v", tool.InputSchema) From 27d0c52268fb0b28288bed866e7c1186fbb421f2 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 14:00:16 +0300 Subject: [PATCH 14/23] feat(mcp): add MCP tool annotations and clicky meta support Introduce ToolAnnotations struct for well-known MCP tool properties. Infer readOnly, destructive, idempotent hints from HTTP method/verb. Propagate tool hints (icon, group, parent, permission, strict) into _meta field. Update RPCOperation to carry ToolHints from clicky annotations. --- mcp/registry.go | 155 +++++++++++++++++++++++++++++++- mcp/registry_test.go | 63 +++++++++++++ rpc/converter.go | 13 ++- rpc/converter_toolgroup_test.go | 28 +++++- 4 files changed, 251 insertions(+), 8 deletions(-) diff --git a/mcp/registry.go b/mcp/registry.go index 5107bfa7..e030fb56 100644 --- a/mcp/registry.go +++ b/mcp/registry.go @@ -20,14 +20,25 @@ type ToolRegistry struct { // ToolDefinition represents an MCP tool definition type ToolDefinition struct { - Name string `json:"name"` - Title string `json:"title"` - Description string `json:"description"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` InputSchema Schema `json:"inputSchema"` OutputSchema *Schema `json:"outputSchema,omitempty"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` Command entity.ExecutableCommand `json:"-"` // Internal reference } +// ToolAnnotations are the well-known MCP tool annotations. +type ToolAnnotations struct { + Title string `json:"title,omitempty"` + ReadOnlyHint *bool `json:"readOnlyHint,omitempty"` + DestructiveHint *bool `json:"destructiveHint,omitempty"` + IdempotentHint *bool `json:"idempotentHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` +} + // Schema represents a JSON schema for tool input/output type Schema struct { Type string `json:"type"` @@ -73,10 +84,148 @@ func NewMcpTool(rpcOp *rpc.RPCOperation) *ToolDefinition { Title: fmt.Sprintf("%s %s", appName, rpcOp.Name), Description: rpcOp.Description, InputSchema: inputSchema, + Annotations: toolAnnotations(rpcOp), + Meta: clickyToolMeta(rpcOp), Command: rpcOp.Command, } } +const clickyToolMetaKey = "com.flanksource.clicky/tool" + +func toolAnnotations(rpcOp *rpc.RPCOperation) *ToolAnnotations { + hints := operationToolHints(rpcOp) + annotations := &ToolAnnotations{ + Title: hints.Title, + ReadOnlyHint: hints.ReadOnlyHint, + DestructiveHint: hints.DestructiveHint, + IdempotentHint: hints.IdempotentHint, + OpenWorldHint: hints.OpenWorldHint, + } + + readOnly, destructive, idempotent := inferredToolSemantics(rpcOp) + if annotations.ReadOnlyHint == nil { + annotations.ReadOnlyHint = readOnly + } + if annotations.DestructiveHint == nil { + annotations.DestructiveHint = destructive + } + if annotations.IdempotentHint == nil { + annotations.IdempotentHint = idempotent + } + + if annotations.Title == "" && + annotations.ReadOnlyHint == nil && + annotations.DestructiveHint == nil && + annotations.IdempotentHint == nil && + annotations.OpenWorldHint == nil { + return nil + } + return annotations +} + +func inferredToolSemantics(rpcOp *rpc.RPCOperation) (readOnly *bool, destructive *bool, idempotent *bool) { + if rpcOp == nil { + return nil, nil, nil + } + method := strings.ToUpper(rpcOp.Method) + verb := "" + if rpcOp.Clicky != nil { + verb = strings.ToLower(rpcOp.Clicky.Verb) + } + + switch { + case method == "GET" || method == "HEAD" || verb == "list" || verb == "get": + return boolPtr(true), boolPtr(false), boolPtr(true) + case method == "DELETE" || verb == "delete": + return boolPtr(false), boolPtr(true), boolPtr(true) + case method == "PUT" || verb == "update": + return boolPtr(false), boolPtr(true), boolPtr(true) + case method == "PATCH": + return boolPtr(false), boolPtr(true), boolPtr(false) + case method == "POST" || verb == "create": + return boolPtr(false), boolPtr(false), boolPtr(false) + case verb == "action": + return boolPtr(false), boolPtr(true), nil + default: + return nil, nil, nil + } +} + +func clickyToolMeta(rpcOp *rpc.RPCOperation) map[string]any { + hints := operationToolHints(rpcOp) + meta := map[string]any{} + if hints.Icon != "" { + meta["icon"] = hints.Icon + } + if hints.Group != "" { + meta["group"] = hints.Group + } + if hints.Parent != "" { + meta["parent"] = hints.Parent + } + if hints.DefaultPermission != "" { + meta["defaultPermission"] = string(hints.DefaultPermission) + } + if hints.Strict != nil { + meta["strict"] = *hints.Strict + } + if len(meta) == 0 { + return nil + } + return map[string]any{clickyToolMetaKey: meta} +} + +func operationToolHints(rpcOp *rpc.RPCOperation) entity.MCPToolHints { + if rpcOp == nil { + return entity.MCPToolHints{} + } + hints := rpcOp.ToolHints + if rpcOp.Clicky != nil { + clickyHints := rpcOp.Clicky.ToolHints + if hints.Title == "" { + hints.Title = clickyHints.Title + } + if hints.ReadOnlyHint == nil { + hints.ReadOnlyHint = clickyHints.ReadOnlyHint + } + if hints.DestructiveHint == nil { + hints.DestructiveHint = clickyHints.DestructiveHint + } + if hints.IdempotentHint == nil { + hints.IdempotentHint = clickyHints.IdempotentHint + } + if hints.OpenWorldHint == nil { + hints.OpenWorldHint = clickyHints.OpenWorldHint + } + if hints.Icon == "" { + hints.Icon = clickyHints.Icon + } + if hints.Group == "" { + hints.Group = clickyHints.Group + } + if hints.Parent == "" { + hints.Parent = clickyHints.Parent + } + if hints.DefaultPermission == "" { + hints.DefaultPermission = clickyHints.DefaultPermission + } + if hints.Strict == nil { + hints.Strict = clickyHints.Strict + } + if hints.Group == "" { + hints.Group = rpcOp.Clicky.Group + } + } + if hints.Group == "" { + hints.Group = rpcOp.Group + } + return hints +} + +func boolPtr(v bool) *bool { + return &v +} + // NewMcpToolWithConfig creates an MCP ToolDefinition from a generic RPC operation with config overrides func (r *ToolRegistry) NewMcpToolWithConfig(rpcOp *rpc.RPCOperation) *ToolDefinition { tool := NewMcpTool(rpcOp) diff --git a/mcp/registry_test.go b/mcp/registry_test.go index a7dcc8b4..1098531c 100644 --- a/mcp/registry_test.go +++ b/mcp/registry_test.go @@ -3,6 +3,8 @@ package mcp import ( "testing" + "github.com/flanksource/clicky/entity" + "github.com/flanksource/clicky/rpc" "github.com/spf13/cobra" ) @@ -129,3 +131,64 @@ func TestRegistry_NoDiscoverToolsBuiltin(t *testing.T) { t.Fatal("discover-tools must not be exposed as a regular MCP tool; it lives under `mcp tools`") } } + +func TestNewMcpToolEmitsAnnotationsAndClickyMeta(t *testing.T) { + readOnly := true + destructive := false + openWorld := false + strict := true + tool := NewMcpTool(&rpc.RPCOperation{ + Name: "invoice list", + Description: "List invoices", + Method: "GET", + Schema: rpc.Schema{ + Type: "object", + Properties: map[string]rpc.Property{"status": {Type: "string"}}, + }, + ToolHints: entity.MCPToolHints{ + Title: "Invoice list", + ReadOnlyHint: &readOnly, + DestructiveHint: &destructive, + OpenWorldHint: &openWorld, + Icon: "receipt", + Group: "billing", + Parent: "accounting", + DefaultPermission: entity.ToolPermissionAsk, + Strict: &strict, + }, + }) + + if tool.Annotations == nil { + t.Fatal("Annotations = nil, want MCP annotations") + } + if tool.Annotations.Title != "Invoice list" { + t.Fatalf("annotation title = %q, want Invoice list", tool.Annotations.Title) + } + if tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint { + t.Fatalf("readOnlyHint = %v, want true", tool.Annotations.ReadOnlyHint) + } + if tool.Annotations.DestructiveHint == nil || *tool.Annotations.DestructiveHint { + t.Fatalf("destructiveHint = %v, want false", tool.Annotations.DestructiveHint) + } + if tool.Annotations.IdempotentHint == nil || !*tool.Annotations.IdempotentHint { + t.Fatalf("idempotentHint = %v, want inferred true for GET", tool.Annotations.IdempotentHint) + } + if tool.Annotations.OpenWorldHint == nil || *tool.Annotations.OpenWorldHint { + t.Fatalf("openWorldHint = %v, want false", tool.Annotations.OpenWorldHint) + } + + rawMeta, ok := tool.Meta[clickyToolMetaKey] + if !ok { + t.Fatalf("_meta missing %q: %+v", clickyToolMetaKey, tool.Meta) + } + meta, ok := rawMeta.(map[string]any) + if !ok { + t.Fatalf("_meta[%q] = %T, want map[string]any", clickyToolMetaKey, rawMeta) + } + if meta["icon"] != "receipt" || meta["group"] != "billing" || meta["parent"] != "accounting" { + t.Fatalf("unexpected clicky _meta: %+v", meta) + } + if meta["defaultPermission"] != "ask" || meta["strict"] != true { + t.Fatalf("permission/strict _meta = %+v, want ask/true", meta) + } +} diff --git a/rpc/converter.go b/rpc/converter.go index 2719dd6e..e7deb66b 100644 --- a/rpc/converter.go +++ b/rpc/converter.go @@ -186,8 +186,17 @@ func (c *Converter) ConvertCommand(cmd *cobra.Command) (*RPCOperation, error) { operation.Clicky.IDParam = meta.IDParam operation.Clicky.SupportsLookup = meta.SupportsLookup operation.Clicky.SupportsFilterMode = meta.SupportsFilterMode - operation.Clicky.Group = meta.ToolGroup - operation.Group = meta.ToolGroup + hints := meta.ToolHints + if hints.Group == "" { + hints.Group = meta.ToolGroup + } + operation.ToolHints = hints + operation.Clicky.ToolHints = hints + if !hints.IsZero() { + operation.Clicky.OpenAPIToolHints = &hints + } + operation.Clicky.Group = hints.Group + operation.Group = hints.Group } if df := clicky.GetDataFunc(cmd); df != nil { diff --git a/rpc/converter_toolgroup_test.go b/rpc/converter_toolgroup_test.go index a4c40c87..56f3ec24 100644 --- a/rpc/converter_toolgroup_test.go +++ b/rpc/converter_toolgroup_test.go @@ -11,22 +11,44 @@ import ( // clicky/tool-group annotation onto RPCOperation.Group and Clicky.Group, and // that it does NOT leak into the auto-derived OpenAPI Tags. func TestConvertCommandPropagatesToolGroup(t *testing.T) { + strict := "true" cmd := &cobra.Command{ Use: "list", Short: "List invoices", RunE: func(*cobra.Command, []string) error { return nil }, } cmd.Annotations = map[string]string{ - "clicky/entity-name": "invoice", - "clicky/operation-verb": "list", - "clicky/tool-group": "billing", + "clicky/entity-name": "invoice", + "clicky/operation-verb": "list", + "clicky/tool-group": "billing", + "clicky/tool-parent": "accounting", + "clicky/tool-icon": "receipt", + "clicky/tool-read-only-hint": "true", + "clicky/tool-destructive-hint": "false", + "clicky/tool-default-permission": "ask", + "clicky/tool-strict": strict, } op, err := NewConverter(DefaultConfig()).ConvertCommand(cmd) assert.NoError(t, err) assert.Equal(t, "billing", op.Group, "RPCOperation.Group") + assert.Equal(t, "billing", op.ToolHints.Group, "RPCOperation.ToolHints.Group") + assert.Equal(t, "accounting", op.ToolHints.Parent, "RPCOperation.ToolHints.Parent") + assert.Equal(t, "receipt", op.ToolHints.Icon, "RPCOperation.ToolHints.Icon") + assert.Equal(t, "ask", string(op.ToolHints.DefaultPermission), "RPCOperation.ToolHints.DefaultPermission") + if assert.NotNil(t, op.ToolHints.ReadOnlyHint) { + assert.True(t, *op.ToolHints.ReadOnlyHint) + } + if assert.NotNil(t, op.ToolHints.DestructiveHint) { + assert.False(t, *op.ToolHints.DestructiveHint) + } + if assert.NotNil(t, op.ToolHints.Strict) { + assert.True(t, *op.ToolHints.Strict) + } if assert.NotNil(t, op.Clicky) { assert.Equal(t, "billing", op.Clicky.Group, "ClickyOperationMeta.Group") + assert.Equal(t, op.ToolHints, op.Clicky.ToolHints, "ClickyOperationMeta.ToolHints") + assert.Equal(t, &op.ToolHints, op.Clicky.OpenAPIToolHints, "ClickyOperationMeta.OpenAPIToolHints") } assert.NotContains(t, op.Tags, "billing", "tool-group must not leak into OpenAPI Tags") } From be582a5acf3a6e1a8a3d5c3375c7623f77ce650b Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 9 Jul 2026 14:00:26 +0300 Subject: [PATCH 15/23] fix(task): fix data races and render lifecycle for task output Add mutex protection to Group and Task methods to fix data races. Introduce render lifecycle state machine (renderIdle, renderRunning, renderStopping) to allow the render loop to be restarted after stopRender, enabling subsequent enqueues to trigger fresh rendering. Implement prettyPlainDelta to emit only new log entries per PlainRender tick, preserving full log history for snapshots and final tree. Add plainSummaryText for a concise one-line summary after plain loop. Ensure renderFinal is idempotent and final output includes summary without duplicating task lines. Update Wait/WaitSilent to flush captured output via StopCapturingOutput. Fix WaitTime and StartTime to be thread-safe. --- task/group.go | 37 ++++---- task/group_race_test.go | 28 ++++++ task/manager.go | 33 ++++--- task/manager_lifecycle.go | 138 ++++++++++++++++++++++------- task/manager_output_test.go | 38 ++++++++ task/manager_wait.go | 10 ++- task/render.go | 53 +++++++++-- task/render_dedupe_test.go | 115 ++++++++++++++++++++++++ task/render_hook_test.go | 2 +- task/render_restart_test.go | 172 ++++++++++++++++++++++++++++++++++++ task/task.go | 122 +++++++------------------ task/task_logs_render.go | 112 +++++++++++++++++++++++ 12 files changed, 698 insertions(+), 162 deletions(-) create mode 100644 task/render_dedupe_test.go create mode 100644 task/render_restart_test.go create mode 100644 task/task_logs_render.go diff --git a/task/group.go b/task/group.go index 37884643..ac7e94fa 100644 --- a/task/group.go +++ b/task/group.go @@ -124,7 +124,11 @@ func (g *Group) observeTerminal(status Status, now time.Time) { } func (g *Group) GetTasks() []Taskable { - return g.Items + g.mu.RLock() + defer g.mu.RUnlock() + items := make([]Taskable, len(g.Items)) + copy(items, g.Items) + return items } type TypedGroup[T any] struct { @@ -163,7 +167,7 @@ func (g TypedGroup[T]) Add(name string, taskFunc func(flanksourceContext.Context // GetResults waits for all tasks in the group and returns typed results func (g TypedGroup[T]) GetResults() (map[TypedTask[T]]T, error) { results := make(map[TypedTask[T]]T) - for _, item := range g.Items { + for _, item := range g.GetTasks() { switch v := item.(type) { case TypedTask[T]: v.WaitFor() @@ -184,7 +188,8 @@ func (g *Group) Name() string { } func (g *Group) Status() Status { - if len(g.Items) == 0 { + items := g.GetTasks() + if len(items) == 0 { return StatusPending } @@ -193,7 +198,7 @@ func (g *Group) Status() Status { hasFailed := false allCompleted := true - for _, item := range g.Items { + for _, item := range items { status := item.GetTask().Status() switch status { case StatusRunning: @@ -321,7 +326,10 @@ func (g *Group) Cancel() { // Duration returns the total duration from first start to last completion func (g *TypedGroup[T]) Duration() time.Duration { - if g.startTime.IsZero() { + g.mu.RLock() + startTime := g.startTime + g.mu.RUnlock() + if startTime.IsZero() { return 0 } @@ -329,30 +337,23 @@ func (g *TypedGroup[T]) Duration() time.Duration { var latestEnd time.Time allCompleted := true - for _, item := range g.Items { + for _, item := range g.GetTasks() { status := item.GetTask().Status() if status == StatusPending || status == StatusRunning { allCompleted = false break } - itemDuration := item.GetTask().Duration() - if itemDuration > 0 { - if !item.GetTask().endTime.IsZero() && item.GetTask().endTime.After(latestEnd) { - latestEnd = item.GetTask().endTime - } + if end := item.GetTask().EndTime(); !end.IsZero() && end.After(latestEnd) { + latestEnd = end } } - if !allCompleted { - return time.Since(g.startTime) - } - - if latestEnd.IsZero() { - return time.Since(g.startTime) + if !allCompleted || latestEnd.IsZero() { + return time.Since(startTime) } - return latestEnd.Sub(g.startTime) + return latestEnd.Sub(startTime) } // IsGroup returns true for Group diff --git a/task/group_race_test.go b/task/group_race_test.go index 4bd21073..34eecdc0 100644 --- a/task/group_race_test.go +++ b/task/group_race_test.go @@ -4,6 +4,8 @@ import ( "fmt" "sync" "testing" + + flanksourceContext "github.com/flanksource/commons/context" ) // TestStartGroupSnapshotAllRace runs StartGroup concurrently with SnapshotAll; @@ -25,3 +27,29 @@ func TestStartGroupSnapshotAllRace(t *testing.T) { } wg.Wait() } + +// TestGroupReadersRaceWithAdd runs Group.Status/GetTasks/Duration concurrently +// with TypedGroup.Add; under -race it fails if g.Items is read without g.mu. +func TestGroupReadersRaceWithAdd(t *testing.T) { + withTestGlobal(t) + + group := StartGroup[int]("reader-race-group") + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func(n int) { + defer wg.Done() + group.Add(fmt.Sprintf("reader-race-task-%d", n), func(ctx flanksourceContext.Context, tk *Task) (int, error) { + return n, nil + }) + }(i) + go func() { + defer wg.Done() + group.Status() + group.GetTasks() + group.Duration() + }() + } + wg.Wait() + group.WaitFor() +} diff --git a/task/manager.go b/task/manager.go index 8ec69f22..4a9dcc23 100644 --- a/task/manager.go +++ b/task/manager.go @@ -67,12 +67,19 @@ type Manager struct { // Task identity tracking for deduplication tasksByIdentity sync.Map // map[string]*Task - // Render loop control - stopRenderCh chan struct{} - renderDone chan struct{} - renderStopped sync.Once - renderStarted sync.Once - renderOwnsTTY bool + // Render loop control. renderState re-arms the lifecycle so a batch of + // tasks enqueued after Wait()/stopRender renders again; all fields below + // are guarded by mu. + stopRenderCh chan struct{} + renderDone chan struct{} + renderStopDone chan struct{} + renderState renderLifecycleState + renderOwnsTTY bool + + // finalRendered marks that renderFinal already emitted the closing output + // for the current batch; reset on enqueue so a new batch renders again. + // Guarded by mu. + finalRendered bool // Terminal state originalTermState *term.State @@ -432,12 +439,13 @@ func (tm *Manager) newTask(name string, opts ...Option) *Task { } func (tm *Manager) enqueue(task *Task) *Task { - if !tm.noRender.Load() { - tm.renderStarted.Do(func() { - if !tm.noProgress.Load() { - tm.startRenderLoop() - } - }) + if !tm.noRender.Load() && !tm.noProgress.Load() { + tm.mu.RLock() + idle := tm.renderState == renderIdle + tm.mu.RUnlock() + if idle { + tm.startRenderLoop() + } } if task.identity != "" { @@ -451,6 +459,7 @@ func (tm *Manager) enqueue(task *Task) *Task { tm.mu.Lock() tm.tasks = append(tm.tasks, task) + tm.finalRendered = false tm.mu.Unlock() tm.taskQueue.Enqueue(task) diff --git a/task/manager_lifecycle.go b/task/manager_lifecycle.go index 8df700f6..b0c26e17 100644 --- a/task/manager_lifecycle.go +++ b/task/manager_lifecycle.go @@ -10,21 +10,45 @@ import ( "golang.org/x/term" ) -// startRenderLoop starts the unified render loop for both interactive and non-interactive modes. -// Must not be called concurrently with stopRender. +// renderLifecycleState tracks the render loop lifecycle so it can be +// restarted: a batch of tasks enqueued after Wait()/stopRender starts a +// fresh loop instead of rendering nothing. Guarded by Manager.mu. +type renderLifecycleState int + +const ( + renderIdle renderLifecycleState = iota + renderRunning + renderStopping +) + +// startRenderLoop starts the unified render loop for both interactive and +// non-interactive modes. Safe to call repeatedly and concurrently with +// stopRender: the renderState machine ensures at most one loop runs, and a +// stopped loop is restarted by a later enqueue. func (tm *Manager) startRenderLoop() { if tm.noRender.Load() { return } - if tm.isInteractive.Load() && !globalANSITerminal.tryAcquireTaskRenderer(tm) { - return + acquiredTTY := false + if tm.isInteractive.Load() { + if !globalANSITerminal.tryAcquireTaskRenderer(tm) { + return + } + acquiredTTY = true } tm.mu.Lock() + if tm.renderState != renderIdle { + tm.mu.Unlock() + if acquiredTTY { + globalANSITerminal.releaseTaskRenderer(tm) + } + return + } + tm.renderState = renderRunning tm.stopRenderCh = make(chan struct{}) tm.renderDone = make(chan struct{}) - tm.renderOwnsTTY = tm.isInteractive.Load() - tm.mu.Unlock() + tm.renderOwnsTTY = acquiredTTY // Route flanksource/commons/logger output through a writer that // serializes against tick renders. Without this, a logger.Infof between @@ -32,6 +56,7 @@ func (tm *Manager) startRenderLoop() { // tick's ClearLines undercounts and leaves stale frame lines behind. // Restored in stopRender. tm.installLogSerializer() + tm.mu.Unlock() go tm.renderLoop() } @@ -107,32 +132,73 @@ func (tm *Manager) renderLoop() { } } -// stopRender signals the render loop to stop and waits for cleanup +// stopRender signals the render loop to stop, waits for teardown, and re-arms +// the lifecycle so a later enqueue can start a fresh loop. func (tm *Manager) stopRender() { - tm.renderStopped.Do(func() { - tm.mu.RLock() + tm.mu.Lock() + switch tm.renderState { + case renderRunning: + tm.renderState = renderStopping ch := tm.stopRenderCh done := tm.renderDone - tm.mu.RUnlock() + stopped := make(chan struct{}) + tm.renderStopDone = stopped + tm.mu.Unlock() - if ch != nil { - close(ch) - <-done - } - // In interactive mode the stop branch of renderLoop already emitted - // the authoritative final frame (via interactiveRender, which - // ClearLines(lastLines)+writes atomically). Calling renderFinal - // here would append a second copy of the summary below the live - // frame, doubling every summary line. PlainRender-based mode only - // prints dirty tasks per tick, so renderFinal is still needed to - // cover tasks that completed between the last tick and stop. - if !tm.noRender.Load() && !tm.isInteractive.Load() { - tm.renderFinal() - } - tm.cleanupTerminal() + close(ch) + <-done + tm.finishRenderTeardown(true) + + tm.mu.Lock() + tm.renderState = renderIdle + tm.renderStopDone = nil + tm.mu.Unlock() + close(stopped) + + case renderStopping: + // A concurrent caller (e.g. Wait racing the shutdown hook) is mid- + // teardown; wait for it to finish rather than returning while + // terminal/logger cleanup is still in flight. + stopped := tm.renderStopDone + tm.mu.Unlock() + <-stopped + + default: // renderIdle: loop never started (noProgress/CI) or already stopped. + tm.mu.Unlock() + tm.finishRenderTeardown(false) + } +} + +// finishRenderTeardown emits the final frame where needed and restores +// terminal and logger state. Shared by both stopRender paths: after a running +// loop exits (loopWasRunning), and when no loop ever ran — noProgress/CI mode +// prints its final tree here. +func (tm *Manager) finishRenderTeardown(loopWasRunning bool) { + // In interactive mode the stop branch of renderLoop already emitted + // the authoritative final frame (via interactiveRender, which + // ClearLines(lastLines)+writes atomically). Calling renderFinal + // here would append a second copy of the summary below the live + // frame, doubling every summary line. PlainRender-based mode gets its + // closing output from renderFinal: the loop's stop branch already + // flushed every dirty task via PlainRender, so a running loop only + // needs the one-line summary; when no loop ran, nothing has been + // printed yet and the full tree is emitted. + if !tm.noRender.Load() && !tm.isInteractive.Load() { + tm.renderFinal(loopWasRunning) + } + tm.cleanupTerminal() + // On the idle path, only tear down while still idle: a concurrent enqueue + // may have restarted the loop, which still needs its freshly installed + // serializer and TTY ownership (its own stopRender releases them). + tm.mu.Lock() + teardown := loopWasRunning || tm.renderState == renderIdle + if teardown { tm.uninstallLogSerializer() + } + tm.mu.Unlock() + if teardown { tm.releaseRenderTerminal() - }) + } } // cleanupTerminal restores terminal to a clean state @@ -152,23 +218,31 @@ func (tm *Manager) cleanupTerminal() { } } -// renderFinal outputs the final task status -func (tm *Manager) renderFinal() { +// renderFinal outputs the final task status once per lifecycle. When the plain +// render loop ran (loopRan), per-tick PlainRender output already covers every +// task, so only a one-line gray summary is emitted; when no loop ran +// (noProgress/CI), the full task tree is printed. A custom LiveRenderer keeps +// its full RenderFinal block on both paths. finalRendered makes a repeat +// stopRender with no newly enqueued tasks print nothing — enqueue resets it. +func (tm *Manager) renderFinal(loopRan bool) { if tm.noRender.Load() { return } - tm.mu.RLock() - if len(tm.tasks) == 0 { - tm.mu.RUnlock() + tm.mu.Lock() + if tm.finalRendered || len(tm.tasks) == 0 { + tm.mu.Unlock() return } + tm.finalRendered = true taskSnapshot := make([]*Task, len(tm.tasks)) copy(taskSnapshot, tm.tasks) - tm.mu.RUnlock() + tm.mu.Unlock() var rendered api.Text if r := tm.getLiveRenderer(); r != nil { rendered = r.RenderFinal(taskSnapshot) + } else if loopRan { + rendered = plainSummaryText(taskSnapshot) } else { rendered = tm.prettyFromTasks(taskSnapshot) } diff --git a/task/manager_output_test.go b/task/manager_output_test.go index 6283f574..9fb2064f 100644 --- a/task/manager_output_test.go +++ b/task/manager_output_test.go @@ -7,6 +7,8 @@ import ( "os" "testing" "time" + + flanksourceContext "github.com/flanksource/commons/context" ) // TestCaptureAndStopFlushesInOrder verifies the core contract: while @@ -134,6 +136,42 @@ func TestBlankLinesArePreserved(t *testing.T) { } } +// TestWaitFlushesAndStopsCapture verifies that Wait() itself stops output +// capture: an app that calls StartCapturingOutput then task.Wait() and exits +// without the shutdown hook must still get its buffered output flushed and +// os.Stdout restored to the pre-capture file. +func TestWaitFlushesAndStopsCapture(t *testing.T) { + outR, errR, closeWriters := swapTestPipes(t) + swappedStdout := os.Stdout + + originalGlobal := global + global = newTestManager(1) + t.Cleanup(func() { + global.StopCapturingOutput() + global = originalGlobal + }) + + StartCapturingOutput() + fmt.Println("wait-flush-sentinel") + + StartTask[string]("trivial", func(flanksourceContext.Context, *Task) (string, error) { + return "", nil + }) + Wait() + + if os.Stdout != swappedStdout { + t.Errorf("Wait must stop capture and restore os.Stdout to the pre-capture file: got %p, want %p", os.Stdout, swappedStdout) + } + + closeWriters() + stdoutBytes, _ := io.ReadAll(outR) + _, _ = io.ReadAll(errR) + + if !bytes.Contains(stdoutBytes, []byte("wait-flush-sentinel")) { + t.Errorf("Wait must flush captured output, got %q", stdoutBytes) + } +} + // TestStopWithoutStartIsSafe ensures StopCapturingOutput is a no-op when // StartCapturingOutput was never called — gavel relies on this to use // defer StopCapturingOutput() across code paths that may not have diff --git a/task/manager_wait.go b/task/manager_wait.go index 0bc5c6e4..a774c2a6 100644 --- a/task/manager_wait.go +++ b/task/manager_wait.go @@ -95,7 +95,9 @@ func ClearTasks() { global.groups = activeGroups } -// WaitSilent waits for all tasks to complete without displaying results +// WaitSilent waits for all tasks to complete without displaying results. +// It stops the renderer and stops output capture (flushing any output +// buffered by StartCapturingOutput to the restored streams). func WaitSilent() int { ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() @@ -121,6 +123,7 @@ func WaitSilent() int { } global.stopRender() + global.StopCapturingOutput() global.mu.RLock() tasks := global.tasks @@ -140,7 +143,9 @@ func WaitSilent() int { return 0 } -// Wait waits for all tasks to complete and returns the appropriate exit code +// Wait waits for all tasks to complete and returns the appropriate exit code. +// It stops the renderer and stops output capture (flushing any output +// buffered by StartCapturingOutput to the restored streams). func Wait() int { ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() @@ -166,6 +171,7 @@ func Wait() int { } global.stopRender() + global.StopCapturingOutput() var failed, canceled int diff --git a/task/render.go b/task/render.go index 8614aa98..e1859dce 100644 --- a/task/render.go +++ b/task/render.go @@ -3,8 +3,11 @@ package task import ( "fmt" "strings" + "time" "github.com/charmbracelet/lipgloss" + "github.com/flanksource/commons/text" + "github.com/flanksource/clicky/api" ) @@ -33,19 +36,20 @@ func (tm *Manager) PlainRender() { // manager init) so live progress stays out of StartCapturingOutput's // buffer and appears in real time. Guard each line with bufferMutex so // a concurrent log-serializer write cannot split a single line write. + // prettyPlainDelta renders only log entries new since the last tick and + // advances the per-task cursor, keeping the buffer intact for snapshots + // and the final tree. output := tm.renderer.Output() for _, task := range taskSnapshot { if task.PopDirty() { + rendered := task.prettyPlainDelta() tm.bufferMutex.Lock() if tm.noColor.Load() { - fmt.Fprintf(output, "%s\n", task.Pretty().String()) + fmt.Fprintf(output, "%s\n", rendered.String()) } else { - fmt.Fprintf(output, "%s\n", task.Pretty().ANSI()) + fmt.Fprintf(output, "%s\n", rendered.ANSI()) } tm.bufferMutex.Unlock() - if task.bufferedLogger != nil { - task.bufferedLogger.ClearLogs() - } } } } @@ -178,6 +182,45 @@ func (tm *Manager) prettyFromTasks(tasks []*Task) api.Text { return text } +// plainSummaryText builds the one-line gray closing summary emitted after a +// plain render loop ran, e.g. "12 tasks: 10 ok, 2 failed in 3.4s" — the +// per-tick PlainRender output already printed every task line. +func plainSummaryText(tasks []*Task) api.Text { + var ok, failed int + var start, end time.Time + for _, t := range tasks { + switch t.Status() { + case StatusFailed, StatusWarning, StatusCancelled, StatusFAIL, StatusERR: + failed++ + case StatusPending, StatusRunning: + // not counted; final summaries normally see only terminal tasks + default: + ok++ + } + t.mu.Lock() + if !t.startTime.IsZero() && (start.IsZero() || t.startTime.Before(start)) { + start = t.startTime + } + if t.endTime.After(end) { + end = t.endTime + } + t.mu.Unlock() + } + + label := "tasks" + if len(tasks) == 1 { + label = "task" + } + summary := fmt.Sprintf("%d %s: %d ok", len(tasks), label, ok) + if failed > 0 { + summary += fmt.Sprintf(", %d failed", failed) + } + if !start.IsZero() && end.After(start) { + summary += " in " + text.HumanizeDuration(end.Sub(start)) + } + return api.Text{Content: summary, Style: "text-gray-400"} +} + // interactiveRender renders tasks in-place using ANSI clear lines. // Returns the number of lines rendered for the next cycle's ClearLines call. func (tm *Manager) interactiveRender(lastLines int) int { diff --git a/task/render_dedupe_test.go b/task/render_dedupe_test.go new file mode 100644 index 00000000..5e67dd79 --- /dev/null +++ b/task/render_dedupe_test.go @@ -0,0 +1,115 @@ +package task + +import ( + "strings" + "testing" + "time" + + "github.com/flanksource/clicky/text" +) + +// addDirtyLoggedTask registers a completed, dirty task directly on the +// manager's task list so PlainRender can be driven manually without workers. +func addDirtyLoggedTask(tm *Manager, name string) *Task { + task := tm.newTask(name) + task.SetStatus(StatusSuccess) + task.completed.Store(true) + task.dirty.Store(true) + tm.mu.Lock() + tm.tasks = append(tm.tasks, task) + tm.mu.Unlock() + return task +} + +// F11: PlainRender must not destroy log history — after a tick prints a dirty +// task, snapshots (SSE/registry) and the final tree still see every buffered +// entry. +func TestPlainRenderPreservesLogHistoryInSnapshot(t *testing.T) { + tm, _ := newRestartManager(t) + tm.noProgress.Store(true) // drive PlainRender manually, no loop + + task := addDirtyLoggedTask(tm, "history-task") + task.Warnf("history-log-1") + task.Warnf("history-log-2") + + tm.PlainRender() + + snap := SnapshotTask(task, nil) + if len(snap.Logs) != 2 { + t.Fatalf("PlainRender must preserve buffered logs for snapshots; got %d entries, want 2 (%+v)", len(snap.Logs), snap.Logs) + } + for i, want := range []string{"history-log-1", "history-log-2"} { + if snap.Logs[i].Message != want { + t.Errorf("snapshot log %d = %q, want %q", i, snap.Logs[i].Message, want) + } + } +} + +// F11(2): incremental plain output is preserved — a log emitted between two +// PlainRender ticks prints exactly once, not on every subsequent tick. +func TestPlainRenderEmitsEachLogOnce(t *testing.T) { + tm, capture := newRestartManager(t) + tm.noProgress.Store(true) + + task := addDirtyLoggedTask(tm, "incremental-task") + task.Warnf("incremental-log-first") + tm.PlainRender() + + task.Warnf("incremental-log-second") + task.dirty.Store(true) + tm.PlainRender() + + waitForRenderOutput(t, capture, "incremental-log-second") + stripped := text.StripANSI(capture.String()) + for _, want := range []string{"incremental-log-first", "incremental-log-second"} { + if got := strings.Count(stripped, want); got != 1 { + t.Errorf("log %q must print exactly once across ticks, got %d occurrences; output:\n%s", want, got, stripped) + } + } +} + +// F8: when the plain render loop ran, the final output after stopRender is the +// loop's own last PlainRender plus ONE gray one-line summary — not a second +// copy of every task via the full tree. +func TestLoopModeFinalOutputNoDuplicateAndSummary(t *testing.T) { + tm, capture := newRestartManager(t) + + runRestartTask(t, tm, "loop-dedupe-task") + tm.stopRender() + + waitForRenderOutput(t, capture, "1 task: 1 ok") + + stripped := text.StripANSI(capture.String()) + successLines := 0 + for _, line := range strings.Split(stripped, "\n") { + if strings.Contains(line, "loop-dedupe-task") && strings.Contains(line, "✓") { + successLines++ + } + } + if successLines != 1 { + t.Errorf("completed task must print exactly once in loop mode, got %d success lines; output:\n%s", successLines, stripped) + } + if got := strings.Count(stripped, "1 task: 1 ok"); got != 1 { + t.Errorf("expected exactly one summary line, got %d; output:\n%s", got, stripped) + } +} + +// F8(2): when no loop ran (noProgress/CI), stopRender prints the full tree +// once; a repeat stopRender with nothing newly enqueued prints nothing. +func TestNoLoopFinalRenderIdempotent(t *testing.T) { + tm, capture := newRestartManager(t) + tm.noProgress.Store(true) + + runRestartTask(t, tm, "noloop-task") + + tm.stopRender() + waitForRenderOutput(t, capture, "noloop-task") + + tm.stopRender() + time.Sleep(100 * time.Millisecond) // let any (wrong) duplicate output drain into capture + + stripped := text.StripANSI(capture.String()) + if got := strings.Count(stripped, "noloop-task"); got != 1 { + t.Errorf("no-loop final tree must print exactly once across repeated stopRender, got %d; output:\n%s", got, stripped) + } +} diff --git a/task/render_hook_test.go b/task/render_hook_test.go index 8355191f..59bc0696 100644 --- a/task/render_hook_test.go +++ b/task/render_hook_test.go @@ -103,7 +103,7 @@ func TestLiveRenderer_FinalUsesRenderFinal(t *testing.T) { addCompletedTask(tm, "task-x") tm.setLiveRenderer(&fakeLiveRenderer{live: "LIVE-ONLY", final: "FINAL-SUMMARY"}) - tm.renderFinal() + tm.renderFinal(false) os.Stderr = originalStderr _ = w.Close() diff --git a/task/render_restart_test.go b/task/render_restart_test.go new file mode 100644 index 00000000..d49c0921 --- /dev/null +++ b/task/render_restart_test.go @@ -0,0 +1,172 @@ +package task + +import ( + "bytes" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + flanksourceContext "github.com/flanksource/commons/context" + "github.com/flanksource/commons/logger" + + "github.com/flanksource/clicky/text" +) + +// syncBuffer is a mutex-guarded buffer safe for a concurrent io.Copy writer +// and test-side readers. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// newRestartManager builds a manager in plain (non-interactive) render mode +// whose renderer writes into a captured pipe: noProgress stays false so the +// render loop starts on enqueue, noColor keeps assertions stable. +func newRestartManager(t *testing.T) (*Manager, *syncBuffer) { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + + // The lipgloss renderer binds os.Stderr at construction time; swap it in + // only for the constructor so live output lands in the pipe. + origStderr := os.Stderr + os.Stderr = w + tm := newManagerWithConcurrency(2) + os.Stderr = origStderr + + tm.noColor.Store(true) + tm.isInteractive.Store(false) + + capture := &syncBuffer{} + copyDone := make(chan struct{}) + go func() { + _, _ = io.Copy(capture, r) + close(copyDone) + }() + + origLogOutput := logger.GetOutput() + t.Cleanup(func() { + tm.stopRender() + close(tm.shutdown) + _ = w.Close() + <-copyDone + _ = r.Close() + logger.SetOutput(origLogOutput) + }) + + return tm, capture +} + +func runRestartTask(t *testing.T, tm *Manager, name string) { + t.Helper() + task := tm.newTask(name) + task.runFunc = func(ctx flanksourceContext.Context, tk *Task) error { + tk.Success() + return nil + } + tm.enqueue(task) + select { + case <-task.doneChan: + case <-time.After(5 * time.Second): + t.Fatalf("timeout waiting for task %q to complete", name) + } +} + +func waitForRenderOutput(t *testing.T, capture *syncBuffer, want string) { + t.Helper() + deadline := time.After(3 * time.Second) + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + if strings.Contains(text.StripANSI(capture.String()), want) { + return + } + select { + case <-deadline: + t.Fatalf("timed out waiting for %q in render output; got:\n%s", + want, text.StripANSI(capture.String())) + case <-tick.C: + } + } +} + +// F10: the render lifecycle must be re-armable. After stopRender ends the +// first batch (as Wait/WaitSilent do), a later enqueue must start a fresh +// render loop: recreated stop channel, reinstalled commons-logger serializer, +// and visible tick output for the new batch. +func TestRenderLoopRestartsAfterStopRender(t *testing.T) { + tm, capture := newRestartManager(t) + + runRestartTask(t, tm, "first-batch-task") + tm.stopRender() + waitForRenderOutput(t, capture, "first-batch-task") + + tm.mu.RLock() + chBefore := tm.stopRenderCh + tm.mu.RUnlock() + + runRestartTask(t, tm, "second-batch-task") + + tm.mu.RLock() + chAfter := tm.stopRenderCh + serializer := tm.logSerializer + tm.mu.RUnlock() + + if chAfter == nil || chAfter == chBefore { + t.Errorf("enqueue after stopRender must restart the render loop with a fresh stop channel") + } + if serializer == nil { + t.Errorf("expected the commons logger serializer to be reinstalled for the restarted loop") + } else if logger.GetOutput() != io.Writer(serializer) { + t.Errorf("expected logger.GetOutput() to be the reinstalled serializer while the loop runs") + } + + waitForRenderOutput(t, capture, "second-batch-task") +} + +// F10(2): when noProgress is set at first enqueue (e.g. before CLI flags are +// applied), the lifecycle must not be consumed — after noProgress flips off, +// the next enqueue must start the render loop. +func TestRenderLoopStartsAfterNoProgressFlip(t *testing.T) { + tm, capture := newRestartManager(t) + tm.noProgress.Store(true) + + runRestartTask(t, tm, "before-flip-task") + + tm.mu.RLock() + chBefore := tm.stopRenderCh + tm.mu.RUnlock() + if chBefore != nil { + t.Fatalf("render loop must not start while noProgress is set") + } + + tm.noProgress.Store(false) + runRestartTask(t, tm, "after-flip-task") + + tm.mu.RLock() + chAfter := tm.stopRenderCh + tm.mu.RUnlock() + if chAfter == nil { + t.Errorf("enqueue after the noProgress flip must start the render loop") + } + + waitForRenderOutput(t, capture, "after-flip-task") +} diff --git a/task/task.go b/task/task.go index 85db174d..2d02f72c 100644 --- a/task/task.go +++ b/task/task.go @@ -12,11 +12,9 @@ import ( flanksourceContext "github.com/flanksource/commons/context" "github.com/flanksource/commons/logger" "github.com/flanksource/commons/text" - "github.com/samber/lo" "golang.org/x/sync/semaphore" "github.com/flanksource/clicky/api" - "github.com/flanksource/clicky/formatters" ) // Status represents the status of a task @@ -206,10 +204,11 @@ type Task struct { identity string // Unique identifier for task deduplication // 4-byte types - progress int - maxValue int - retryCount int - priority int // Priority for queue ordering (lower = higher priority) + progress int + maxValue int + retryCount int + priority int // Priority for queue ordering (lower = higher priority) + plainLogsRendered int // buffered log entries already emitted by PlainRender; guarded by mu // Smaller types status Status @@ -463,6 +462,8 @@ func (t *Task) Status() Status { // WaitTime returns how long the task waited before starting func (t *Task) WaitTime() time.Duration { + t.mu.Lock() + defer t.mu.Unlock() if t.endTime.IsZero() { return time.Since(t.startTime) } @@ -471,6 +472,8 @@ func (t *Task) WaitTime() time.Duration { // StartTime returns when the task started execution func (t *Task) StartTime() time.Time { + t.mu.Lock() + defer t.mu.Unlock() return t.startTime } @@ -634,6 +637,14 @@ func (t *Task) Duration() time.Duration { return endTime.Sub(t.startTime) } +// EndTime returns when the task reached a terminal state, or the zero time if +// it is still pending/running. +func (t *Task) EndTime() time.Time { + t.mu.Lock() + defer t.mu.Unlock() + return t.endTime +} + // IsGroup returns false for Task func (t *Task) IsGroup() bool { return false @@ -666,96 +677,23 @@ func (t *Task) getDuration() string { return text.HumanizeDuration(end.Sub(t.startTime)) } -// Pretty returns a formatted text representation of the task +// Pretty returns a formatted text representation of the task with its full +// buffered log history. func (t *Task) Pretty() api.Text { t.mu.Lock() defer t.mu.Unlock() + text, _ := t.prettyWithLogOffset(0) + return text +} - if pretty, ok := t.result.(formatters.PrettyMixin); ok { - rv := reflect.ValueOf(pretty) - if rv.Kind() != reflect.Ptr || (!rv.IsNil() && rv.Pointer() != reflect.ValueOf(t).Pointer()) { - return pretty.Pretty() - } - } - - var text api.Text - - duration := t.getDuration() - displayName := t.name - if t.modelName != "" { - displayName = t.modelName + " " + displayName - } - if t.prompt != "" { - truncatedPrompt := t.prompt - displayName += fmt.Sprintf(" %q", truncatedPrompt) - } - if t.description != "" { - displayName += ": " + t.description - } - - text.Content = displayName - text.Style = "max-w-[tw-20ch] truncate-suffix" - text = text.Space().Append(fmt.Sprintf("%-10s", duration), "") - - // Note: We can't call t.Status() here since it would try to acquire the same mutex - // So we directly access t.status and handle the health check inline - if health, ok := t.result.(HealthMixin); ok { - switch health.Health() { - case HealthOK: - t.status = StatusSuccess - case HealthWarning: - t.status = StatusWarning - case HealthError: - t.status = StatusFailed - case HealthPending: - t.status = StatusPending - } - } - text = t.status.Apply(text) - - level := t.ctx.Logger.GetLevel() - // Add logs as children if present from bufferedLogger - bufferedLogs := t.getBufferedLogger().GetLogs() - maxLogs := 5 - if t.ctx.Logger.IsLevelEnabled(logger.Trace2) { - maxLogs = 1000 - } else if t.ctx.Logger.IsLevelEnabled(logger.Trace1) { - maxLogs = 100 - } else if t.ctx.Logger.IsLevelEnabled(logger.Trace) { - maxLogs = 50 - } else if t.ctx.Logger.IsLevelEnabled(logger.Debug) { - maxLogs = 20 - } - if len(bufferedLogs) > maxLogs { - excess := len(bufferedLogs) - maxLogs - bufferedLogs = bufferedLogs[len(bufferedLogs)-maxLogs:] - bufferedLogs = append(bufferedLogs, logger.BufferedLogEntry{Message: fmt.Sprintf("\t... %d more log lines ...", excess)}) - } - for _, log := range bufferedLogs { - if level < log.Level { - continue - } - // Hide info logs when task completed successfully and log level is Info (0) or higher - if t.status == StatusSuccess && level <= logger.Info && log.Level == logger.Info { - continue - } - var logStyle string - - switch log.Level { - case logger.Error: - logStyle = "text-red-600" - case logger.Warn: - logStyle = "text-yellow-600" - default: - logStyle = "text-gray-400" - } - - text.Children = append(text.Children, api.Text{ - Content: fmt.Sprintf("\n%s", lo.Ellipsis(log.Message, 500)), - Style: logStyle, - }) - } - +// prettyPlainDelta renders the task line plus only the log entries not yet +// emitted by a previous PlainRender tick, then advances the cursor. The buffer +// itself is preserved for Pretty(), snapshots, and the final tree. +func (t *Task) prettyPlainDelta() api.Text { + t.mu.Lock() + defer t.mu.Unlock() + text, total := t.prettyWithLogOffset(t.plainLogsRendered) + t.plainLogsRendered = total return text } diff --git a/task/task_logs_render.go b/task/task_logs_render.go new file mode 100644 index 00000000..1e0ca7f6 --- /dev/null +++ b/task/task_logs_render.go @@ -0,0 +1,112 @@ +package task + +import ( + "fmt" + "reflect" + + "github.com/flanksource/commons/logger" + "github.com/samber/lo" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/formatters" +) + +// prettyWithLogOffset renders the task's status line with buffered-log +// children, considering only log entries at index >= from. It returns the +// rendered text plus the total number of buffered entries observed, so +// PlainRender can advance its per-task cursor without re-reading (and racing) +// the buffer. Pretty() delegates with from=0 for the full history. Caller must +// hold t.mu. +func (t *Task) prettyWithLogOffset(from int) (api.Text, int) { + if pretty, ok := t.result.(formatters.PrettyMixin); ok { + rv := reflect.ValueOf(pretty) + if rv.Kind() != reflect.Ptr || (!rv.IsNil() && rv.Pointer() != reflect.ValueOf(t).Pointer()) { + return pretty.Pretty(), from + } + } + + var text api.Text + + duration := t.getDuration() + displayName := t.name + if t.modelName != "" { + displayName = t.modelName + " " + displayName + } + if t.prompt != "" { + truncatedPrompt := t.prompt + displayName += fmt.Sprintf(" %q", truncatedPrompt) + } + if t.description != "" { + displayName += ": " + t.description + } + + text.Content = displayName + text.Style = "max-w-[tw-20ch] truncate-suffix" + text = text.Space().Append(fmt.Sprintf("%-10s", duration), "") + + // Note: We can't call t.Status() here since it would try to acquire the same mutex + // So we directly access t.status and handle the health check inline + if health, ok := t.result.(HealthMixin); ok { + switch health.Health() { + case HealthOK: + t.status = StatusSuccess + case HealthWarning: + t.status = StatusWarning + case HealthError: + t.status = StatusFailed + case HealthPending: + t.status = StatusPending + } + } + text = t.status.Apply(text) + + level := t.ctx.Logger.GetLevel() + // Add logs as children if present from bufferedLogger + bufferedLogs := t.getBufferedLogger().GetLogs() + total := len(bufferedLogs) + if from > total { + from = total // per-level ring eviction may have shrunk the buffer + } + bufferedLogs = bufferedLogs[from:] + maxLogs := 5 + if t.ctx.Logger.IsLevelEnabled(logger.Trace2) { + maxLogs = 1000 + } else if t.ctx.Logger.IsLevelEnabled(logger.Trace1) { + maxLogs = 100 + } else if t.ctx.Logger.IsLevelEnabled(logger.Trace) { + maxLogs = 50 + } else if t.ctx.Logger.IsLevelEnabled(logger.Debug) { + maxLogs = 20 + } + if len(bufferedLogs) > maxLogs { + excess := len(bufferedLogs) - maxLogs + bufferedLogs = bufferedLogs[len(bufferedLogs)-maxLogs:] + bufferedLogs = append(bufferedLogs, logger.BufferedLogEntry{Message: fmt.Sprintf("\t... %d more log lines ...", excess)}) + } + for _, log := range bufferedLogs { + if level < log.Level { + continue + } + // Hide info logs when task completed successfully and log level is Info (0) or higher + if t.status == StatusSuccess && level <= logger.Info && log.Level == logger.Info { + continue + } + var logStyle string + + switch log.Level { + case logger.Error: + logStyle = "text-red-600" + case logger.Warn: + logStyle = "text-yellow-600" + default: + logStyle = "text-gray-400" + } + + text.Children = append(text.Children, api.Text{ + Content: fmt.Sprintf("\n%s", lo.Ellipsis(log.Message, 500)), + Style: logStyle, + }) + } + + return text, total +} From 939ebf0b16dec314085dccf93a82422391e79e78 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 12:57:23 +0300 Subject: [PATCH 16/23] claude: stamp default tool permissions on standard entity verbs feat(entity): list/get default to auto-run and create/update/delete to ask at registration; EntityBuilder.ToolPermission and ActionSpec.WithToolPermission override; promoted entity roots inherit the list permission --- entity/annotations.go | 18 ++++++ entity/builder.go | 8 +++ entity/entity.go | 11 ++++ entity/toolpermission_test.go | 101 ++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 entity/toolpermission_test.go diff --git a/entity/annotations.go b/entity/annotations.go index fce84832..1183d101 100644 --- a/entity/annotations.go +++ b/entity/annotations.go @@ -192,6 +192,24 @@ func annotateEntityOperationCommand( // Applied after inheritance: non-empty per-action hints replace inherited // entity hints; empty values are no-ops. AnnotateTool(cmd, toolHints) + if cmd.Annotations[annotationClickyToolPermission] == "" { + setCommandAnnotation(cmd, annotationClickyToolPermission, string(verbDefaultToolPermission(verb))) + } +} + +// verbDefaultToolPermission is the approval mode a standard entity operation +// carries when neither the entity nor the action declares one: reads auto-run, +// mutations ask. Custom actions have no verb default — they declare their own +// via WithToolPermission or fall through to the app's approval policy. +func verbDefaultToolPermission(verb string) ToolPermission { + switch verb { + case "list", "get": + return ToolPermissionOn + case "create", "update", "delete": + return ToolPermissionAsk + default: + return "" + } } func inheritEntityAnnotations(cmd *cobra.Command, parent *cobra.Command) { diff --git a/entity/builder.go b/entity/builder.go index 2624dcee..01870cbb 100644 --- a/entity/builder.go +++ b/entity/builder.go @@ -41,6 +41,14 @@ func (b *EntityBuilder[T, ListOpts, R]) ToolGroup(group string) *EntityBuilder[T return b } +// ToolPermission sets the default approval mode inherited by every generated +// operation, replacing the standard verb defaults (list/get auto-run, +// create/update/delete ask). A per-action WithToolPermission overrides it. +func (b *EntityBuilder[T, ListOpts, R]) ToolPermission(permission ToolPermission) *EntityBuilder[T, ListOpts, R] { + b.entity.ToolHints.DefaultPermission = permission + return b +} + // ToolHints sets MCP-facing annotations and Clicky UI metadata inherited by all // generated operations. Per-action hints can override these values. func (b *EntityBuilder[T, ListOpts, R]) ToolHints(hints MCPToolHints) *EntityBuilder[T, ListOpts, R] { diff --git a/entity/entity.go b/entity/entity.go index b28b1220..6bfad73f 100644 --- a/entity/entity.go +++ b/entity/entity.go @@ -375,6 +375,14 @@ func (a *ActionSpec[R]) WithToolGroup(group string) *ActionSpec[R] { return a } +// WithToolPermission sets this action's default approval mode. Actions carry no +// verb default: unset defers to the entity's ToolPermission or, failing that, +// the app's approval policy. +func (a *ActionSpec[R]) WithToolPermission(permission ToolPermission) *ActionSpec[R] { + a.toolHints.DefaultPermission = permission + return a +} + // WithToolHints overrides this action's inherited MCP tool hints. func (a *ActionSpec[R]) WithToolHints(hints MCPToolHints) *ActionSpec[R] { a.toolHints = a.toolHints.merge(hints) @@ -1088,6 +1096,9 @@ func promoteListToEntityRoot(entityCmd *cobra.Command) { if listMeta := GetCommandOpenAPIMeta(listCmd); listMeta != nil && listMeta.SupportsLookup { setCommandAnnotation(entityCmd, annotationClickySupportsLookup, "true") } + if entityCmd.Annotations[annotationClickyToolPermission] == "" { + setCommandAnnotation(entityCmd, annotationClickyToolPermission, listCmd.Annotations[annotationClickyToolPermission]) + } if df := GetDataFunc(listCmd); df != nil { dataFuncRegistry.Store(entityCmd, df) diff --git a/entity/toolpermission_test.go b/entity/toolpermission_test.go new file mode 100644 index 00000000..495a8792 --- /dev/null +++ b/entity/toolpermission_test.go @@ -0,0 +1,101 @@ +package entity + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// permissionOf annotates an entity-root command and one operation command, then +// returns the operation's resolved DefaultPermission. entityPerm is the +// entity-level default ("" means none); override is the per-action permission +// passed to annotateEntityOperationCommand ("" means inherit). +func permissionOf(t *testing.T, verb string, entityPerm, override ToolPermission) ToolPermission { + t.Helper() + entityCmd := &cobra.Command{Use: "invoice"} + annotateEntityCommand(entityCmd, EntityInfo{ + Name: "invoice", + ToolHints: MCPToolHints{DefaultPermission: entityPerm}, + }) + + opCmd := &cobra.Command{Use: verb} + annotateEntityOperationCommand(opCmd, entityCmd, verb, "", "collection", "", "", false, false, false, MCPToolHints{DefaultPermission: override}) + + meta := GetCommandOpenAPIMeta(opCmd) + if meta == nil { + t.Fatalf("GetCommandOpenAPIMeta returned nil for operation command") + } + return meta.ToolHints.DefaultPermission +} + +func TestToolPermissionVerbDefaults(t *testing.T) { + tests := []struct { + verb string + want ToolPermission + }{ + {verb: "list", want: ToolPermissionOn}, + {verb: "get", want: ToolPermissionOn}, + {verb: "create", want: ToolPermissionAsk}, + {verb: "update", want: ToolPermissionAsk}, + {verb: "delete", want: ToolPermissionAsk}, + {verb: "action", want: ""}, + } + for _, tt := range tests { + t.Run(tt.verb, func(t *testing.T) { + if got := permissionOf(t, tt.verb, "", ""); got != tt.want { + t.Errorf("DefaultPermission for %q = %q, want %q", tt.verb, got, tt.want) + } + }) + } +} + +func TestToolPermissionEntityOverridesVerbDefault(t *testing.T) { + if got := permissionOf(t, "list", ToolPermissionAsk, ""); got != ToolPermissionAsk { + t.Errorf("entity-level permission = %q, want ask", got) + } + if got := permissionOf(t, "delete", ToolPermissionOn, ""); got != ToolPermissionOn { + t.Errorf("entity-level permission = %q, want on", got) + } +} + +func TestToolPermissionActionOverridesEntityAndVerb(t *testing.T) { + if got := permissionOf(t, "delete", ToolPermissionOn, ToolPermissionAsk); got != ToolPermissionAsk { + t.Errorf("per-action permission = %q, want ask", got) + } + if got := permissionOf(t, "action", ToolPermissionAsk, ToolPermissionOn); got != ToolPermissionOn { + t.Errorf("per-action permission = %q, want on", got) + } +} + +func TestToolPermissionBuilderAndAction(t *testing.T) { + e := NewEntity[sampleTableEntity, toolGroupOpts, sampleTableEntity]("invoice"). + ToolPermission(ToolPermissionAsk). + Build() + if e.ToolHints.DefaultPermission != ToolPermissionAsk { + t.Errorf("Entity.ToolHints.DefaultPermission = %q, want ask", e.ToolHints.DefaultPermission) + } + + action := Action("recalculate", func(id string, _ map[string]string) (sampleTableEntity, error) { + return sampleTableEntity{ID: id}, nil + }).WithToolPermission(ToolPermissionOn) + if got := action.actionInfo().ToolHints.DefaultPermission; got != ToolPermissionOn { + t.Errorf("ActionInfo.ToolHints.DefaultPermission = %q, want on", got) + } +} + +// TestToolPermissionPromotedEntityRoot verifies the promoted entity-root command +// (bare `invoice` running list) carries the list operation's permission. +func TestToolPermissionPromotedEntityRoot(t *testing.T) { + entityCmd := &cobra.Command{Use: "invoice"} + annotateEntityCommand(entityCmd, EntityInfo{Name: "invoice"}) + listCmd := &cobra.Command{Use: "list", RunE: func(*cobra.Command, []string) error { return nil }} + annotateEntityOperationCommand(listCmd, entityCmd, "list", "", "collection", "", "", false, false, false, MCPToolHints{}) + entityCmd.AddCommand(listCmd) + + promoteListToEntityRoot(entityCmd) + + meta := GetCommandOpenAPIMeta(entityCmd) + if meta == nil || meta.ToolHints.DefaultPermission != ToolPermissionOn { + t.Fatalf("promoted root DefaultPermission = %+v, want on", meta) + } +} From b455a7a18d58f5d6e79a9f60e7734dac82be8d5a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 19:17:29 +0300 Subject: [PATCH 17/23] refactor(aichat): centralize model and tool metadata --- aichat/agent.go | 21 --- aichat/agent_test.go | 48 +++--- aichat/approval.go | 8 + aichat/approval_test.go | 10 +- aichat/genkit.go | 29 +--- aichat/go.mod | 2 +- aichat/go.sum | 4 +- aichat/models.go | 319 ++++------------------------------- aichat/models_test.go | 47 +++--- aichat/server.go | 34 +++- aichat/strict_tools.go | 103 +++++++++++ aichat/strict_tools_test.go | 149 ++++++++++++++++ aichat/tool_registry.go | 22 ++- aichat/tool_registry_test.go | 42 +++++ aichat/tools_clicky.go | 9 +- aichat/tools_clicky_test.go | 7 + aichat/tools_mcp.go | 2 +- mcp/registry.go | 31 ++-- 18 files changed, 483 insertions(+), 404 deletions(-) create mode 100644 aichat/strict_tools.go create mode 100644 aichat/strict_tools_test.go diff --git a/aichat/agent.go b/aichat/agent.go index ab38fbc4..1e09b7dc 100644 --- a/aichat/agent.go +++ b/aichat/agent.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "os/exec" "strings" "time" @@ -59,26 +58,6 @@ func defaultAgentProviderFactory(cfg capapi.Config) (capapi.StreamingProvider, e return sp, nil } -// agentModelConfigured reports whether the local backend for an agent model is -// installed (best effort): codex needs the `codex` binary; claude-agent needs -// `tsx` on PATH (or a provisioned agent dir, created lazily). A turn still fails -// loud if the probe is wrong. -func agentModelConfigured(m Model) bool { - switch m.Backend { - case capapi.BackendCodexCLI: - return lookPath("codex") - case capapi.BackendClaudeAgent, capapi.BackendClaudeCLI: - return lookPath("tsx") - default: - return false - } -} - -func lookPath(bin string) bool { - _, err := exec.LookPath(bin) - return err == nil -} - // captainModel returns the model slug passed to the captain backend: AgentModel // when set, otherwise the menu ID. func captainModel(m Model) string { diff --git a/aichat/agent_test.go b/aichat/agent_test.go index 1507c29d..551067ef 100644 --- a/aichat/agent_test.go +++ b/aichat/agent_test.go @@ -117,6 +117,11 @@ func containsType(parts []map[string]any, typ string) bool { // agentServer builds a server whose agent engine uses the supplied fake, // recording every Config the factory is asked to build. func agentServer(opts Options, fake *fakeStreamProvider, configs *[]capapi.Config) *Server { + // Register a stable test agent model so LookupModel routes these requests to + // the agent path regardless of the concrete ids captain's catalog ships. The + // upsert is idempotent and process-global; a fake provider factory serves the + // turn, so no real backend is contacted. + _ = RegisterModel(Model{ID: "claude-agent-sonnet", Backend: capapi.BackendClaudeAgent, Label: "Claude Agent · Sonnet (test)", Reasoning: true, ContextWindow: 200000}) opts.AgentProviderFactory = func(cfg capapi.Config) (capapi.StreamingProvider, error) { if configs != nil { *configs = append(*configs, cfg) @@ -367,8 +372,8 @@ func TestAgentRequestEditOptInSkipsReadOnlyDefault(t *testing.T) { if !air.Permissions.HasPreset(capapi.PresetEdit) { t.Errorf("Edit should be true") } - if air.Context.Dir != "/repo" { - t.Errorf("Cwd = %q, want /repo", air.Context.Dir) + if air.Cwd() != "/repo" { + t.Errorf("Cwd = %q, want /repo", air.Cwd()) } if len(air.Permissions.Tools.Allow) != 0 { t.Errorf("AllowedTools = %v, want empty (edit opt-in lets the backend curate)", air.Permissions.Tools.Allow) @@ -393,29 +398,30 @@ func TestAgentRequestCarriesBudgetAndTemperature(t *testing.T) { } } +// TestAgentModelsInCatalog verifies the captain-owned catalog (consumed via the +// aichat aliases) carries both agent and Genkit models, that IsAgent() tracks the +// backend kind, and that every id round-trips through LookupModel with the slug +// the captain backend receives. func TestAgentModelsInCatalog(t *testing.T) { - cases := map[string]struct { - engine Engine - backend capapi.Backend - agentModel string - }{ - "claude-agent-sonnet": {EngineAgent, capapi.BackendClaudeAgent, ""}, - "codex-gpt-5-codex": {EngineAgent, capapi.BackendCodexCLI, "gpt-5-codex"}, - "anthropic/claude-sonnet-5": {EngineGenkit, "", ""}, - } - for id, want := range cases { - m, err := LookupModel(id) + var sawAgent, sawGenkit bool + for _, model := range Catalog() { + got, err := LookupModel(model.ID) if err != nil { - t.Fatalf("LookupModel(%q): %v", id, err) + t.Fatalf("LookupModel(%q): %v", model.ID, err) } - if m.Engine != want.engine { - t.Errorf("%s engine = %q, want %q", id, m.Engine, want.engine) + if got.IsAgent() != (model.Backend.Kind() == "cli") { + t.Errorf("%s IsAgent = %v, but backend %q kind = %q", model.ID, got.IsAgent(), model.Backend, model.Backend.Kind()) } - if m.Backend != want.backend { - t.Errorf("%s backend = %q, want %q", id, m.Backend, want.backend) - } - if captainModel(m) != firstNonEmptyString(want.agentModel, id) { - t.Errorf("%s captainModel = %q, want %q", id, captainModel(m), firstNonEmptyString(want.agentModel, id)) + if model.IsAgent() { + sawAgent = true + if want := firstNonEmptyString(model.AgentModel, model.ID); captainModel(model) != want { + t.Errorf("%s captainModel = %q, want %q", model.ID, captainModel(model), want) + } + } else { + sawGenkit = true } } + if !sawAgent || !sawGenkit { + t.Fatalf("catalog should contain both agent and genkit models: agent=%v genkit=%v", sawAgent, sawGenkit) + } } diff --git a/aichat/approval.go b/aichat/approval.go index e53bffe1..a5c25cfa 100644 --- a/aichat/approval.go +++ b/aichat/approval.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/firebase/genkit/go/ai" + clickymcp "github.com/flanksource/clicky/mcp" "github.com/flanksource/clicky/rpc" ) @@ -61,6 +62,9 @@ type ToolInfo struct { Icon string DefaultPermission ToolMode Strict *bool + ReadOnlyHint *bool + DestructiveHint *bool + IdempotentHint *bool Operation *rpc.RPCOperation } @@ -77,6 +81,10 @@ func toolInfo(name string, op *rpc.RPCOperation) ToolInfo { info.Parent = op.ToolHints.Parent info.Icon = op.ToolHints.Icon info.Strict = op.ToolHints.Strict + hints := clickymcp.EffectiveToolHints(op) + info.ReadOnlyHint = hints.ReadOnlyHint + info.DestructiveHint = hints.DestructiveHint + info.IdempotentHint = hints.IdempotentHint if op.Clicky != nil { info.ClickyVerb = op.Clicky.Verb info.ClickyScope = op.Clicky.Scope diff --git a/aichat/approval_test.go b/aichat/approval_test.go index 2ea0c7e8..8b60a8e7 100644 --- a/aichat/approval_test.go +++ b/aichat/approval_test.go @@ -3,6 +3,8 @@ package aichat import ( "context" "testing" + + capai "github.com/flanksource/captain/pkg/ai" ) func TestRequireApprovalForEmptyIsNil(t *testing.T) { @@ -105,9 +107,9 @@ func TestToolsForRequestDropsDefaultOffTools(t *testing.T) { } func TestCatalogInfoMarksConfiguredProviders(t *testing.T) { - info := CatalogInfo([]Provider{ProviderAnthropic}) - if len(info) != len(catalog) { - t.Fatalf("CatalogInfo len = %d, want %d", len(info), len(catalog)) + info := capai.CatalogInfo(providerStrings([]Provider{ProviderAnthropic})) + if len(info) != len(Catalog()) { + t.Fatalf("CatalogInfo len = %d, want %d", len(info), len(Catalog())) } var sawConfigured, sawUnconfigured bool for _, m := range info { @@ -116,7 +118,7 @@ func TestCatalogInfoMarksConfiguredProviders(t *testing.T) { } // Agent models are gated on local backend availability, not on the // registered Genkit providers passed here, so skip them for this check. - if model, err := LookupModel(m.ID); err == nil && model.Engine == EngineAgent { + if model, err := LookupModel(m.ID); err == nil && model.IsAgent() { continue } if m.Provider == string(ProviderAnthropic) { diff --git a/aichat/genkit.go b/aichat/genkit.go index be8c8098..bf14abc5 100644 --- a/aichat/genkit.go +++ b/aichat/genkit.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strings" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core/api" @@ -133,8 +132,8 @@ func firstNonEmptyString(vals ...string) string { // schema and per-model capability gating (reasoning, temperature) — then adds // the Genkit non-Anthropic maxOutputTokens cap. func effortConfig(m Model, e Effort, budget ChatBudget, temperature *float64) map[string]any { - cfg := capai.EffortConfig(genkitBackend(m.Provider), bareModelID(m.ID), capapi.Effort(e), budget.MaxTokens, temperature) - if m.Provider != ProviderAnthropic && budget.MaxTokens > 0 { + cfg := capai.EffortConfig(m.Backend, m.BareID(), capapi.Effort(e), budget.MaxTokens, temperature) + if m.Backend != capapi.BackendAnthropic && budget.MaxTokens > 0 { if cfg == nil { cfg = map[string]any{} } @@ -146,30 +145,6 @@ func effortConfig(m Model, e Effort, budget ChatBudget, temperature *float64) ma return cfg } -// genkitBackend maps a Genkit provider to captain's ai.Backend so EffortConfig -// can resolve the model's capabilities from captain's registry. -func genkitBackend(p Provider) capapi.Backend { - switch p { - case ProviderAnthropic: - return capapi.BackendAnthropic - case ProviderOpenAI: - return capapi.BackendOpenAI - case ProviderGoogle: - return capapi.BackendGemini - default: - return "" - } -} - -// bareModelID drops the "provider/" prefix from a Genkit model id so captain's -// registry lookup hits the exact model. -func bareModelID(id string) string { - if i := strings.IndexByte(id, '/'); i >= 0 { - return id[i+1:] - } - return id -} - // generateOptions assembles the Generate options for a chat turn: model, // messages, tools, system prompt, optional effort config, and the streaming // callback. diff --git a/aichat/go.mod b/aichat/go.mod index e75cb3a5..17248a50 100644 --- a/aichat/go.mod +++ b/aichat/go.mod @@ -3,7 +3,7 @@ module github.com/flanksource/clicky/aichat go 1.26.1 require ( - github.com/firebase/genkit/go v1.8.0 + github.com/firebase/genkit/go v1.10.0 github.com/flanksource/captain v0.0.9 github.com/flanksource/clicky v1.21.37 github.com/spf13/cobra v1.10.2 diff --git a/aichat/go.sum b/aichat/go.sum index be4e6736..925482fa 100644 --- a/aichat/go.sum +++ b/aichat/go.sum @@ -246,8 +246,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/firebase/genkit/go v1.8.0 h1:jIL9xS3ZxW9sTWN2SG9RyupPd0srjXmfB1749FPIuaY= -github.com/firebase/genkit/go v1.8.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= +github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs= +github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= github.com/flanksource/captain v0.0.9 h1:salnAHq7R7duv6vfyDfM31Lu22JsG1gNwO1NS7qO6Tc= github.com/flanksource/captain v0.0.9/go.mod h1:QPXC+P386XbR8m5vyg/qz4L1RXdcDJXsVspUuk6PSAA= github.com/flanksource/commons v1.53.1 h1:WiMvY9XGG//L4ndYKTcmp0NjWXg4B7Wbn4hYWkITUmY= diff --git a/aichat/models.go b/aichat/models.go index ef5ab7c4..9cfcf6ab 100644 --- a/aichat/models.go +++ b/aichat/models.go @@ -3,14 +3,13 @@ package aichat import ( "fmt" "slices" - "sort" - "strings" - "sync" - capapi "github.com/flanksource/captain/pkg/api" + capai "github.com/flanksource/captain/pkg/ai" ) -// Provider is a Genkit provider name (the prefix of a "provider/model" id). +// Provider is a Genkit provider name (the prefix of a "provider/model" id). It +// keys the Genkit plugin registration and the per-request "configured" overlay; +// the model catalog itself is owned by captain (see Model below). type Provider string const ( @@ -31,306 +30,62 @@ const ( EffortHigh Effort = "high" ) -// Engine selects which execution path serves a model: the in-process Genkit -// runtime (API providers with caller-injected tools + human approval) or -// captain's consolidated agent framework (claude-agent / codex, which own their -// own tools and run as supervised local subprocesses). -type Engine string +// Model and ModelInfo are captain's catalog types. captain owns the catalog (it +// is keyed on Backend, which is captain data) and aichat consumes it via these +// aliases, so the chat menu never drifts from `captain whoami`. An agent model +// is identified by Model.IsAgent(); the Genkit provider for an API model is +// captain's BackendToProvider(Model.Backend). +type Model = capai.Model -const ( - // EngineGenkit (the zero value) is the Firebase Genkit API path. - EngineGenkit Engine = "" - // EngineAgent routes the turn through a captain pkg/ai StreamingProvider. - EngineAgent Engine = "agent" -) - -// Model describes one entry in the chat model menu. For EngineGenkit models ID -// is the full Genkit "provider/model" id passed to ai.WithModelName. For -// EngineAgent models ID is the menu/catalog key (e.g. "claude-agent-sonnet"); -// Backend and AgentModel describe how to construct the captain provider. -type Model struct { - ID string - Provider Provider - Label string // human-friendly menu label - Reasoning bool // model honours Effort - Temperature bool // model honours the temperature sampling control - ContextWindow int // max context tokens, for a usage gauge's denominator - - // Engine selects the execution path. The zero value (EngineGenkit) is the - // Genkit API path; EngineAgent routes through captain's agent framework. - Engine Engine - // Backend is the captain ai.Backend for EngineAgent models (e.g. - // capapi.BackendClaudeAgent, capapi.BackendCodexCLI). Unused for - // EngineGenkit. - Backend capapi.Backend - // AgentModel is the model slug passed to the captain backend when it differs - // from ID (e.g. menu id "codex-gpt-5-codex" → backend model "gpt-5-codex"). - // Empty means use ID. Unused for EngineGenkit. - AgentModel string -} - -// ModelInfo is the JSON shape served at GET /api/chat/models so a client model -// selector can be data-driven. Configured reports whether the model's provider -// has an API key (selectable) vs merely catalogued. -type ModelInfo struct { - ID string `json:"id"` - Provider string `json:"provider"` - Label string `json:"label"` - Reasoning bool `json:"reasoning"` - Temperature bool `json:"temperature"` - Configured bool `json:"configured"` - ContextWindow int `json:"contextWindow"` -} - -// DefaultModelID is the chat backend's default, mirroring captain pkg/ai -// (Anthropic Sonnet 5 is captain's default). -const DefaultModelID = "anthropic/claude-sonnet-5" - -// defaultCatalog is the model menu: only the latest generally-available model -// per tier for each provider — no preview or superseded entries. Mirrors -// captain pkg/ai's catalog so the chat agrees with the rest of the stack. -// -// Provider currency (reviewed 2026-07-02): -// - Anthropic: Fable 5 (most capable), Opus 4.8, Sonnet 5, Haiku 4.5. Mythos 5 -// is Project Glasswing invite-only, so it is intentionally excluded. -// - OpenAI: GPT-5.5 (flagship) and GPT-5.4 mini. GPT-5.6 is preview-only. -// - Google: Gemini 2.5 Pro is the newest GA Pro (all Gemini 3.x Pro models are -// still preview), paired with the GA Gemini 3.5 Flash. -var defaultCatalog = []Model{ - {ID: "anthropic/claude-fable-5", Provider: ProviderAnthropic, Label: "Claude Fable 5", Reasoning: true, ContextWindow: 1000000}, - {ID: "anthropic/claude-opus-4-8", Provider: ProviderAnthropic, Label: "Claude Opus 4.8", Reasoning: true, ContextWindow: 1000000}, - {ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic, Label: "Claude Sonnet 5", Reasoning: true, ContextWindow: 1000000}, - {ID: "anthropic/claude-haiku-4-5", Provider: ProviderAnthropic, Label: "Claude Haiku 4.5", Reasoning: true, Temperature: true, ContextWindow: 200000}, - {ID: "openai/gpt-5.5", Provider: ProviderOpenAI, Label: "GPT-5.5", Reasoning: true, ContextWindow: 1000000}, - {ID: "openai/gpt-5.4-mini", Provider: ProviderOpenAI, Label: "GPT-5.4 mini", Reasoning: true, ContextWindow: 400000}, - {ID: "googleai/gemini-2.5-pro", Provider: ProviderGoogle, Label: "Gemini 2.5 Pro", Reasoning: true, Temperature: true, ContextWindow: 1048576}, - {ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle, Label: "Gemini 3.5 Flash", Reasoning: true, Temperature: true, ContextWindow: 1048576}, - - // Agent-framework models (captain pkg/ai StreamingProvider). These run a - // supervised local subprocess that owns its own tools; ids carry the - // backend prefix captain's InferBackend recognises, and Backend is set - // explicitly so codex slugs (which look like gpt-*) are not misrouted. - {ID: "claude-agent-sonnet", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Sonnet", Reasoning: true, ContextWindow: 1000000}, - {ID: "claude-agent-opus", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Opus", Reasoning: true, ContextWindow: 1000000}, - {ID: "claude-agent-haiku", Engine: EngineAgent, Backend: capapi.BackendClaudeAgent, Provider: "claude-agent", Label: "Claude Agent · Haiku", Reasoning: true, ContextWindow: 200000}, - {ID: "codex-gpt-5-codex", Engine: EngineAgent, Backend: capapi.BackendCodexCLI, AgentModel: "gpt-5-codex", Provider: "codex-cli", Label: "Codex · GPT-5", Reasoning: true, ContextWindow: 400000}, -} - -var ( - modelRegistryMu sync.RWMutex - catalog = append([]Model(nil), defaultCatalog...) -) - -// Catalog returns the registered model menu. -func Catalog() []Model { - modelRegistryMu.RLock() - defer modelRegistryMu.RUnlock() - - out := make([]Model, len(catalog)) - copy(out, catalog) - return out -} - -// RegisterModel adds a model to the global chat model registry, or replaces the -// existing entry with the same ID while preserving its position in the menu. -func RegisterModel(model Model) error { - return RegisterModels(model) -} +// ModelInfo is the JSON shape served at GET /api/chat/models. +type ModelInfo = capai.ModelInfo -// RegisterModels adds models to the global chat model registry. Existing IDs -// are updated in place; new IDs are appended to the menu. -func RegisterModels(models ...Model) error { - normalized, err := normalizeModels(models, false) - if err != nil { - return err - } - - modelRegistryMu.Lock() - defer modelRegistryMu.Unlock() - - for _, model := range normalized { - updated := false - for i := range catalog { - if catalog[i].ID == model.ID { - catalog[i] = model - updated = true - break - } - } - if !updated { - catalog = append(catalog, model) - } - } - return nil -} - -// SetModelCatalog replaces the global chat model registry. Use RegisterModel or -// RegisterModels when you only need to extend the built-in catalog. -func SetModelCatalog(models []Model) error { - normalized, err := normalizeModels(models, true) - if err != nil { - return err - } +// DefaultModelID mirrors captain's catalog default (Anthropic Sonnet 5). +const DefaultModelID = capai.DefaultModelID - modelRegistryMu.Lock() - defer modelRegistryMu.Unlock() - catalog = append([]Model(nil), normalized...) - return nil -} +// Catalog returns captain's static model menu. +func Catalog() []Model { return capai.Catalog() } -// ResetModelCatalog restores the built-in model registry. It is primarily useful -// for tests that temporarily install custom models. -func ResetModelCatalog() { - modelRegistryMu.Lock() - defer modelRegistryMu.Unlock() - catalog = append([]Model(nil), defaultCatalog...) -} +// LookupModel resolves an id against captain's catalog for the generation path. +// It uses the static catalog (not the live probe) so resolution is deterministic +// and hermetic; the served menu (handleModels) uses the live catalog for display, +// which is a superset on any host whose probe only adds live provider models. +func LookupModel(id string) (Model, error) { return capai.LookupModel(id) } -func normalizeModels(models []Model, rejectDuplicateIDs bool) ([]Model, error) { - out := make([]Model, 0, len(models)) - seen := make(map[string]bool, len(models)) - for _, model := range models { - normalized, err := normalizeModel(model) - if err != nil { - return nil, err - } - if rejectDuplicateIDs && seen[normalized.ID] { - return nil, fmt.Errorf("duplicate model ID %q", normalized.ID) - } - seen[normalized.ID] = true - out = append(out, normalized) - } - return out, nil -} - -func normalizeModel(model Model) (Model, error) { - model.ID = strings.TrimSpace(model.ID) - model.Label = strings.TrimSpace(model.Label) - if model.ID == "" { - return Model{}, fmt.Errorf("model ID is required") - } - if model.Engine == EngineAgent { - if model.Backend == "" { - return Model{}, fmt.Errorf("agent model %q must set Backend (e.g. claude-agent, codex-cli)", model.ID) - } - if model.Provider == "" { - model.Provider = Provider(model.Backend) - } - } - if model.Provider == "" { - model.Provider = ProviderOf(model.ID) - } - if model.Provider == "" { - return Model{}, fmt.Errorf("model %q must include a provider, e.g. openai/%s", model.ID, model.ID) - } - if model.Label == "" { - model.Label = model.ID - } - return model, nil -} +// RegisterModel / RegisterModels / SetModelCatalog / ResetModelCatalog delegate +// to captain's catalog for embedders that extend or replace the menu. +func RegisterModel(model Model) error { return capai.RegisterModel(model) } +func RegisterModels(models ...Model) error { return capai.RegisterModels(models...) } +func SetModelCatalog(models []Model) error { return capai.SetModelCatalog(models) } +func ResetModelCatalog() { capai.ResetModelCatalog() } -// CatalogInfo returns the model menu annotated with whether each model's -// provider is among the registered providers (i.e. selectable). The order -// mirrors the catalog so the client can render a stable, grouped menu. -func CatalogInfo(registered []Provider) []ModelInfo { - models := Catalog() - out := make([]ModelInfo, len(models)) - for i, m := range models { - configured := providerRegistered(registered, m.Provider) - if m.Engine == EngineAgent { - // Agent models are gated on local backend availability (the CLI/SDK - // being installed), not on a Genkit API key. - configured = agentModelConfigured(m) - } - out[i] = ModelInfo{ - ID: m.ID, - Provider: string(m.Provider), - Label: m.Label, - Reasoning: m.Reasoning, - Temperature: m.Temperature, - Configured: configured, - ContextWindow: m.ContextWindow, - } - } - return out -} +// modelProvider is the Genkit provider (plugin key) for a catalog model. +func modelProvider(m Model) Provider { return Provider(capai.BackendToProvider(m.Backend)) } -// LookupModel resolves a "provider/model" id against the catalog. Returns an -// error listing the menu on miss — fail loud, never silently substitute. -func LookupModel(id string) (Model, error) { - if id == "" { - id = DefaultModelID - } - models := Catalog() - for _, m := range models { - if m.ID == id { - return m, nil - } - } - return Model{}, fmt.Errorf("unknown model %q; available: %s", id, strings.Join(modelIDsFrom(models), ", ")) +// providerRegistered reports whether p is among the configured providers. +func providerRegistered(registered []Provider, p Provider) bool { + return slices.Contains(registered, p) } // defaultModel picks the model used when a request omits one: the catalog -// default if its provider is configured, otherwise the first catalog model -// whose provider is configured. Returns false when no configured provider has a -// catalog model (caller fails loud). +// default if its provider is configured, otherwise the first API-backed catalog +// model whose provider is configured. Returns false when no configured provider +// has a catalog model (caller fails loud). func defaultModel(registered []Provider) (Model, bool) { models := Catalog() for _, m := range models { - if m.ID == DefaultModelID && providerRegistered(registered, m.Provider) { + if m.ID == DefaultModelID && providerRegistered(registered, modelProvider(m)) { return m, true } } for _, m := range models { - if m.Engine == EngineGenkit && providerRegistered(registered, m.Provider) { + if !m.IsAgent() && providerRegistered(registered, modelProvider(m)) { return m, true } } return Model{}, false } -// providerRegistered reports whether p is among the configured providers. -func providerRegistered(registered []Provider, p Provider) bool { - return slices.Contains(registered, p) -} - -// ProviderOf extracts the provider from a "provider/model" id without requiring -// the model to be in the catalog. -func ProviderOf(id string) Provider { - if i := strings.IndexByte(id, '/'); i > 0 { - return NormalizeProvider(id[:i]) - } - return "" -} - -// NormalizeProvider maps product/storage aliases onto the Genkit provider -// namespace used by model ids. -func NormalizeProvider(value string) Provider { - switch strings.ToLower(strings.TrimSpace(value)) { - case "anthropic", "claude": - return ProviderAnthropic - case "openai": - return ProviderOpenAI - case "google", "gemini", "googleai": - return ProviderGoogle - default: - return Provider(strings.TrimSpace(value)) - } -} - -func modelIDs() []string { - return modelIDsFrom(Catalog()) -} - -func modelIDsFrom(models []Model) []string { - ids := make([]string, len(models)) - for i, m := range models { - ids[i] = m.ID - } - sort.Strings(ids) - return ids -} - // ValidateEffort rejects unknown effort values (fail loud). func ValidateEffort(e Effort) error { switch e { diff --git a/aichat/models_test.go b/aichat/models_test.go index 3bca3486..831c0913 100644 --- a/aichat/models_test.go +++ b/aichat/models_test.go @@ -1,6 +1,10 @@ package aichat -import "testing" +import ( + "testing" + + capapi "github.com/flanksource/captain/pkg/api" +) func TestLookupModelDefault(t *testing.T) { m, err := LookupModel("") @@ -22,7 +26,7 @@ func TestRegisterModelAddsAndUpdatesCatalog(t *testing.T) { t.Cleanup(ResetModelCatalog) id := "openai/test-custom-model" - if err := RegisterModel(Model{ID: id, Label: "Custom", Reasoning: true, ContextWindow: 123}); err != nil { + if err := RegisterModel(Model{ID: id, Backend: capapi.BackendOpenAI, Label: "Custom", Reasoning: true, ContextWindow: 123}); err != nil { t.Fatalf("RegisterModel: %v", err) } @@ -30,14 +34,14 @@ func TestRegisterModelAddsAndUpdatesCatalog(t *testing.T) { if err != nil { t.Fatalf("LookupModel(%q): %v", id, err) } - if m.Provider != ProviderOpenAI { - t.Errorf("Provider = %q, want %q", m.Provider, ProviderOpenAI) + if modelProvider(m) != ProviderOpenAI { + t.Errorf("provider = %q, want %q", modelProvider(m), ProviderOpenAI) } if m.Label != "Custom" || !m.Reasoning || m.ContextWindow != 123 { t.Errorf("model = %+v, want registered values", m) } - if err := RegisterModel(Model{ID: id, Provider: ProviderOpenAI, Label: "Updated", ContextWindow: 456}); err != nil { + if err := RegisterModel(Model{ID: id, Backend: capapi.BackendOpenAI, Label: "Updated", ContextWindow: 456}); err != nil { t.Fatalf("RegisterModel update: %v", err) } m, err = LookupModel(id) @@ -52,7 +56,7 @@ func TestRegisterModelAddsAndUpdatesCatalog(t *testing.T) { func TestSetModelCatalogAndReset(t *testing.T) { t.Cleanup(ResetModelCatalog) - if err := SetModelCatalog([]Model{{ID: "openai/only-model", Label: "Only"}}); err != nil { + if err := SetModelCatalog([]Model{{ID: "openai/only-model", Backend: capapi.BackendOpenAI, Label: "Only"}}); err != nil { t.Fatalf("SetModelCatalog: %v", err) } if _, err := LookupModel("openai/gpt-5.5"); err == nil { @@ -62,7 +66,7 @@ func TestSetModelCatalogAndReset(t *testing.T) { if err != nil { t.Fatalf("LookupModel custom catalog: %v", err) } - if m.Provider != ProviderOpenAI || m.Label != "Only" { + if modelProvider(m) != ProviderOpenAI || m.Label != "Only" { t.Errorf("model = %+v", m) } @@ -76,7 +80,10 @@ func TestRegisterModelValidation(t *testing.T) { if err := RegisterModel(Model{}); err == nil { t.Error("expected missing ID to fail") } - if err := SetModelCatalog([]Model{{ID: "openai/dup"}, {ID: "openai/dup"}}); err == nil { + if err := SetModelCatalog([]Model{ + {ID: "openai/dup", Backend: capapi.BackendOpenAI}, + {ID: "openai/dup", Backend: capapi.BackendOpenAI}, + }); err == nil { t.Error("expected duplicate IDs to fail in SetModelCatalog") } } @@ -91,8 +98,8 @@ func TestAnthropicCatalogIncludesRequestedModels(t *testing.T) { if err != nil { t.Fatalf("LookupModel(%q): %v", id, err) } - if m.Provider != ProviderAnthropic { - t.Errorf("LookupModel(%q).Provider = %q, want %q", id, m.Provider, ProviderAnthropic) + if modelProvider(m) != ProviderAnthropic { + t.Errorf("LookupModel(%q) provider = %q, want %q", id, modelProvider(m), ProviderAnthropic) } } } @@ -107,11 +114,11 @@ func TestEffortConfigPerProvider(t *testing.T) { wantKey string wantNil bool }{ - {"openai-high", Model{ID: "openai/gpt-5.5", Provider: ProviderOpenAI}, EffortHigh, "reasoning_effort", false}, - {"gemini-medium", Model{ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle}, EffortMedium, "thinkingConfig", false}, - {"anthropic-low", Model{ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic}, EffortLow, "thinking", false}, - {"anthropic-no-effort-still-sets-max-tokens", Model{ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic}, EffortNone, "max_tokens", false}, - {"unknown-model-no-effort-config", Model{ID: "openai/gpt-4o-mini", Provider: ProviderOpenAI}, EffortHigh, "", true}, + {"openai-high", Model{ID: "openai/gpt-5.5", Backend: capapi.BackendOpenAI}, EffortHigh, "reasoning_effort", false}, + {"gemini-medium", Model{ID: "googleai/gemini-3.5-flash", Backend: capapi.BackendGemini}, EffortMedium, "thinkingConfig", false}, + {"anthropic-low", Model{ID: "anthropic/claude-sonnet-5", Backend: capapi.BackendAnthropic}, EffortLow, "thinking", false}, + {"anthropic-no-effort-still-sets-max-tokens", Model{ID: "anthropic/claude-sonnet-5", Backend: capapi.BackendAnthropic}, EffortNone, "max_tokens", false}, + {"unknown-model-no-effort-config", Model{ID: "openai/gpt-4o-mini", Backend: capapi.BackendOpenAI}, EffortHigh, "", true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -130,7 +137,7 @@ func TestEffortConfigPerProvider(t *testing.T) { } func TestAnthropicEffortConfigSetsMaxTokens(t *testing.T) { - cfg := effortConfig(Model{ID: "anthropic/claude-sonnet-4-6", Provider: ProviderAnthropic}, EffortHigh, ChatBudget{MaxTokens: 1000}, nil) + cfg := effortConfig(Model{ID: "anthropic/claude-sonnet-4-6", Backend: capapi.BackendAnthropic}, EffortHigh, ChatBudget{MaxTokens: 1000}, nil) if got := cfg["max_tokens"]; got != 24576+1000 { t.Errorf("max_tokens = %v, want thinking budget plus visible output budget", got) } @@ -140,7 +147,7 @@ func TestEffortConfigAppliesTemperatureAndMaxTokens(t *testing.T) { temp := 0.4 // gemini-3.5-flash supports temperature; the openai/anthropic adaptive models // do not, so temperature would be gated out for them. - cfg := effortConfig(Model{ID: "googleai/gemini-3.5-flash", Provider: ProviderGoogle}, EffortNone, ChatBudget{MaxTokens: 1200}, &temp) + cfg := effortConfig(Model{ID: "googleai/gemini-3.5-flash", Backend: capapi.BackendGemini}, EffortNone, ChatBudget{MaxTokens: 1200}, &temp) if got := cfg["temperature"]; got != temp { t.Errorf("temperature = %v, want %v", got, temp) } @@ -152,7 +159,7 @@ func TestEffortConfigAppliesTemperatureAndMaxTokens(t *testing.T) { func TestEffortConfigGatesTemperatureForIncapableModel(t *testing.T) { temp := 0.4 // claude-sonnet-5 uses adaptive thinking and does not accept temperature. - cfg := effortConfig(Model{ID: "anthropic/claude-sonnet-5", Provider: ProviderAnthropic}, EffortNone, ChatBudget{}, &temp) + cfg := effortConfig(Model{ID: "anthropic/claude-sonnet-5", Backend: capapi.BackendAnthropic}, EffortNone, ChatBudget{}, &temp) if _, ok := cfg["temperature"]; ok { t.Errorf("temperature should be gated out for claude-sonnet-5, got %v", cfg) } @@ -174,8 +181,8 @@ func TestDefaultModelFallsBackToConfiguredProvider(t *testing.T) { if !ok { t.Fatal("expected a Google model when only Google is configured") } - if m.Provider != ProviderGoogle { - t.Errorf("provider = %q, want %q", m.Provider, ProviderGoogle) + if modelProvider(m) != ProviderGoogle { + t.Errorf("provider = %q, want %q", modelProvider(m), ProviderGoogle) } } diff --git a/aichat/server.go b/aichat/server.go index 34a8828c..5cdbc74d 100644 --- a/aichat/server.go +++ b/aichat/server.go @@ -9,6 +9,9 @@ import ( "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" + capai "github.com/flanksource/captain/pkg/ai" + capapi "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/clicky" "github.com/spf13/cobra" ) @@ -139,12 +142,27 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } + infos, err := capai.LiveCatalogInfo(providerStrings(providers)) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(CatalogInfo(providers)); err != nil { + if err := json.NewEncoder(w).Encode(infos); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } +// providerStrings projects the configured Genkit providers to the string form +// captain's LiveCatalogInfo consumes for its per-request "configured" overlay. +func providerStrings(providers []Provider) []string { + out := make([]string, len(providers)) + for i, p := range providers { + out[i] = string(p) + } + return out +} + func (s *Server) availableProviders(ctx context.Context) ([]Provider, error) { if s.opts.ProviderCredentials != nil { creds, err := s.opts.ProviderCredentials(ctx) @@ -263,7 +281,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // Route agent-framework models to the captain agent engine before building // the Genkit runtime, which the agent path does not use. if id := modelIDForRequest(req, settings); id != "" { - if m, lookupErr := LookupModel(id); lookupErr == nil && m.Engine == EngineAgent { + if m, lookupErr := LookupModel(id); lookupErr == nil && m.IsAgent() { s.serveAgentChat(w, r, m, req) return } @@ -366,8 +384,8 @@ func (s *Server) resolveModel(id string, providers []Provider) (Model, error) { if err != nil { return Model{}, err } - if !providerRegistered(providers, m.Provider) { - return Model{}, fmt.Errorf("model %q requires the %q provider, which is not configured (set its API key)", id, m.Provider) + if provider := modelProvider(m); !providerRegistered(providers, provider) { + return Model{}, fmt.Errorf("model %q requires the %q provider, which is not configured (set its API key)", id, provider) } return m, nil } @@ -396,7 +414,13 @@ func (s *Server) stream(ctx context.Context, sse *sseWriter, rt chatRuntime, mod preferences: toolPrefs, defaultApproval: s.approval, }) - opts := generateOptions(model, effort, budget, temperature, s.system, msgs, toolsForRequest(rt.tools, toolPrefs), cb, resumeOptions(resume)...) + selectedTools := registeredToolsForRequest(rt.tools, toolPrefs) + opts := generateOptions(model, effort, budget, temperature, s.system, msgs, toolRefs(selectedTools), cb, resumeOptions(resume)...) + if model.Backend == capapi.BackendAnthropic { + if mw := anthropicStrictToolsMiddleware(selectedTools, clicky.Warnf); mw != nil { + opts = append(opts, ai.WithMiddleware(mw)) + } + } resp, err := genkit.Generate(ctx, rt.g, opts...) if err != nil { return err diff --git a/aichat/strict_tools.go b/aichat/strict_tools.go new file mode 100644 index 00000000..697ada54 --- /dev/null +++ b/aichat/strict_tools.go @@ -0,0 +1,103 @@ +package aichat + +import ( + "context" + "maps" + "sort" + "sync" + + "github.com/firebase/genkit/go/ai" +) + +const anthropicMaxStrictTools = 20 + +type warningFunc func(string, ...any) + +// anthropicStrictToolsMiddleware makes strict schemas opt-in for every enabled +// tool. Genkit's Anthropic adapter otherwise treats missing strict metadata as +// true. Explicit opt-ins remain strict up to Anthropic's 20-tool limit; every +// other tool is sent non-strict without being removed. +func anthropicStrictToolsMiddleware(tools []registeredTool, warnf warningFunc) ai.ModelMiddleware { + candidates := strictToolCandidates(tools) + if len(tools) == 0 { + return nil + } + + strictCount := min(len(candidates), anthropicMaxStrictTools) + strictNames := make(map[string]bool, strictCount) + for _, tool := range candidates[:strictCount] { + strictNames[tool.info.Name] = true + } + knownNames := make(map[string]bool, len(tools)) + for _, tool := range tools { + knownNames[tool.info.Name] = true + } + + var warningOnce sync.Once + return func(next ai.ModelFunc) ai.ModelFunc { + return func(ctx context.Context, req *ai.ModelRequest, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { + if len(candidates) > anthropicMaxStrictTools { + warningOnce.Do(func() { + if warnf != nil { + warnf("Anthropic supports at most %d strict tools; keeping %d of %d explicit opt-ins strict and sending %d as non-strict", anthropicMaxStrictTools, anthropicMaxStrictTools, len(candidates), len(candidates)-anthropicMaxStrictTools) + } + }) + } + + clonedReq := *req + clonedReq.Tools = make([]*ai.ToolDefinition, len(req.Tools)) + for i, tool := range req.Tools { + if tool == nil || !knownNames[tool.Name] { + clonedReq.Tools[i] = tool + continue + } + clonedTool := *tool + clonedTool.Metadata = maps.Clone(tool.Metadata) + if clonedTool.Metadata == nil { + clonedTool.Metadata = map[string]any{} + } + clonedTool.Metadata["strict"] = strictNames[tool.Name] + clonedReq.Tools[i] = &clonedTool + } + return next(ctx, &clonedReq, cb) + } + } +} + +func strictToolCandidates(tools []registeredTool) []registeredTool { + candidates := make([]registeredTool, 0, len(tools)) + for _, tool := range tools { + if explicitlyStrict(tool.info) { + candidates = append(candidates, tool) + } + } + sort.SliceStable(candidates, func(i, j int) bool { + left, right := candidates[i].info, candidates[j].info + if leftRisk, rightRisk := strictRiskRank(left), strictRiskRank(right); leftRisk != rightRisk { + return leftRisk < rightRisk + } + return left.Name < right.Name + }) + return candidates +} + +func explicitlyStrict(tool ToolInfo) bool { + return tool.Strict != nil && *tool.Strict +} + +// Lower ranks retain strict validation first: destructive, non-idempotent, +// mutating, unknown, then confirmed read-only tools. +func strictRiskRank(tool ToolInfo) int { + switch { + case tool.DestructiveHint != nil && *tool.DestructiveHint: + return 0 + case tool.IdempotentHint != nil && !*tool.IdempotentHint: + return 1 + case tool.ReadOnlyHint != nil && !*tool.ReadOnlyHint: + return 2 + case tool.ReadOnlyHint == nil: + return 3 + default: + return 4 + } +} diff --git a/aichat/strict_tools_test.go b/aichat/strict_tools_test.go new file mode 100644 index 00000000..e80e922d --- /dev/null +++ b/aichat/strict_tools_test.go @@ -0,0 +1,149 @@ +package aichat + +import ( + "context" + "fmt" + "testing" + + "github.com/firebase/genkit/go/ai" +) + +func boolPointer(v bool) *bool { return &v } + +type strictTestTool string + +func (t strictTestTool) Name() string { return string(t) } + +func TestStrictToolCandidatesIncludeOnlyExplicitOptIns(t *testing.T) { + tools := []registeredTool{ + {info: ToolInfo{Name: "readonly", ReadOnlyHint: boolPointer(true)}}, + {info: ToolInfo{Name: "unknown"}}, + {info: ToolInfo{Name: "mutating", ReadOnlyHint: boolPointer(false)}}, + {info: ToolInfo{Name: "non_idempotent", IdempotentHint: boolPointer(false)}}, + {info: ToolInfo{Name: "destructive", DestructiveHint: boolPointer(true)}}, + {info: ToolInfo{Name: "explicit", Strict: boolPointer(true), ReadOnlyHint: boolPointer(true)}}, + {info: ToolInfo{Name: "loose", Strict: boolPointer(false), DestructiveHint: boolPointer(true)}}, + } + + candidates := strictToolCandidates(tools) + want := []string{"explicit"} + if len(candidates) != len(want) { + t.Fatalf("candidates = %d, want %d", len(candidates), len(want)) + } + for i, name := range want { + if candidates[i].info.Name != name { + t.Fatalf("candidate[%d] = %q, want %q", i, candidates[i].info.Name, name) + } + } +} + +func TestAnthropicStrictToolsMiddlewareCapsWithoutRemovingTools(t *testing.T) { + const toolCount = 54 + tools := make([]registeredTool, 0, toolCount) + defs := make([]*ai.ToolDefinition, 0, toolCount) + for i := range toolCount { + name := fmt.Sprintf("readonly_%02d", i) + info := ToolInfo{Name: name, Strict: boolPointer(true), ReadOnlyHint: boolPointer(true)} + if i == toolCount-1 { + name = "destructive" + info = ToolInfo{Name: name, Strict: boolPointer(true), DestructiveHint: boolPointer(true)} + } + tools = append(tools, registeredTool{info: info}) + defs = append(defs, &ai.ToolDefinition{Name: name}) + } + + warnings := 0 + mw := anthropicStrictToolsMiddleware(tools, func(string, ...any) { warnings++ }) + if mw == nil { + t.Fatal("middleware = nil, want strict-tool limiter") + } + var got *ai.ModelRequest + next := func(_ context.Context, req *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { + got = req + return &ai.ModelResponse{}, nil + } + wrapped := mw(next) + req := &ai.ModelRequest{Tools: defs} + if _, err := wrapped(context.Background(), req, nil); err != nil { + t.Fatalf("middleware: %v", err) + } + if _, err := wrapped(context.Background(), req, nil); err != nil { + t.Fatalf("middleware second call: %v", err) + } + + if warnings != 1 { + t.Fatalf("warnings = %d, want 1", warnings) + } + if len(got.Tools) != len(defs) { + t.Fatalf("tools = %d, want all %d tools retained", len(got.Tools), len(defs)) + } + strict := 0 + for _, def := range got.Tools { + if value, _ := def.Metadata["strict"].(bool); value { + strict++ + } + } + if strict != anthropicMaxStrictTools { + t.Fatalf("strict tools = %d, want %d", strict, anthropicMaxStrictTools) + } + for _, def := range got.Tools { + if def.Name == "destructive" { + if value, _ := def.Metadata["strict"].(bool); !value { + t.Fatal("destructive tool was demoted behind read-only tools") + } + } + } + if defs[0].Metadata != nil { + t.Fatal("middleware mutated the shared/original tool definition") + } +} + +func TestAnthropicStrictToolsMiddlewareDefaultsEveryToolToNonStrict(t *testing.T) { + tools := []registeredTool{ + {info: ToolInfo{Name: "unset"}}, + {info: ToolInfo{Name: "explicit_false", Strict: boolPointer(false)}}, + {info: ToolInfo{Name: "explicit_true", Strict: boolPointer(true)}}, + } + defs := []*ai.ToolDefinition{{Name: "unset"}, {Name: "explicit_false"}, {Name: "explicit_true"}} + + mw := anthropicStrictToolsMiddleware(tools, nil) + if mw == nil { + t.Fatal("middleware = nil, want strict opt-in metadata middleware") + } + var got *ai.ModelRequest + next := func(_ context.Context, req *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { + got = req + return &ai.ModelResponse{}, nil + } + if _, err := mw(next)(context.Background(), &ai.ModelRequest{Tools: defs}, nil); err != nil { + t.Fatalf("middleware: %v", err) + } + + for _, def := range got.Tools { + strict, ok := def.Metadata["strict"].(bool) + if !ok { + t.Fatalf("tool %q strict metadata = %#v, want bool", def.Name, def.Metadata["strict"]) + } + if strict != (def.Name == "explicit_true") { + t.Fatalf("tool %q strict = %v, want opt-in behavior", def.Name, strict) + } + } + if defs[0].Metadata != nil { + t.Fatal("middleware mutated the shared/original tool definition") + } +} + +func TestAnthropicStrictToolsMiddlewareCountsAfterPreferences(t *testing.T) { + tools := make([]registeredTool, 0, 21) + for i := range 21 { + name := fmt.Sprintf("tool_%02d", i) + tools = append(tools, registeredTool{ref: strictTestTool(name), info: ToolInfo{Name: name, Strict: boolPointer(true)}}) + } + selected := registeredToolsForRequest(tools, ToolPreferences{"tool_20": ToolModeOff}) + if len(selected) != anthropicMaxStrictTools { + t.Fatalf("selected tools = %d, want %d", len(selected), anthropicMaxStrictTools) + } + if candidates := strictToolCandidates(selected); len(candidates) != anthropicMaxStrictTools { + t.Fatalf("strict candidates = %d, want %d after preferences", len(candidates), anthropicMaxStrictTools) + } +} diff --git a/aichat/tool_registry.go b/aichat/tool_registry.go index 1b7b2fe6..c3be795f 100644 --- a/aichat/tool_registry.go +++ b/aichat/tool_registry.go @@ -36,6 +36,9 @@ type ToolDefinition struct { Icon string DefaultPermission ToolMode Strict *bool + ReadOnlyHint *bool + DestructiveHint *bool + IdempotentHint *bool // Group, when set, places this custom tool in a tool-group so the // preferences UI presents it under the group rather than individually. Group string @@ -76,6 +79,13 @@ func DefineCustomTools(g *genkit.Genkit, defs []ToolDefinition) ([]registeredToo Icon: def.Icon, DefaultPermission: defaultPermissionMode(def.DefaultPermission), Strict: def.Strict, + ReadOnlyHint: def.ReadOnlyHint, + DestructiveHint: def.DestructiveHint, + IdempotentHint: def.IdempotentHint, + } + toolOpts := []ai.ToolOption{ + ai.WithInputSchema(schema), + ai.WithStrictSchema(def.Strict != nil && *def.Strict), } tool := genkit.DefineTool[any, any](g, name, def.Description, func(tc *ai.ToolContext, input any) (any, error) { @@ -84,7 +94,7 @@ func DefineCustomTools(g *genkit.Genkit, defs []ToolDefinition) ([]registeredToo } return def.Handler(tc.Context, input) }, - ai.WithInputSchema(schema), + toolOpts..., ) catalog := customCatalogEntry(def, name, schema) out = append(out, registeredTool{ref: tool, info: info, catalog: &catalog}) @@ -113,10 +123,14 @@ func toolRefs(tools []registeredTool) []ai.ToolRef { } func toolsForRequest(tools []registeredTool, prefs ToolPreferences) []ai.ToolRef { + return toolRefs(registeredToolsForRequest(tools, prefs)) +} + +func registeredToolsForRequest(tools []registeredTool, prefs ToolPreferences) []registeredTool { if len(tools) == 0 { return nil } - refs := make([]ai.ToolRef, 0, len(tools)) + selected := make([]registeredTool, 0, len(tools)) for _, tool := range tools { mode, ok := effectivePreference(prefs, tool.info) if !ok { @@ -125,9 +139,9 @@ func toolsForRequest(tools []registeredTool, prefs ToolPreferences) []ai.ToolRef if mode == ToolModeOff { continue } - refs = append(refs, tool.ref) + selected = append(selected, tool) } - return refs + return selected } func normalizedPreference(prefs ToolPreferences, name string) (ToolMode, bool) { diff --git a/aichat/tool_registry_test.go b/aichat/tool_registry_test.go index c00e488e..a8a2c2b9 100644 --- a/aichat/tool_registry_test.go +++ b/aichat/tool_registry_test.go @@ -87,6 +87,48 @@ func TestHandleToolsServesCustomToolCatalog(t *testing.T) { } } +func TestDefineCustomToolsPropagatesExplicitStrictness(t *testing.T) { + strict := false + tools, err := DefineCustomTools(genkit.Init(context.Background()), []ToolDefinition{{ + Name: "loose_tool", + Strict: &strict, + Handler: func(context.Context, any) (any, error) { + return map[string]any{"ok": true}, nil + }, + }}) + if err != nil { + t.Fatalf("DefineCustomTools: %v", err) + } + withDefinition, ok := tools[0].ref.(toolWithDefinition) + if !ok { + t.Fatalf("tool ref %T does not expose its Genkit definition", tools[0].ref) + } + definition := withDefinition.Definition() + if got, ok := definition.Metadata["strict"].(bool); !ok || got { + t.Fatalf("Genkit strict metadata = %v, want false", definition.Metadata["strict"]) + } +} + +func TestDefineCustomToolsDefaultsToNonStrict(t *testing.T) { + tools, err := DefineCustomTools(genkit.Init(context.Background()), []ToolDefinition{{ + Name: "default_tool", + Handler: func(context.Context, any) (any, error) { + return map[string]any{"ok": true}, nil + }, + }}) + if err != nil { + t.Fatalf("DefineCustomTools: %v", err) + } + withDefinition, ok := tools[0].ref.(toolWithDefinition) + if !ok { + t.Fatalf("tool ref %T does not expose its Genkit definition", tools[0].ref) + } + definition := withDefinition.Definition() + if got, ok := definition.Metadata["strict"].(bool); !ok || got { + t.Fatalf("Genkit strict metadata = %v, want default false", definition.Metadata["strict"]) + } +} + func TestCustomToolHonorsAskPreference(t *testing.T) { cfg := toolRuntimeConfig{ preferences: ToolPreferences{"xero_formula_patch": ToolModeAsk}, diff --git a/aichat/tools_clicky.go b/aichat/tools_clicky.go index 93cab56a..8c6dfb8d 100644 --- a/aichat/tools_clicky.go +++ b/aichat/tools_clicky.go @@ -62,12 +62,17 @@ func (t *ClickyToolset) DefineRegisteredTools(g *genkit.Genkit) []registeredTool } seen[name] = true schema := jsonSchema(op.Schema) + info := toolInfo(name, op) + toolOpts := []ai.ToolOption{ + ai.WithInputSchema(schema), + ai.WithStrictSchema(info.Strict != nil && *info.Strict), + } tool := genkit.DefineTool[any, any](g, name, op.Description, t.handlerFor(op), - ai.WithInputSchema(schema), + toolOpts..., ) catalog := clickyCatalogEntry(name, op, schema) - refs = append(refs, registeredTool{ref: tool, info: toolInfo(name, op), catalog: &catalog}) + refs = append(refs, registeredTool{ref: tool, info: info, catalog: &catalog}) } return refs } diff --git a/aichat/tools_clicky_test.go b/aichat/tools_clicky_test.go index 52c05597..8f8ff435 100644 --- a/aichat/tools_clicky_test.go +++ b/aichat/tools_clicky_test.go @@ -90,6 +90,13 @@ func TestClickyToolCatalogPreservesInputSchema(t *testing.T) { if name["type"] != "string" || name["description"] == "" { t.Fatalf("name schema = %v", name) } + withDefinition, ok := tools[0].ref.(toolWithDefinition) + if !ok { + t.Fatalf("tool ref %T does not expose its Genkit definition", tools[0].ref) + } + if strict, ok := withDefinition.Definition().Metadata["strict"].(bool); !ok || strict { + t.Fatalf("Genkit strict metadata = %v, want default false", withDefinition.Definition().Metadata["strict"]) + } } func TestJSONSchemaConversion(t *testing.T) { diff --git a/aichat/tools_mcp.go b/aichat/tools_mcp.go index 9e4b744a..260c3b25 100644 --- a/aichat/tools_mcp.go +++ b/aichat/tools_mcp.go @@ -49,7 +49,7 @@ func MCPRegisteredTools(ctx context.Context, g *genkit.Genkit, servers []MCPServ catalog := mcpCatalogEntry(t) refs[i] = registeredTool{ ref: t, - info: ToolInfo{Name: t.Name()}, + info: ToolInfo{Name: t.Name(), Strict: catalog.Strict}, catalog: &catalog, } } diff --git a/mcp/registry.go b/mcp/registry.go index e030fb56..fc34a8fd 100644 --- a/mcp/registry.go +++ b/mcp/registry.go @@ -93,7 +93,7 @@ func NewMcpTool(rpcOp *rpc.RPCOperation) *ToolDefinition { const clickyToolMetaKey = "com.flanksource.clicky/tool" func toolAnnotations(rpcOp *rpc.RPCOperation) *ToolAnnotations { - hints := operationToolHints(rpcOp) + hints := EffectiveToolHints(rpcOp) annotations := &ToolAnnotations{ Title: hints.Title, ReadOnlyHint: hints.ReadOnlyHint, @@ -102,17 +102,6 @@ func toolAnnotations(rpcOp *rpc.RPCOperation) *ToolAnnotations { OpenWorldHint: hints.OpenWorldHint, } - readOnly, destructive, idempotent := inferredToolSemantics(rpcOp) - if annotations.ReadOnlyHint == nil { - annotations.ReadOnlyHint = readOnly - } - if annotations.DestructiveHint == nil { - annotations.DestructiveHint = destructive - } - if annotations.IdempotentHint == nil { - annotations.IdempotentHint = idempotent - } - if annotations.Title == "" && annotations.ReadOnlyHint == nil && annotations.DestructiveHint == nil && @@ -152,7 +141,7 @@ func inferredToolSemantics(rpcOp *rpc.RPCOperation) (readOnly *bool, destructive } func clickyToolMeta(rpcOp *rpc.RPCOperation) map[string]any { - hints := operationToolHints(rpcOp) + hints := EffectiveToolHints(rpcOp) meta := map[string]any{} if hints.Icon != "" { meta["icon"] = hints.Icon @@ -175,7 +164,11 @@ func clickyToolMeta(rpcOp *rpc.RPCOperation) map[string]any { return map[string]any{clickyToolMetaKey: meta} } -func operationToolHints(rpcOp *rpc.RPCOperation) entity.MCPToolHints { +// EffectiveToolHints returns the explicit MCP/Clicky hints for an operation, +// filling in missing safety semantics from its HTTP method and Clicky verb. +// Keeping this inference shared ensures MCP exposure and in-process AI chat rank +// tools from the same read-only/destructive/idempotent signals. +func EffectiveToolHints(rpcOp *rpc.RPCOperation) entity.MCPToolHints { if rpcOp == nil { return entity.MCPToolHints{} } @@ -219,6 +212,16 @@ func operationToolHints(rpcOp *rpc.RPCOperation) entity.MCPToolHints { if hints.Group == "" { hints.Group = rpcOp.Group } + readOnly, destructive, idempotent := inferredToolSemantics(rpcOp) + if hints.ReadOnlyHint == nil { + hints.ReadOnlyHint = readOnly + } + if hints.DestructiveHint == nil { + hints.DestructiveHint = destructive + } + if hints.IdempotentHint == nil { + hints.IdempotentHint = idempotent + } return hints } From e9d971c82178ac91def1e8292426c3b82bc19352 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 19:17:33 +0300 Subject: [PATCH 18/23] feat(rpc): expose swagger command executor --- rpc/serve.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rpc/serve.go b/rpc/serve.go index 10e75a1a..5c0052a1 100644 --- a/rpc/serve.go +++ b/rpc/serve.go @@ -95,6 +95,15 @@ func NewSwaggerServer(config *ServeConfig, rootCmd *cobra.Command, openAPIConfig return server } +// Executor returns the server's command executor, or nil when execution is not +// enabled. Callers can use it to invoke operations programmatically — resolve an +// operation with FindOperation(method, path) and run it via ExecuteCommand — so a +// stored operation descriptor can be replayed through the same code path the HTTP +// executor routes use. +func (s *SwaggerServer) Executor() *CommandExecutor { + return s.executor +} + // Start starts the HTTP server // RegisterRoutes registers all API routes onto the provided mux. // This allows callers to compose the SwaggerServer routes with other handlers. From 4de28a47f0b6a7c75a8e33e8a83e695fea349676 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 12 Jul 2026 19:17:37 +0300 Subject: [PATCH 19/23] docs(skills): add clicky development guides --- .gitignore | 1 + skills/clicky-tasks/SKILL.md | 115 +++++++++++++ skills/clicky-tasks/agents/openai.yaml | 4 + .../references/refactoring-patterns.md | 160 ++++++++++++++++++ skills/pretty-printing/SKILL.md | 130 ++++++++++++++ skills/pretty-printing/agents/openai.yaml | 4 + .../references/api-patterns.md | 140 +++++++++++++++ 7 files changed, 554 insertions(+) create mode 100644 skills/clicky-tasks/SKILL.md create mode 100644 skills/clicky-tasks/agents/openai.yaml create mode 100644 skills/clicky-tasks/references/refactoring-patterns.md create mode 100644 skills/pretty-printing/SKILL.md create mode 100644 skills/pretty-printing/agents/openai.yaml create mode 100644 skills/pretty-printing/references/api-patterns.md diff --git a/.gitignore b/.gitignore index dff4258c..d7389591 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ clicky !examples/enitity/webapp/.oxlintrc.json !examples/enitity/webapp/package.json !examples/enitity/webapp/pnpm-lock.yaml +!skills/*/agents/openai.yaml !examples/enitity/webapp/pnpm-workspace.yaml !examples/enitity/webapp/tsconfig.json !examples/enitity/webapp/dist/ diff --git a/skills/clicky-tasks/SKILL.md b/skills/clicky-tasks/SKILL.md new file mode 100644 index 00000000..d1636618 --- /dev/null +++ b/skills/clicky-tasks/SKILL.md @@ -0,0 +1,115 @@ +--- +name: clicky-tasks +description: Refactor Go work and concurrency to Clicky's task APIs, including StartTask, StartGroup, typed results, dependencies, retries, timeouts, cancellation, and task-safe logging. Use when replacing goroutines, sync.WaitGroup, ad hoc output, or manual result collection with Clicky task execution. +--- + +# Clicky Tasks + +Replace ad hoc goroutine orchestration with Clicky's tracked task and group APIs while preserving concurrency, result ordering, cancellation, and error semantics. + +## Workflow + +1. Inspect the complete concurrency boundary: goroutine creation, `WaitGroup`, channels, shared result writes, cancellation, error aggregation, and logging. +2. Decide whether the work is one task or a typed group: + - Use `clicky.StartTask[T]` for one tracked operation. + - Use `clicky.StartGroup[T]` when multiple related operations should run concurrently. +3. Preserve observable behavior explicitly. Record whether callers require input order, all errors, partial results, bounded concurrency, or fail-fast behavior. +4. Convert output inside task callbacks to task logging. Return errors instead of calling `Fatalf`, panicking, or exiting. +5. Wait at the owning boundary with `GetResult`, `GetResults`, `WaitFor`, or `clicky.WaitForGlobalCompletion`, depending on whether typed values or only final status are needed. +6. Run focused tests under the race detector when shared state or cancellation behavior changed. + +## Imports and callback contract + +Prefer the top-level Clicky entrypoints. Import the task package for task types and options: + +```go +import ( + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/task" + flanksourceContext "github.com/flanksource/commons/context" +) +``` + +Task callbacks receive `flanksource/commons/context.Context`, not the standard-library `context.Context` type: + +```go +run := clicky.StartTask("Loading configuration", func( + ctx flanksourceContext.Context, + t *task.Task, +) (Config, error) { + t.Debugf("Reading %s", path) + config, err := loadConfig(ctx, path) + if err != nil { + t.Errorf("Loading configuration failed: %v", err) + return Config{}, err + } + return config, nil +}) + +config, err := run.GetResult() +``` + +Supported task logging methods are `Debugf`, `Infof`, `Warnf`, and `Errorf`. Do not invent `Tracef`. Use Clicky's global logging helpers outside a task callback when the interactive renderer may own the terminal. + +## Typed groups + +```go +group := clicky.StartGroup[Result]( + "Processing files", + task.WithConcurrency(4), +) + +handles := make([]task.TypedTask[Result], 0, len(files)) +for _, file := range files { + file := file + handles = append(handles, group.Add( + fmt.Sprintf("Processing %s", file), + func(ctx flanksourceContext.Context, t *task.Task) (Result, error) { + t.Infof("Processing %s", file) + return processFile(ctx, file) + }, + )) +} + +if wait := group.WaitFor(); wait.Error != nil { + return nil, wait.Error +} + +results := make([]Result, 0, len(handles)) +for _, handle := range handles { + result, err := handle.GetResult() + if err != nil { + return nil, err + } + results = append(results, result) +} +``` + +Capture the loop value even on Go versions that provide per-iteration range variables; it keeps the task closure's intent explicit across supported modules. + +`TypedGroup.GetResults()` returns `map[task.TypedTask[T]]T`. Map iteration is unordered, and the method returns on the first task error. Retain task handles in input order when result order matters, and preserve a custom aggregation strategy when callers need every error or partial results. + +## Options and completion + +- Use `task.WithConcurrency` when a group must bound parallelism. +- Use `clicky.WithTimeout`, `clicky.WithTaskTimeout`, `clicky.WithDependencies`, and `clicky.WithRetryConfig` for individual tasks. +- Use `task.DefaultRetryConfig()` as a starting point only after confirming retry safety and idempotency. +- Use the callback context for downstream calls and cancellation checks. +- Use `GetResult()` when the typed value matters; it waits for completion. +- Use `WaitFor()` only when completion and summary status matter. The current group implementation does not populate per-status count fields and returns an error before setting status or duration when a child fails. +- Use `clicky.WaitForGlobalCompletion()` at the CLI ownership boundary when the process exit code should reflect all global tasks. + +## Guardrails + +- Do not mechanically replace a `WaitGroup` until result ordering and error behavior are understood. +- Do not collapse an indexed partial-result contract into a compact success-only slice; keep a fixed-length result slice keyed by handle position. +- Do not mix a task group with a second `WaitGroup` for the same work. +- Do not write concurrently to an existing slice or map just because tasks now provide tracking; return typed values or keep synchronization explicit. +- Do not call `fmt.Print`, `log.Printf`, or `os.Stdout` while the live renderer owns the terminal. +- Do not convert `Fatalf` to `t.Errorf` without also returning the error. +- Do not add retries to non-idempotent operations without an explicit safety contract. +- Do not discard cancellation by replacing the callback context with `context.Background()`. + +## Detailed reference + +Read [references/refactoring-patterns.md](references/refactoring-patterns.md) before converting ordered results, channels, nested groups, dependencies, timeouts, retries, or multi-error flows. diff --git a/skills/clicky-tasks/agents/openai.yaml b/skills/clicky-tasks/agents/openai.yaml new file mode 100644 index 00000000..59a75ae1 --- /dev/null +++ b/skills/clicky-tasks/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Clicky Tasks" + short_description: "Refactor Go concurrency into Clicky tasks" + default_prompt: "Use $clicky-tasks to refactor this Go concurrency flow into Clicky tasks." diff --git a/skills/clicky-tasks/references/refactoring-patterns.md b/skills/clicky-tasks/references/refactoring-patterns.md new file mode 100644 index 00000000..75d1367f --- /dev/null +++ b/skills/clicky-tasks/references/refactoring-patterns.md @@ -0,0 +1,160 @@ +# Clicky Task Refactoring Patterns + +## Contents + +- [Current contracts](#current-contracts) +- [WaitGroup without results](#waitgroup-without-results) +- [Ordered results](#ordered-results) +- [Error behavior](#error-behavior) +- [Dependencies and nested work](#dependencies-and-nested-work) +- [Timeouts, retries, and cancellation](#timeouts-retries-and-cancellation) +- [Logging and renderer ownership](#logging-and-renderer-ownership) +- [Validation](#validation) + +## Current contracts + +The current implementation lives in `global_task_api.go` and `task/`. + +```go +func clicky.StartTask[T any]( + name string, + taskFunc task.TaskFunc[T], + opts ...clicky.TaskOption, +) task.TypedTask[T] + +func clicky.StartGroup[T any]( + name string, + opts ...task.TaskGroupOption, +) task.TypedGroup[T] +``` + +`task.TaskFunc[T]` receives `flanksource/commons/context.Context` plus `*task.Task` and returns `(T, error)`. `task.TypedTask[T].GetResult()` waits and returns the typed value. `task.TypedGroup[T].GetResults()` returns a task-keyed map and stops on the first error. + +`WaitFor()` returns a `*task.WaitResult` type with status, error, duration, and count fields. In the current group implementation, count fields are not populated, and a child error is returned before status and duration are set. Do not build caller behavior around those group fields without first changing and testing the implementation. + +## WaitGroup without results + +Before: + +```go +var wg sync.WaitGroup +for _, item := range items { + wg.Add(1) + go func(item Item) { + defer wg.Done() + process(item) + }(item) +} +wg.Wait() +``` + +After: + +```go +group := clicky.StartGroup[struct{}]("Processing items") +for _, item := range items { + item := item + group.Add(item.TaskName(), func( + ctx flanksourceContext.Context, + t *task.Task, + ) (struct{}, error) { + err := process(ctx, item) + return struct{}{}, err + }) +} + +if wait := group.WaitFor(); wait.Error != nil { + return wait.Error +} +``` + +Use `struct{}` rather than `interface{}` when no value is returned; it states that result data is intentionally absent. + +## Ordered results + +A `WaitGroup` that writes into `results[index]` preserves input order. `GetResults()` does not, because it returns a map. Preserve handles in input order: + +```go +handles := make([]task.TypedTask[Result], 0, len(items)) +for _, item := range items { + item := item + handles = append(handles, group.Add(item.Name, func( + ctx flanksourceContext.Context, + t *task.Task, + ) (Result, error) { + return process(ctx, item) + })) +} + +results := make([]Result, 0, len(handles)) +for _, handle := range handles { + result, err := handle.GetResult() + if err != nil { + return nil, err + } + results = append(results, result) +} +``` + +Do not rebuild an ordered slice by ranging over the map returned from `GetResults()`. + +## Error behavior + +Record the original contract before refactoring: + +- Fail fast: return the first task error through `GetResult`, `GetResults`, or `WaitFor`. +- Collect every error: wait for every retained handle, collect errors explicitly, and join them after all tasks finish. +- Return compact partial results: append successful handle results and return them with an aggregate error only when positional correspondence is not part of the contract. +- Return positional partial results: allocate the original length, assign each handle result at its input index, and aggregate every error after all handles complete. `TypedTask.GetResult()` can return a non-zero result together with an error; decide whether that partial value or the zero value belongs in a failed position. +- Best effort: log task failures, retain success/failure status, and return a policy-specific result. + +Task tracking does not decide which policy is correct. Preserve the caller's existing contract. + +Inside callbacks, log context and return the same error: + +```go +if err != nil { + t.Errorf("Fetching %s failed: %v", id, err) + return Result{}, fmt.Errorf("fetching %s: %w", id, err) +} +``` + +## Dependencies and nested work + +Use `clicky.WithDependencies(prerequisite.GetTask())` when a task must not start before another task completes. Keep the dependency graph acyclic and return prerequisite failures rather than silently running downstream work. + +Use nested groups only when the hierarchy is meaningful to users or concurrency must be bounded separately. Avoid creating a group for every function call; one task should correspond to a useful operation/status boundary. + +## Timeouts, retries, and cancellation + +Use task options at creation: + +```go +run := clicky.StartTask( + "Fetching inventory", + fetchInventory, + clicky.WithTimeout(2*time.Minute), + clicky.WithTaskTimeout(30*time.Second), + clicky.WithRetryConfig(task.DefaultRetryConfig()), +) +``` + +Confirm the distinction between the task's overall timeout and its per-attempt timeout in `task/options.go`. Only retry operations that are idempotent or carry an idempotency key. + +Pass the callback context to I/O calls. If a downstream API accepts only `context.Context`, use the callback value directly where its interface is accepted; do not replace it with a background context. + +## Logging and renderer ownership + +Within a callback, use `t.Debugf`, `t.Infof`, `t.Warnf`, and `t.Errorf`. These attach messages to the task and cooperate with the renderer. Outside callbacks, use Clicky's global logging path rather than direct standard output while tasks are active. + +Avoid duplicate reporting: returning an error records task failure, so log only when the message adds operation-specific context. + +## Validation + +1. Run the focused package tests for the changed callers. +2. Run those tests with `-race` when shared state, cancellation, or completion ordering changed. +3. Assert maximum concurrency when introducing `WithConcurrency`. +4. Assert input ordering when replacing indexed result writes. +5. Assert failure, cancellation, timeout, and retry exhaustion paths. +6. Verify task names are stable, descriptive actions and do not contain secrets. +7. Exercise the CLI's final wait path so task output is flushed before process exit. diff --git a/skills/pretty-printing/SKILL.md b/skills/pretty-printing/SKILL.md new file mode 100644 index 00000000..df71f12d --- /dev/null +++ b/skills/pretty-printing/SKILL.md @@ -0,0 +1,130 @@ +--- +name: pretty-printing +description: Implement and review Clicky render interfaces and builders in Go, including Pretty, PrettyFull, PrettyShort, TreeNode, TableProvider, icons, code blocks, collapsed sections, and humanized values. Use when adding or correcting rich terminal, HTML, Markdown, tree, or table output in Clicky or a consuming Go repository. +--- + +# Pretty Printing with Clicky + +Build structured output and return Clicky values instead of rendering ANSI, HTML, or Markdown inside domain code. + +## Workflow + +1. Inspect the Clicky version used by the target module before choosing helpers or interfaces. In this repository, treat `api/`, `format.go`, and `aliases.go` as the source of truth. +2. Choose the narrowest render interface that matches the presentation: + - `Pretty() api.Text` for a normal representation. + - `PrettyFull() api.Textable` for expanded detail. + - `PrettyShort() api.Textable` for compact table-cell links or labels. + - `api.TableProvider` for table rows. + - `api.TreeNode` for hierarchy. +3. Prefer top-level helpers such as `clicky.Text`, `clicky.CodeBlock`, `clicky.Collapsed`, `clicky.Map`, `clicky.List`, and `clicky.Human` when they express the intent. Use `api.Text{}.Append(...)` for incremental composition. +4. Keep format selection at the output boundary. Return `api.Text` or `api.Textable`; do not call `.ANSI()`, `.HTML()`, `.Markdown()`, or `.String()` inside render builders. +5. Run `clicky lint` in consumer repositories when available, then exercise the affected output formats. + +## Core patterns + +```go +import ( + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" +) + +func (r Result) Pretty() api.Text { + text := clicky.Text(r.Name, "font-bold") + if r.Err != nil { + return text.Space().Add(icons.Error).Space().Append("Failed", "error") + } + return text.Space().Add(icons.Success).Space().Append("Passed", "success") +} +``` + +Use `.Space()` for layout, and add icons as values so their style is preserved: + +```go +t := api.Text{}. + Add(icons.Success). + Space(). + Append("Running", "success") +``` + +Use root helpers for rich blocks: + +```go +func (r Result) PrettyFull() api.Textable { + return api.Text{}. + Add(r.Pretty()). + NewLine(). + Add(clicky.Collapsed( + "Response", + clicky.CodeBlock("application/json", r.Body), + )) +} +``` + +Use human formatting instead of hand-written duration, time, number, or byte formatting: + +```go +t := api.Text{}. + Append(clicky.Human(elapsed), "muted"). + Space(). + Add(api.HumanizeBytes(size).Styles("muted")) +``` + +## Tables + +Implement both methods on the row type. Return Clicky values in `Row()` when a cell needs rich rendering. + +```go +func (r Result) Columns() []api.ColumnDef { + return []api.ColumnDef{ + clicky.Column("name").Label("Name").Build(), + clicky.Column("status").Label("Status").Build(), + clicky.Column("duration").Label("Duration").Build(), + } +} + +func (r Result) Row() map[string]any { + return map[string]any{ + "name": r.Name, + "status": r.StatusText(), + "duration": clicky.Human(r.Duration, "muted"), + } +} + +table := api.NewTableFrom(results) +``` + +Add `RowDetail() api.Textable` only when rows need expandable detail. Return `nil` when no detail exists. + +## Trees + +Render only the current node in `Pretty()` and expose hierarchy through `GetChildren()`: + +```go +func (n Node) Pretty() api.Text { + return clicky.Text(n.Name) +} + +func (n Node) GetChildren() []api.TreeNode { + children := make([]api.TreeNode, 0, len(n.Children)) + for _, child := range n.Children { + children = append(children, child) + } + return children +} +``` + +Return `nil` from `GetChildren()` for leaves. Do not recursively render children inside `Pretty()`. + +## Guardrails + +- Do not hardcode ANSI escapes or HTML. +- Do not convert icons to strings before adding them. +- When `clicky lint` applies, name concrete `api.Text` returners as recognized `Pretty*` methods; use `api.Textable` for other render helpers. This is a repository lint contract, not a Go type-system restriction. +- Do not use direct `api.Text{Content: ...}` or child-slice literals when a helper or append chain exists. +- Do not assume terminal-only behavior; verify Markdown or HTML when the changed primitive has format-specific behavior. +- Preserve stable table column keys and unique tree identity separately from human-readable labels. + +## Detailed reference + +Read [references/api-patterns.md](references/api-patterns.md) when choosing an interface, builder, icon, list, key-value layout, styling class, or validation strategy. diff --git a/skills/pretty-printing/agents/openai.yaml b/skills/pretty-printing/agents/openai.yaml new file mode 100644 index 00000000..dc5b48d1 --- /dev/null +++ b/skills/pretty-printing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Clicky Pretty Printing" + short_description: "Build rich multi-format Clicky output" + default_prompt: "Use $pretty-printing to implement rich Clicky output for this Go type." diff --git a/skills/pretty-printing/references/api-patterns.md b/skills/pretty-printing/references/api-patterns.md new file mode 100644 index 00000000..967d4ec7 --- /dev/null +++ b/skills/pretty-printing/references/api-patterns.md @@ -0,0 +1,140 @@ +# Clicky Pretty-Printing API Patterns + +## Contents + +- [Interfaces](#interfaces) +- [Text composition](#text-composition) +- [Icons and status](#icons-and-status) +- [Code and collapsed content](#code-and-collapsed-content) +- [Key-value and list content](#key-value-and-list-content) +- [Tables and row detail](#tables-and-row-detail) +- [Human formatting](#human-formatting) +- [Validation](#validation) + +## Interfaces + +```go +type Pretty interface { + Pretty() api.Text +} + +type PrettyFull interface { + PrettyFull() api.Textable +} + +type PrettyShort interface { + PrettyShort() api.Textable +} + +type TreeNode interface { + Pretty() api.Text + GetChildren() []api.TreeNode +} + +type TableProvider interface { + Columns() []api.ColumnDef + Row() map[string]any +} + +type DetailProvider interface { + RowDetail() api.Textable +} +``` + +Use `PrettyShort` for compact self-links or labels in table cells. Use `PrettyFull` when normal output should stay concise but a detail surface needs more content. + +## Text composition + +Prefer these constructors and methods: + +| Intent | API | +|---|---| +| Start with content | `clicky.Text(content, styles...)` | +| Incremental empty builder | `api.Text{}` followed by `.Append(...)` | +| Append any supported value | `.Append(value, styles...)` | +| Append a `Textable` | `.Add(value)` | +| Append a string | `.AddText(value, styles...)` | +| Safe spacing | `.Space()` | +| Line break | `.NewLine()` | +| Style a node | `.Styles(classes...)` | +| Add a prefix or suffix | `.Prefix(value)` / `.Suffix(value)` | +| Wrap content | `.Wrap(prefix, suffix)` | + +`Append` accepts strings, time values, durations, maps, and `Textable` values. Extract status/style selection into a helper when it would otherwise obscure the render chain. + +## Icons and status + +Import `github.com/flanksource/clicky/api/icons`. Add the icon value directly: + +```go +return api.Text{}. + Add(icons.Warning). + Space(). + Append("Degraded", "warning") +``` + +Common status icons include `Success`, `Error`, `Warning`, `Info`, `Pending`, `Unknown`, and `Skip`. Use `icons.Filename(path)` when the icon should follow a filename or extension. Confirm less common icon names in `api/icons/` rather than guessing. + +## Code and collapsed content + +Use a language name or MIME type supported by `api.CodeBlock`: + +```go +code := clicky.CodeBlock("application/yaml", manifest) +details := clicky.Collapsed("Manifest", code) +``` + +The formatter owns syntax highlighting and output-specific behavior. Do not manually add terminal color or HTML markup. + +## Key-value and list content + +Use `clicky.KeyValue` when empty values should be skipped and `clicky.Map` for deterministic map presentation: + +```go +items := []api.KeyValuePair{ + clicky.KeyValue("namespace", obj.Namespace), + clicky.KeyValue("owner", obj.Owner), +} + +details := api.DescriptionList{Items: items, Style: "badge"} +labels := clicky.Map(map[string]string{"env": "prod", "team": "platform"}) +``` + +Use `clicky.List` for explicit `Textable` items and `clicky.CompactList` for short scalar slices that should become vertical only when necessary. + +## Tables and row detail + +Define column order in `Columns()`. Keep `Row()` keys aligned with column names, and return rich values rather than pre-rendered strings. Use `MaxWidth` and style configuration on the column builder instead of truncating cell content manually. + +`api.NewTableFrom(items)` uses the first item for column metadata and can build an empty table from the zero value. Make `Columns()` safe on a zero-value receiver. + +If the row implements `DetailProvider`, Clicky collects its detail into expandable row content. Return `nil` when a row has no detail. +Return the detail body itself rather than nesting another `Collapsed` unless the product explicitly needs a second disclosure level. + +## Human formatting + +| Value | API | +|---|---| +| Duration, time, bool, numeric scalar | `clicky.Human(value, styles...)` | +| Bytes | `api.HumanizeBytes(value).Styles(...)` | +| Large integer | `api.HumanNumber(value, styles...)` | +| Formatted date | `api.HumanDate(value, format)` | + +Pass the original typed value whenever possible so each output formatter can preserve meaning. +`clicky.Human(time.Duration(0))` currently renders empty. Omit zero durations or render an explicit `0s` according to the product contract. + +## Validation + +1. Run focused tests for the package containing the render implementation. +2. Run `clicky lint ./...` from an external consumer module when validating lint-enforced patterns; the analyzer intentionally skips the Clicky module itself. +3. Exercise the exact requested output plus one structurally different format, such as ANSI and Markdown or HTML. +4. Treat broad-suite environment failures separately when focused Clicky API, lint, and formatter checks pass. + +Repository source locations: + +- `api/types.go` — `Pretty`, `PrettyFull`, `PrettyShort`, and `PrettyRow`. +- `api/text.go` — text builders, key-value helpers, code blocks, and human formatting. +- `api/column.go` — table and row-detail interfaces. +- `api/meta.go` — tree interfaces. +- `format.go` and `aliases.go` — preferred root helper exports. +- `api/icons/` — current icon catalog. From d4bf5b91e930e30439a9bb34261450ad056ca221 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 12:41:41 +0300 Subject: [PATCH 20/23] docs(docs): delegate agent workflow guidance to shared instructions Replace duplicated repository-specific Grite instructions with a pointer to shared agent guidance, project memory, and Clicky skills. This keeps workflow documentation centralized and easier to maintain. --- AGENTS.md | 125 +++--------------------------------------------------- 1 file changed, 7 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8efc4a54..b37962e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,122 +1,11 @@ -## Grite +# clicky — agent notes -This repository uses **Grite** as the canonical task and memory system. **Always use grite commands** (not git) for task/issue tracking. +Shared ways of working, the gavel todo workflow, and global skills come from the root ~/.agents/AGENTS.md. -### When to use grite +## Memory -- **"What tasks are done/open?"** → `grite issue list` -- **"What did I work on?"** → `grite issue list --state closed` -- **"What do I know about X?"** → `grite issue list --label memory` -- **"What should I work on next?"** → `grite issue dep topo --state open` -- **"What does function X do?"** → `grite context query X` -- **Starting a new task** → `grite issue create` -- **Making progress** → `grite issue comment` -- **Task A depends on B** → `grite issue dep add` +See the [project memory index](.agents/memory/index.md). -### Startup routine - -Run at the beginning of each session: - -```bash -grite sync --pull --json -grite issue list --json -grite issue dep topo --state open --json -``` - -### Creating tasks/memories - -```bash -# Create a task -grite issue create --title "Task title" --body "Description" --label agent:todo --json - -# Store a discovery as memory -grite issue create --title "[Memory] Topic" --body "What you learned..." --label memory --json -``` - -### Working on issues - -```bash -# Add a comment with your plan before coding -grite issue comment --body "Plan: ..." --json - -# Add checkpoint comments after milestones -grite issue comment --body "Checkpoint: what changed, why, tests run" --json - -# Close when done -grite issue close --json -grite sync --push --json -``` - -### Querying tasks - -```bash -# All issues -grite issue list --json - -# Open tasks -grite issue list --state open --json - -# Completed tasks -grite issue list --state closed --json - -# Memories -grite issue list --label memory --json -``` - -### Dependencies - -Track task ordering and blockers with a dependency DAG: - -```bash -# Task A depends on Task B (B must complete first) -grite issue dep add --target --type depends_on --json - -# Task A blocks Task B (A must complete first) -grite issue dep add --target --type blocks --json - -# Mark tasks as related (no ordering constraint) -grite issue dep add --target --type related_to --json - -# List what an issue depends on -grite issue dep list --json - -# List what depends on an issue (reverse) -grite issue dep list --reverse --json - -# Get execution order (topological sort of open tasks) -grite issue dep topo --state open --json -``` - -**Always run `dep topo`** at session start to determine which task to work on next. - -### Context store - -Index and query codebase structure for fast navigation: - -```bash -# Index all tracked files (skips unchanged files) -grite context index --json - -# Index specific files or patterns -grite context index --path src/main.py --json -grite context index --pattern "*.rs" --json - -# Query for a symbol (function, class, struct, etc.) -grite context query --json - -# Show all symbols in a file -grite context show --json - -# Set project-level context (conventions, architecture notes) -grite context set --json - -# View all project context -grite context project --json -``` - -**Index after significant code changes** to keep the symbol database current. - -### Key flags - -- `--json` - Use for all commands (machine-readable output) -- `--quiet` - Suppress human output +## Skills +- [clicky-tasks](skills/clicky-tasks/SKILL.md) — refactor goroutines/WaitGroups to Clicky task APIs (StartTask, StartGroup, typed results, retries, cancellation, task-safe logging). +- [pretty-printing](skills/pretty-printing/SKILL.md) — implement/review Clicky render interfaces and builders (Pretty, PrettyShort, TreeNode, TableProvider, icons, code blocks) instead of rendering ANSI/HTML/Markdown in domain code. From bdf1894f3ecb2796ae95b877416a53f14466d875 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 12:41:49 +0300 Subject: [PATCH 21/23] feat(api): support semantic columns and structured table values Add semantic column metadata and structured value rendering while preserving raw values for exports. Improve terminal width calculations so capped columns behave correctly in wide and narrow terminals. --- api/column.go | 14 +- api/column_value.go | 243 ++++++++++++++++++++++++ api/column_value_test.go | 44 +++++ api/table.go | 38 ++-- api/table_test.go | 66 +++++++ api/types.go | 1 + formatters/html_react_formatter.go | 6 + formatters/html_react_formatter_test.go | 10 + 8 files changed, 402 insertions(+), 20 deletions(-) create mode 100644 api/column_value.go create mode 100644 api/column_value_test.go diff --git a/api/column.go b/api/column.go index 7388cdf5..72569583 100644 --- a/api/column.go +++ b/api/column.go @@ -7,6 +7,7 @@ import "fmt" type ColumnDef struct { Name string Label string + Kind string Style string HeaderStyle string Type string @@ -40,6 +41,14 @@ func (b *ColumnBuilder) Label(label string) *ColumnBuilder { return b } +// Kind sets the semantic UI column kind (for example timestamp, tags, or +// status). Unlike Type/Format, Kind controls table interaction and rendering +// behavior rather than value coercion. +func (b *ColumnBuilder) Kind(kind string) *ColumnBuilder { + b.col.Kind = kind + return b +} + // Style sets the Tailwind CSS classes for cell content. func (b *ColumnBuilder) Style(style string) *ColumnBuilder { b.col.Style = style @@ -128,6 +137,7 @@ func NewEmptyTable(columns []ColumnDef) TextTable { table.Columns = append(table.Columns, PrettyField{ Name: col.Name, Label: col.DisplayLabel(), + Kind: col.Kind, Style: style, LabelStyle: col.HeaderStyle, Type: col.Type, @@ -157,7 +167,7 @@ func NewTableFrom[T TableProvider](items []T) TextTable { for _, col := range columns { if val, exists := rowData[col.Name]; exists && col.Hidden { - row[col.Name] = TypedValue{Textable: Text{}.Add(convertToTextable(val))} + row[col.Name] = TypedValue{Textable: Text{}.Add(ColumnTextable(col, val))} continue } if col.Hidden { @@ -168,7 +178,7 @@ func NewTableFrom[T TableProvider](items []T) TextTable { if col.MaxWidth > 0 { style = fmt.Sprintf("%s max-w-[%dch] truncate", style, col.MaxWidth) } - text := Text{Style: style}.Add(convertToTextable(val)) + text := Text{Style: style}.Add(ColumnTextable(col, val)) row[col.Name] = TypedValue{Textable: text} } else { row[col.Name] = TypedValue{Textable: Text{}} diff --git a/api/column_value.go b/api/column_value.go new file mode 100644 index 00000000..17fdb6dc --- /dev/null +++ b/api/column_value.go @@ -0,0 +1,243 @@ +package api + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +const ( + ColumnTypeKeyValue = "key_value" + ColumnTypeKeyValues = "key_values" + ColumnTypeJSON = "json" +) + +// IsStructuredColumnType reports whether a column type carries JSON-like +// structure that needs a semantic table-cell representation. +func IsStructuredColumnType(columnType string) bool { + switch columnType { + case ColumnTypeKeyValue, ColumnTypeKeyValues, ColumnTypeJSON: + return true + default: + return false + } +} + +// ColumnTextable adapts a raw column value to a structured Clicky value. It is +// intentionally presentation-only: callers that emit JSON/YAML should retain +// the original raw value instead of replacing it with this representation. +func ColumnTextable(column ColumnDef, value any) Textable { + switch column.Type { + case ColumnTypeKeyValue, ColumnTypeKeyValues: + if pairs, ok := normalizeKeyValuePairs(value); ok { + return DescriptionList{Items: pairs, Style: "compact"} + } + case ColumnTypeJSON: + if normalized, ok := normalizeJSONColumnValue(value); ok { + if data, err := json.Marshal(normalized); err == nil { + return CodeBlock("json", string(data)) + } + } + } + return convertToTextable(value) +} + +// ColumnString returns the deterministic scalar representation used by CSV, +// Markdown, and spreadsheet exports. Structured JSON/YAML exporters should +// continue to encode the original raw value. +func ColumnString(column ColumnDef, value any) string { + switch column.Type { + case ColumnTypeKeyValue, ColumnTypeKeyValues: + if pairs, ok := normalizeKeyValuePairs(value); ok { + parts := make([]string, 0, len(pairs)) + for _, pair := range pairs { + parts = append(parts, pair.Key+"="+columnScalarString(pair.Value)) + } + return strings.Join(parts, ", ") + } + case ColumnTypeJSON: + if normalized, ok := normalizeJSONColumnValue(value); ok { + if data, err := json.Marshal(normalized); err == nil { + return string(data) + } + } + } + return convertToTextable(value).String() +} + +func normalizeKeyValuePairs(value any) ([]KeyValuePair, bool) { + if list, ok := value.(DescriptionList); ok { + return list.Items, true + } + if list, ok := value.(*DescriptionList); ok && list != nil { + return list.Items, true + } + + decoded, ok := decodeStructuredColumnValue(value, true) + if !ok { + return nil, false + } + return keyValuePairsFromDecoded(decoded) +} + +func keyValuePairsFromDecoded(value any) ([]KeyValuePair, bool) { + switch typed := value.(type) { + case map[string]any: + if pair, ok := keyValuePairFromObject(typed); ok { + return []KeyValuePair{pair}, true + } + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + pairs := make([]KeyValuePair, 0, len(keys)) + for _, key := range keys { + pairs = append(pairs, KeyValuePair{Key: key, Value: normalizedPairValue(typed[key]), Style: "compact"}) + } + return pairs, true + case []any: + pairs := make([]KeyValuePair, 0, len(typed)) + for _, item := range typed { + object, ok := item.(map[string]any) + if !ok { + return nil, false + } + if pair, ok := keyValuePairFromObject(object); ok { + pairs = append(pairs, pair) + continue + } + objectPairs, ok := keyValuePairsFromDecoded(object) + if !ok { + return nil, false + } + pairs = append(pairs, objectPairs...) + } + return pairs, true + default: + return nil, false + } +} + +func keyValuePairFromObject(object map[string]any) (KeyValuePair, bool) { + key, hasKey := lookupObjectString(object, "key", "name") + value, hasValue := lookupObjectValue(object, "value") + if !hasKey || !hasValue { + return KeyValuePair{}, false + } + return KeyValuePair{Key: key, Value: normalizedPairValue(value), Style: "compact"}, true +} + +func lookupObjectString(object map[string]any, names ...string) (string, bool) { + value, ok := lookupObjectValue(object, names...) + if !ok { + return "", false + } + text, ok := value.(string) + return text, ok +} + +func lookupObjectValue(object map[string]any, names ...string) (any, bool) { + for _, name := range names { + for key, value := range object { + if strings.EqualFold(key, name) { + return value, true + } + } + } + return nil, false +} + +func normalizedPairValue(value any) any { + if value == nil { + return "null" + } + if text, ok := value.(string); ok { + if text == "" { + return `""` + } + return text + } + switch value.(type) { + case bool, float64, json.Number: + return value + } + if data, err := json.Marshal(value); err == nil { + return string(data) + } + return fmt.Sprintf("%v", value) +} + +func columnScalarString(value any) string { + switch typed := value.(type) { + case string: + return typed + case nil: + return "null" + default: + if data, err := json.Marshal(typed); err == nil { + return string(data) + } + return fmt.Sprintf("%v", typed) + } +} + +func normalizeJSONColumnValue(value any) (any, bool) { + if value == nil { + return nil, true + } + return decodeStructuredColumnValue(value, false) +} + +func decodeStructuredColumnValue(value any, requireContainer bool) (any, bool) { + switch typed := value.(type) { + case string: + trimmed := strings.TrimSpace(typed) + if (strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) && json.Valid([]byte(trimmed)) { + var decoded any + if json.Unmarshal([]byte(trimmed), &decoded) == nil { + return decoded, true + } + } + if requireContainer { + return nil, false + } + return typed, true + case []byte: + if json.Valid(typed) { + var decoded any + if json.Unmarshal(typed, &decoded) == nil { + if !requireContainer || isJSONContainer(decoded) { + return decoded, true + } + } + } + if requireContainer { + return nil, false + } + return string(typed), true + default: + data, err := json.Marshal(value) + if err != nil { + return nil, false + } + var decoded any + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, false + } + if requireContainer && !isJSONContainer(decoded) { + return nil, false + } + return decoded, true + } +} + +func isJSONContainer(value any) bool { + switch value.(type) { + case map[string]any, []any: + return true + default: + return false + } +} diff --git a/api/column_value_test.go b/api/column_value_test.go new file mode 100644 index 00000000..77208dff --- /dev/null +++ b/api/column_value_test.go @@ -0,0 +1,44 @@ +package api + +import "testing" + +func TestColumnStringNormalizesKeyValueShapes(t *testing.T) { + column := ColumnDef{Type: ColumnTypeKeyValue} + if got := ColumnString(column, map[string]any{"team": "core", "env": "prod"}); got != "env=prod, team=core" { + t.Fatalf("map string = %q", got) + } + + list := []any{ + map[string]any{"key": "env", "value": "prod"}, + map[string]any{"name": "env", "value": "staging"}, + } + if got := ColumnString(ColumnDef{Type: ColumnTypeKeyValues}, list); got != "env=prod, env=staging" { + t.Fatalf("pair list string = %q", got) + } + + encoded := `[{"key":"region","value":"eu"}]` + if got := ColumnString(ColumnDef{Type: ColumnTypeKeyValues}, encoded); got != "region=eu" { + t.Fatalf("encoded pair list string = %q", got) + } +} + +func TestColumnTextableKeepsStructuredValues(t *testing.T) { + value := ColumnTextable(ColumnDef{Type: ColumnTypeKeyValue}, map[string]any{"env": "prod"}) + list, ok := value.(DescriptionList) + if !ok || len(list.Items) != 1 || list.Items[0].Key != "env" { + t.Fatalf("expected description list, got %#v", value) + } + + jsonValue := ColumnTextable(ColumnDef{Type: ColumnTypeJSON}, map[string]any{"enabled": true}) + code, ok := jsonValue.(Code) + if !ok || code.Language != "json" || code.Content != `{"enabled":true}` { + t.Fatalf("expected canonical JSON code, got %#v", jsonValue) + } +} + +func TestColumnTextableFallsBackForMalformedKeyValues(t *testing.T) { + value := ColumnTextable(ColumnDef{Type: ColumnTypeKeyValues}, "not-json") + if got := value.String(); got != "not-json" { + t.Fatalf("fallback = %q", got) + } +} diff --git a/api/table.go b/api/table.go index dff80736..17176587 100644 --- a/api/table.go +++ b/api/table.go @@ -576,14 +576,14 @@ func (t *TextTable) renderLipgloss(withColors bool) string { // Measure headers for i, header := range t.Headers { - columnWidths[i] = len(header.String()) + columnWidths[i] = lipgloss.Width(header.String()) } // Measure all row cells to find max width per column for _, row := range t.Rows { for colIdx := range t.Headers { cell := t.getCellValue(row, colIdx) - width := len(cell.String()) + width := lipgloss.Width(cell.String()) if width > columnWidths[colIdx] { columnWidths[colIdx] = width } @@ -617,6 +617,11 @@ func (t *TextTable) renderLipgloss(withColors bool) string { // Calculate total width needed for table totalWidth := GetTerminalWidth() + naturalWidth := len(columnWidths) + 1 + for _, width := range columnWidths { + naturalWidth += width + } + fixCappedColumns := naturalWidth <= totalWidth // Create lipgloss table with calculated dimensions tbl := table.New(). @@ -624,27 +629,24 @@ func (t *TextTable) renderLipgloss(withColors bool) string { Rows(rows...). Width(totalWidth) - // Apply styling if colors are enabled - if withColors { - tbl = tbl.StyleFunc(func(row, col int) lipgloss.Style { - // row -1 is the header + tbl = tbl.StyleFunc(func(row, col int) lipgloss.Style { + style := lipgloss.NewStyle() + if withColors { if row == -1 { - return lipgloss.NewStyle().Bold(true) - } - - // Get the cell value to check for styling - if row < len(t.Rows) && col < len(t.Headers) { + style = style.Bold(true) + } else if row < len(t.Rows) && col < len(t.Headers) { cell := t.getCellValue(t.Rows[row], col) - - // Check if the Textable is a Text type and has a Style if textCell, isText := cell.(*Text); isText && textCell.Style != "" { - return parseTailwindToLipgloss(textCell.Style) + style = parseTailwindToLipgloss(textCell.Style) } } - - return lipgloss.NewStyle() - }) - } + } + if fixCappedColumns && col < len(t.Columns) && col < len(columnWidths) && + tailwind.ParseStyle(t.Columns[col].Style).MaxWidth > 0 { + style = style.Width(columnWidths[col]) + } + return style + }) return tbl.String() } diff --git a/api/table_test.go b/api/table_test.go index 4fbc17e0..4a2025b2 100644 --- a/api/table_test.go +++ b/api/table_test.go @@ -1,10 +1,46 @@ package api import ( + "strings" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +type cappedWidthTableRow struct{} + +func (cappedWidthTableRow) Columns() []ColumnDef { + return []ColumnDef{ + Column("agent").Label("Agent").MaxWidth(12).Build(), + Column("session").Label("Session").MaxWidth(8).Build(), + Column("title").Label("Title").Build(), + } +} + +type shrinkingCappedWidthTableRow struct{} + +func (shrinkingCappedWidthTableRow) Columns() []ColumnDef { + return []ColumnDef{ + Column("prompt").Label("Prompt").MaxWidth(30).Build(), + Column("usage").Label("Usage").Build(), + } +} + +func (shrinkingCappedWidthTableRow) Row() map[string]any { + return map[string]any{ + "prompt": "A capped column must yield space when the terminal is narrow", + "usage": "$1.25", + } +} + +func (cappedWidthTableRow) Row() map[string]any { + return map[string]any{ + "agent": "codex-agent", + "session": "019f5c3c", + "title": "Title uses all terminal space left after capped columns", + } +} + var _ = Describe("WithoutEmptyColumns", func() { It("removes columns where every row is empty", func() { table := TextTable{ @@ -104,3 +140,33 @@ var _ = Describe("TextTable Markdown pipe escaping", func() { Expect(t.Markdown()).NotTo(ContainSubstring(`| x|y |`)) }) }) + +var _ = Describe("TextTable terminal column widths", func() { + It("keeps capped columns compact and gives uncapped columns the remaining width", func() { + previousWidth := terminalWidth.Swap(120) + DeferCleanup(func() { terminalWidth.Store(previousWidth) }) + + rendered := NewTableFrom([]cappedWidthTableRow{{}}).String() + var header string + for _, line := range strings.Split(rendered, "\n") { + if strings.Contains(line, "│Agent") { + header = line + break + } + } + Expect(header).NotTo(BeEmpty()) + cells := strings.Split(strings.Trim(header, "│"), "│") + Expect(cells).To(HaveLen(3)) + Expect(len([]rune(cells[0]))).To(BeNumerically("<=", 12)) + Expect(len([]rune(cells[1]))).To(BeNumerically("<=", 8)) + Expect(len([]rune(cells[2]))).To(BeNumerically(">", 80)) + }) + + It("allows capped columns to shrink when content exceeds the terminal width", func() { + previousWidth := terminalWidth.Swap(30) + DeferCleanup(func() { terminalWidth.Store(previousWidth) }) + + rendered := NewTableFrom([]shrinkingCappedWidthTableRow{{}}).String() + Expect(rendered).To(ContainSubstring("$1.25")) + }) +}) diff --git a/api/types.go b/api/types.go index dd2730bb..bf9ab922 100644 --- a/api/types.go +++ b/api/types.go @@ -52,6 +52,7 @@ type RenderFunc func(value interface{}, field PrettyField, theme Theme) string type PrettyField struct { Name string `json:"name" yaml:"name"` Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"` + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` Type string `json:"type,omitempty" yaml:"type,omitempty"` Format string `json:"format,omitempty" yaml:"format,omitempty"` Label string `json:"label,omitempty" yaml:"label,omitempty"` diff --git a/formatters/html_react_formatter.go b/formatters/html_react_formatter.go index b386f144..64159dd8 100644 --- a/formatters/html_react_formatter.go +++ b/formatters/html_react_formatter.go @@ -69,6 +69,8 @@ type ClickyField struct { type ClickyColumn struct { Name string `json:"name"` Label string `json:"label,omitempty"` + Kind string `json:"kind,omitempty"` + Type string `json:"type,omitempty"` Header *ClickyNode `json:"header,omitempty"` Align string `json:"align,omitempty"` } @@ -508,6 +510,8 @@ func convertTable(table *api.TextTable) ClickyNode { if i < len(table.Columns) { column.Label = table.Columns[i].Label + column.Kind = table.Columns[i].Kind + column.Type = table.Columns[i].Type column.Align = alignFromStyle(table.Columns[i].Style) } if column.Label == "" { @@ -523,6 +527,8 @@ func convertTable(table *api.TextTable) ClickyNode { column := ClickyColumn{ Name: columnDef.Name, Label: columnDef.Label, + Kind: columnDef.Kind, + Type: columnDef.Type, Align: alignFromStyle(columnDef.Style), } if column.Label == "" { diff --git a/formatters/html_react_formatter_test.go b/formatters/html_react_formatter_test.go index 6df924fc..f09a1add 100644 --- a/formatters/html_react_formatter_test.go +++ b/formatters/html_react_formatter_test.go @@ -182,6 +182,10 @@ func TestHTMLReactConvertTable(t *testing.T) { api.Text{Content: "Name"}, api.Text{Content: "Status"}, }, + Columns: []api.PrettyField{ + {Name: "Name", Label: "Name"}, + {Name: "Status", Label: "Status", Kind: "status", Type: api.ColumnTypeKeyValue}, + }, Rows: []api.TableRow{ { "Name": api.TypedValue{Textable: api.Text{Content: "svc-a"}}, @@ -205,6 +209,12 @@ func TestHTMLReactConvertTable(t *testing.T) { if len(node.Columns) != 2 { t.Fatalf("expected 2 columns, got %d", len(node.Columns)) } + if node.Columns[1].Kind != "status" { + t.Fatalf("expected status column kind to survive clicky conversion, got %#v", node.Columns[1]) + } + if node.Columns[1].Type != api.ColumnTypeKeyValue { + t.Fatalf("expected column type to survive clicky conversion, got %#v", node.Columns[1]) + } if len(node.Rows) != 2 { t.Fatalf("expected 2 rows, got %d", len(node.Rows)) } From d02478de3f5b1b21c4bc5ca6a597f15a2d8f28b6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 12:42:01 +0300 Subject: [PATCH 22/23] feat(api): Add streaming table exports and export metadata Add bounded-memory streaming exports for JSON, NDJSON, YAML, CSV, Markdown, HTML, Excel, and PDF. Expose export capabilities through operation metadata and support NDJSON content negotiation. Render paged clicky-json responses as table documents with pagination retained in headers. BREAKING CHANGE: Paged clicky-json responses no longer wrap rows in the data/page envelope. --- entity/operation.go | 11 ++ formatters/http/http.go | 4 + formatters/options.go | 3 +- formatters/stream.go | 378 ++++++++++++++++++++++++++++++++++++++ formatters/stream_test.go | 217 ++++++++++++++++++++++ rpc/entities_test.go | 56 ++++++ rpc/openapi.go | 1 + rpc/serve.go | 11 +- rpc/types.go | 1 + 9 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 formatters/stream.go create mode 100644 formatters/stream_test.go diff --git a/entity/operation.go b/entity/operation.go index 520e1150..ed6508ab 100644 --- a/entity/operation.go +++ b/entity/operation.go @@ -73,9 +73,20 @@ type ClickyOperationMeta struct { Group string `json:"group,omitempty"` ToolHints MCPToolHints `json:"-"` OpenAPIToolHints *MCPToolHints `json:"toolHints,omitempty"` + Export *ExportMeta `json:"export,omitempty"` Order int `json:"-"` } +// ExportMeta advertises the representations and scopes an operation can +// download. UI consumers preserve legacy download behavior when this metadata +// is absent. +type ExportMeta struct { + Formats []string `json:"formats,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AllRowsMode string `json:"allRowsMode,omitempty"` + FormatMaxRows map[string]int `json:"formatMaxRows,omitempty"` +} + // RPCParameter represents a parameter in an RPC operation. type RPCParameter struct { Name string `json:"name"` diff --git a/formatters/http/http.go b/formatters/http/http.go index 711bdf65..e91206e5 100644 --- a/formatters/http/http.go +++ b/formatters/http/http.go @@ -174,6 +174,8 @@ func acceptToFormat(accept string) string { contentType := trimQuality(part) switch contentType { + case "application/x-ndjson", "application/ndjson": + return "ndjson" case "application/yaml", "text/yaml", "application/x-yaml": return "yaml" case "text/csv", "application/csv": @@ -242,6 +244,8 @@ func formatToContentType(format string) string { return "application/json" case "clicky-json": return "application/json+clicky" + case "ndjson": + return "application/x-ndjson" case "yaml", "yml": return "application/yaml" case "csv": diff --git a/formatters/options.go b/formatters/options.go index f2937f6c..35cb3199 100644 --- a/formatters/options.go +++ b/formatters/options.go @@ -16,6 +16,7 @@ import ( var knownFormats = map[string]bool{ "pretty": true, "json": true, + "ndjson": true, "yaml": true, "yml": true, "clicky-json": true, @@ -32,7 +33,7 @@ var knownFormats = map[string]bool{ "tree": true, } -const FormatSpecHelp = "Output format. Use one stdout format (pretty|json|yaml|yml|csv|markdown|md|html|html-static|html-react|clicky-json|pdf|slack|excel|xlsx|tree), " + +const FormatSpecHelp = "Output format. Use one stdout format (pretty|json|ndjson|yaml|yml|csv|markdown|md|html|html-static|html-react|clicky-json|pdf|slack|excel|xlsx|tree), " + "or comma-separated format=file sinks such as 'pretty,json=out.json,markdown=summary.md'." // canonicalFormat normalises common aliases (e.g. "md" -> "markdown"). diff --git a/formatters/stream.go b/formatters/stream.go new file mode 100644 index 00000000..35aab2f9 --- /dev/null +++ b/formatters/stream.go @@ -0,0 +1,378 @@ +package formatters + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "html" + "io" + "sort" + "strings" + + "github.com/flanksource/clicky/api" + "github.com/xuri/excelize/v2" + "gopkg.in/yaml.v3" +) + +// RowIterator is the bounded-memory table source consumed by WriteTableStream. +// Columns may be empty; in that case the first row establishes a stable, +// alphabetically sorted schema for table-oriented formats. +type RowIterator interface { + Columns() []api.ColumnDef + Next() bool + Row() map[string]any + Err() error + Close() error +} + +// StreamOptions controls incremental table formatting. MaxRows is enforced +// before PDF rendering and is otherwise zero (unlimited) unless a caller opts +// into a cap. +type StreamOptions struct { + Format string + MaxRows int64 +} + +// WriteTableStream writes rows incrementally in a Clicky-supported tabular +// format. Text formats keep only the current row in memory. Excel uses +// excelize's streaming worksheet writer; PDF deliberately buffers no more than +// MaxRows so the existing Chromium formatter can render a bounded document. +func WriteTableStream(ctx context.Context, w io.Writer, rows RowIterator, opts StreamOptions) (count int64, err error) { + defer func() { + if closeErr := rows.Close(); err == nil && closeErr != nil { + err = closeErr + } + }() + + format := canonicalFormat(strings.ToLower(strings.TrimSpace(opts.Format))) + if format == "" { + format = "json" + } + + first, ok, err := nextStreamRow(ctx, rows) + if err != nil { + return 0, err + } + columns := visibleStreamColumns(rows.Columns()) + structuredColumns := columns + if len(columns) == 0 && ok { + columns = derivedStreamColumns(first) + } + + switch format { + case "json": + return writeJSONStream(ctx, w, rows, structuredColumns, first, ok, false, opts.MaxRows) + case "ndjson": + return writeJSONStream(ctx, w, rows, structuredColumns, first, ok, true, opts.MaxRows) + case "yaml": + return writeYAMLStream(ctx, w, rows, structuredColumns, first, ok, opts.MaxRows) + case "csv": + return writeCSVStream(ctx, w, rows, columns, first, ok, opts.MaxRows) + case "markdown": + return writeMarkdownStream(ctx, w, rows, columns, first, ok, opts.MaxRows) + case "html": + return writeHTMLStream(ctx, w, rows, columns, first, ok, opts.MaxRows) + case "excel": + return writeExcelStream(ctx, w, rows, columns, first, ok, opts.MaxRows) + case "pdf": + return writePDFStream(ctx, w, rows, columns, first, ok, opts.MaxRows) + default: + return 0, fmt.Errorf("unsupported streaming format: %s", format) + } +} + +func nextStreamRow(ctx context.Context, rows RowIterator) (map[string]any, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, false, err + } + return nil, false, nil + } + return rows.Row(), true, nil +} + +func visibleStreamColumns(columns []api.ColumnDef) []api.ColumnDef { + out := make([]api.ColumnDef, 0, len(columns)) + for _, column := range columns { + if !column.Hidden { + out = append(out, column) + } + } + return out +} + +func derivedStreamColumns(row map[string]any) []api.ColumnDef { + names := make([]string, 0, len(row)) + for name := range row { + names = append(names, name) + } + sort.Strings(names) + columns := make([]api.ColumnDef, len(names)) + for i, name := range names { + columns[i] = api.ColumnDef{Name: name} + } + return columns +} + +func projectedStreamRow(row map[string]any, columns []api.ColumnDef) map[string]any { + if len(columns) == 0 { + return row + } + out := make(map[string]any, len(columns)) + for _, column := range columns { + out[column.Name] = row[column.Name] + } + return out +} + +func streamRows(ctx context.Context, rows RowIterator, first map[string]any, hasFirst bool, maxRows int64, emit func(map[string]any) error) (int64, error) { + var count int64 + emitRow := func(row map[string]any) error { + if maxRows > 0 && count >= maxRows { + return fmt.Errorf("row limit exceeded: maximum %d", maxRows) + } + if err := emit(row); err != nil { + return err + } + count++ + return nil + } + if hasFirst { + if err := emitRow(first); err != nil { + return count, err + } + } + for { + row, ok, err := nextStreamRow(ctx, rows) + if err != nil { + return count, err + } + if !ok { + return count, nil + } + if err := emitRow(row); err != nil { + return count, err + } + } +} + +func writeJSONStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok, ndjson bool, maxRows int64) (int64, error) { + enc := json.NewEncoder(w) + firstJSON := true + if !ndjson { + if _, err := io.WriteString(w, "["); err != nil { + return 0, err + } + } + count, err := streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + row = projectedStreamRow(row, columns) + if ndjson { + return enc.Encode(row) + } + if !firstJSON { + if _, err := io.WriteString(w, ","); err != nil { + return err + } + } + firstJSON = false + data, err := json.Marshal(row) + if err != nil { + return err + } + _, err = w.Write(data) + return err + }) + if err != nil { + return count, err + } + if !ndjson { + _, err = io.WriteString(w, "]\n") + } + return count, err +} + +func writeYAMLStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok bool, maxRows int64) (int64, error) { + if !ok { + _, err := io.WriteString(w, "[]\n") + return 0, err + } + return streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + data, err := yaml.Marshal(projectedStreamRow(row, columns)) + if err != nil { + return err + } + lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + for i, line := range lines { + prefix := " " + if i == 0 { + prefix = "- " + } + if _, err := fmt.Fprintln(w, prefix+line); err != nil { + return err + } + } + return nil + }) +} + +func writeCSVStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok bool, maxRows int64) (int64, error) { + writer := csv.NewWriter(w) + headers := make([]string, len(columns)) + for i, column := range columns { + headers[i] = column.DisplayLabel() + } + if err := writer.Write(headers); err != nil { + return 0, err + } + count, err := streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + values := make([]string, len(columns)) + for i, column := range columns { + values[i] = streamCell(row[column.Name], column, "plain") + } + return writer.Write(values) + }) + writer.Flush() + if err == nil { + err = writer.Error() + } + return count, err +} + +func writeMarkdownStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok bool, maxRows int64) (int64, error) { + headers := make([]string, len(columns)) + separators := make([]string, len(columns)) + for i, column := range columns { + headers[i] = escapeMarkdownStream(column.DisplayLabel()) + separators[i] = "---" + } + if _, err := fmt.Fprintf(w, "| %s |\n| %s |\n", strings.Join(headers, " | "), strings.Join(separators, " | ")); err != nil { + return 0, err + } + return streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + values := make([]string, len(columns)) + for i, column := range columns { + values[i] = escapeMarkdownStream(streamCell(row[column.Name], column, "markdown")) + } + _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(values, " | ")) + return err + }) +} + +func writeHTMLStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok bool, maxRows int64) (int64, error) { + if _, err := io.WriteString(w, "Clicky export"); err != nil { + return 0, err + } + for _, column := range columns { + if _, err := fmt.Fprintf(w, "", html.EscapeString(column.DisplayLabel())); err != nil { + return 0, err + } + } + if _, err := io.WriteString(w, ""); err != nil { + return 0, err + } + count, err := streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + if _, err := io.WriteString(w, ""); err != nil { + return err + } + for _, column := range columns { + if _, err := fmt.Fprintf(w, "", streamCell(row[column.Name], column, "html")); err != nil { + return err + } + } + _, err := io.WriteString(w, "") + return err + }) + if err != nil { + return count, err + } + _, err = io.WriteString(w, "
%s
%s
\n") + return count, err +} + +func writeExcelStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok bool, maxRows int64) (int64, error) { + file := excelize.NewFile() + defer file.Close() + stream, err := file.NewStreamWriter("Sheet1") + if err != nil { + return 0, err + } + headers := make([]any, len(columns)) + for i, column := range columns { + headers[i] = column.DisplayLabel() + } + if err := stream.SetRow("A1", headers); err != nil { + return 0, err + } + rowNumber := 2 + count, err := streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + values := make([]any, len(columns)) + for i, column := range columns { + if api.IsStructuredColumnType(column.Type) { + values[i] = api.ColumnString(column, row[column.Name]) + } else { + values[i] = row[column.Name] + } + } + cell, err := excelize.CoordinatesToCellName(1, rowNumber) + if err != nil { + return err + } + rowNumber++ + return stream.SetRow(cell, values) + }) + if err != nil { + return count, err + } + if err := stream.Flush(); err != nil { + return count, err + } + return count, file.Write(w) +} + +func writePDFStream(ctx context.Context, w io.Writer, rows RowIterator, columns []api.ColumnDef, first map[string]any, ok bool, maxRows int64) (int64, error) { + if maxRows <= 0 { + return 0, fmt.Errorf("pdf streaming requires a positive row limit") + } + table := api.TextTable{} + for _, column := range columns { + table.Headers = append(table.Headers, api.Text{Content: column.DisplayLabel()}) + table.FieldNames = append(table.FieldNames, column.Name) + table.Columns = append(table.Columns, api.PrettyField{Name: column.Name, Label: column.DisplayLabel(), Type: column.Type, Format: column.Format}) + } + count, err := streamRows(ctx, rows, first, ok, maxRows, func(row map[string]any) error { + out := api.TableRow{} + for _, column := range columns { + out[column.Name] = api.NewTypedValue(api.ColumnTextable(column, row[column.Name])) + } + table.Rows = append(table.Rows, out) + return nil + }) + if err != nil { + return count, err + } + manager := NewFormatManager() + output, err := manager.FormatWithOptions(FormatOptions{Format: "pdf"}, table) + if err != nil { + return count, err + } + _, err = io.WriteString(w, output) + return count, err +} + +func streamCell(value any, column api.ColumnDef, format string) string { + text := api.ColumnTextable(column, value) + switch format { + case "html": + return text.HTML() + default: + return api.ColumnString(column, value) + } +} + +func escapeMarkdownStream(value string) string { + return strings.ReplaceAll(strings.ReplaceAll(value, "|", "\\|"), "\n", "
") +} diff --git a/formatters/stream_test.go b/formatters/stream_test.go new file mode 100644 index 00000000..a4a4cf2b --- /dev/null +++ b/formatters/stream_test.go @@ -0,0 +1,217 @@ +package formatters + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/flanksource/clicky/api" + "github.com/xuri/excelize/v2" + "gopkg.in/yaml.v3" +) + +type sliceRowIterator struct { + columns []api.ColumnDef + rows []map[string]any + index int +} + +type generatedStructuredRowIterator struct { + count int + index int +} + +func (i *generatedStructuredRowIterator) Columns() []api.ColumnDef { + return []api.ColumnDef{ + {Name: "id", Type: "number"}, + {Name: "labels", Type: api.ColumnTypeKeyValue}, + {Name: "payload", Type: api.ColumnTypeJSON}, + } +} +func (i *generatedStructuredRowIterator) Next() bool { + if i.index >= i.count { + return false + } + i.index++ + return true +} +func (i *generatedStructuredRowIterator) Row() map[string]any { + return map[string]any{ + "id": i.index, + "labels": map[string]any{"env": "prod", "shard": i.index % 32}, + "payload": map[string]any{"active": true, "index": i.index}, + } +} +func (i *generatedStructuredRowIterator) Err() error { return nil } +func (i *generatedStructuredRowIterator) Close() error { return nil } + +func (i *sliceRowIterator) Columns() []api.ColumnDef { return i.columns } +func (i *sliceRowIterator) Next() bool { + if i.index >= len(i.rows) { + return false + } + i.index++ + return true +} +func (i *sliceRowIterator) Row() map[string]any { return i.rows[i.index-1] } +func (i *sliceRowIterator) Err() error { return nil } +func (i *sliceRowIterator) Close() error { return nil } + +func TestWriteTableStreamStructuredFormatsPreserveSchemaLessRows(t *testing.T) { + rows := []map[string]any{{"a": 1}, {"a": 2, "later": true}} + + var jsonOutput bytes.Buffer + count, err := WriteTableStream(context.Background(), &jsonOutput, &sliceRowIterator{rows: rows}, StreamOptions{Format: "json"}) + if err != nil || count != 2 { + t.Fatalf("json count=%d err=%v", count, err) + } + var decoded []map[string]any + if err := json.Unmarshal(jsonOutput.Bytes(), &decoded); err != nil { + t.Fatalf("invalid json: %v: %s", err, jsonOutput.String()) + } + if got := decoded[1]["later"]; got != true { + t.Fatalf("later row field was lost: %#v", decoded[1]) + } + + var ndjson bytes.Buffer + if _, err := WriteTableStream(context.Background(), &ndjson, &sliceRowIterator{rows: rows}, StreamOptions{Format: "ndjson"}); err != nil { + t.Fatal(err) + } + if got := strings.Count(strings.TrimSpace(ndjson.String()), "\n"); got != 1 { + t.Fatalf("expected two ndjson records, got %q", ndjson.String()) + } + + var yamlOutput bytes.Buffer + if _, err := WriteTableStream(context.Background(), &yamlOutput, &sliceRowIterator{rows: rows}, StreamOptions{Format: "yaml"}); err != nil { + t.Fatal(err) + } + var yamlRows []map[string]any + if err := yaml.Unmarshal(yamlOutput.Bytes(), &yamlRows); err != nil { + t.Fatalf("invalid yaml: %v: %s", err, yamlOutput.String()) + } + if got := yamlRows[1]["later"]; got != true { + t.Fatalf("later row field was lost: %#v", yamlRows[1]) + } +} + +func TestWriteTableStreamTabularFormatsUseDeclaredColumns(t *testing.T) { + columns := []api.ColumnDef{ + {Name: "name", Label: "Display name"}, + {Name: "secret", Hidden: true}, + {Name: "count"}, + } + rows := []map[string]any{{"count": 3, "name": "a|b", "secret": "hidden"}} + + for _, format := range []string{"csv", "markdown", "html"} { + t.Run(format, func(t *testing.T) { + var output bytes.Buffer + count, err := WriteTableStream(context.Background(), &output, &sliceRowIterator{columns: columns, rows: rows}, StreamOptions{Format: format}) + if err != nil || count != 1 { + t.Fatalf("count=%d err=%v", count, err) + } + if strings.Contains(output.String(), "secret") || strings.Contains(output.String(), "hidden") { + t.Fatalf("hidden column leaked: %s", output.String()) + } + if !strings.Contains(output.String(), "Display name") || !strings.Contains(output.String(), "Count") { + t.Fatalf("declared headers missing: %s", output.String()) + } + }) + } +} + +func TestWriteTableStreamStructuredColumns(t *testing.T) { + columns := []api.ColumnDef{ + {Name: "labels", Type: api.ColumnTypeKeyValue}, + {Name: "metadata", Type: api.ColumnTypeJSON}, + } + rows := []map[string]any{{ + "labels": map[string]any{"team": "core", "env": "prod"}, + "metadata": map[string]any{"retries": 3, "enabled": true}, + }} + + var csvOutput bytes.Buffer + if _, err := WriteTableStream(context.Background(), &csvOutput, &sliceRowIterator{columns: columns, rows: rows}, StreamOptions{Format: "csv"}); err != nil { + t.Fatal(err) + } + records, err := csv.NewReader(strings.NewReader(csvOutput.String())).ReadAll() + if err != nil { + t.Fatal(err) + } + if records[1][0] != "env=prod, team=core" || records[1][1] != `{"enabled":true,"retries":3}` { + t.Fatalf("unexpected structured CSV: %#v", records) + } + + var jsonOutput bytes.Buffer + if _, err := WriteTableStream(context.Background(), &jsonOutput, &sliceRowIterator{columns: columns, rows: rows}, StreamOptions{Format: "json"}); err != nil { + t.Fatal(err) + } + var decoded []map[string]any + if err := json.Unmarshal(jsonOutput.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if _, ok := decoded[0]["labels"].(map[string]any); !ok { + t.Fatalf("raw labels were flattened: %#v", decoded[0]["labels"]) + } + if _, ok := decoded[0]["metadata"].(map[string]any); !ok { + t.Fatalf("raw JSON was flattened: %#v", decoded[0]["metadata"]) + } +} + +func TestWriteTableStreamExcel(t *testing.T) { + var output bytes.Buffer + rows := &sliceRowIterator{ + columns: []api.ColumnDef{{Name: "name"}, {Name: "count"}}, + rows: []map[string]any{{"name": "alpha", "count": 3}}, + } + if _, err := WriteTableStream(context.Background(), &output, rows, StreamOptions{Format: "xlsx"}); err != nil { + t.Fatal(err) + } + book, err := excelize.OpenReader(bytes.NewReader(output.Bytes())) + if err != nil { + t.Fatal(err) + } + defer book.Close() + if cell, _ := book.GetCellValue("Sheet1", "A2"); cell != "alpha" { + t.Fatalf("unexpected cell A2: %q", cell) + } +} + +func TestWriteTableStreamPDFRequiresAndEnforcesLimit(t *testing.T) { + rows := []map[string]any{{"id": 1}, {"id": 2}} + var output bytes.Buffer + if _, err := WriteTableStream(context.Background(), &output, &sliceRowIterator{rows: rows}, StreamOptions{Format: "pdf"}); err == nil { + t.Fatal("expected missing PDF limit to fail") + } + if _, err := WriteTableStream(context.Background(), &output, &sliceRowIterator{rows: rows}, StreamOptions{Format: "pdf", MaxRows: 1}); err == nil || !strings.Contains(err.Error(), "maximum 1") { + t.Fatalf("expected PDF row limit error, got %v", err) + } +} + +func TestWriteTableStreamEmptyYAMLIsSequence(t *testing.T) { + var output bytes.Buffer + if _, err := WriteTableStream(context.Background(), &output, &sliceRowIterator{}, StreamOptions{Format: "yaml"}); err != nil { + t.Fatal(err) + } + if output.String() != "[]\n" { + t.Fatalf("unexpected empty yaml: %q", output.String()) + } +} + +func TestWriteTableStreamMillionStructuredRows(t *testing.T) { + if os.Getenv("CLICKY_MILLION_ROW_SMOKE") == "" { + t.Skip("set CLICKY_MILLION_ROW_SMOKE=1 to run the million-row export smoke test") + } + rows := &generatedStructuredRowIterator{count: 1_000_000} + count, err := WriteTableStream(context.Background(), io.Discard, rows, StreamOptions{Format: "ndjson"}) + if err != nil { + t.Fatal(err) + } + if count != 1_000_000 || rows.index != 1_000_000 { + t.Fatalf("count=%d generated=%d", count, rows.index) + } +} diff --git a/rpc/entities_test.go b/rpc/entities_test.go index a44f7e5b..07b0b82a 100644 --- a/rpc/entities_test.go +++ b/rpc/entities_test.go @@ -167,6 +167,62 @@ func TestEntityPagedList_ResponseEnvelopeAndHeaders(t *testing.T) { assert.Equal(t, int64(7), payload.Page.Total) } +func TestEntityPagedList_ClickyJSONUnwrapsToTable(t *testing.T) { + const name = "rpc-entity-paged-list-clicky-json-test" + clicky.NewEntity[testEntity, pagedListOpts, testEntity](name). + ListPaged(func(opts pagedListOpts) (clicky.PagedResult[testEntity], error) { + return clicky.NewPagedResult( + []testEntity{{ID: "5", Name: "five"}, {ID: "6", Name: "six"}}, + opts.Limit, + opts.Offset, + 7, + ), nil + }). + Register() + + root := &cobra.Command{Use: "testapp"} + clicky.GenerateCLI(root) + server := NewSwaggerServer( + &ServeConfig{ + Title: "t", + Version: "v", + SkipHealth: true, + Executor: &ExecutorConfig{ + Enabled: true, + PathPrefix: "/api/v1", + }, + }, + root, + &OpenAPIConfig{Title: "t", Version: "v"}, + ) + mux := http.NewServeMux() + server.RegisterExecutionRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/"+name+"?limit=2&offset=4", nil) + req.Header.Set("Accept", "application/json+clicky") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + // Paging still travels via headers, never the clicky body. + assert.Equal(t, "7", rr.Header().Get("X-Total-Count")) + assert.Equal(t, "2", rr.Header().Get("X-Page-Limit")) + assert.Equal(t, "4", rr.Header().Get("X-Page-Offset")) + + // The clicky document root is the table itself — not a {data, page} map — + // so the React DataTable/picker renders rows and marks attached ones. + var doc struct { + Version int `json:"version"` + Node struct { + Kind string `json:"kind"` + Rows []json.RawMessage `json:"rows"` + } `json:"node"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &doc)) + assert.Equal(t, "table", doc.Node.Kind) + assert.Len(t, doc.Node.Rows, 2) +} + // TestEntityRoot_RunnableListShortcut_OmitsGlobalFlags pins the default // behaviour that the bare entity command (`testapp `) is runnable as // its own list, exposed as GET /api/v1/, and that the promotion copies diff --git a/rpc/openapi.go b/rpc/openapi.go index 651c2f11..642720a1 100644 --- a/rpc/openapi.go +++ b/rpc/openapi.go @@ -172,6 +172,7 @@ type OpenAPISchema struct { Properties map[string]*OpenAPISchema `json:"properties,omitempty"` Required []string `json:"required,omitempty"` Items *OpenAPISchema `json:"items,omitempty"` + OneOf []*OpenAPISchema `json:"oneOf,omitempty"` AdditionalProperties *OpenAPISchema `json:"additionalProperties,omitempty"` Nullable bool `json:"nullable,omitempty"` Example interface{} `json:"example,omitempty"` diff --git a/rpc/serve.go b/rpc/serve.go index 5c0052a1..f6ab4556 100644 --- a/rpc/serve.go +++ b/rpc/serve.go @@ -823,8 +823,12 @@ func sanitizedAttachmentFilename(raw string) string { func shouldFormatPagedRows(format string) bool { switch format { - case "json", "clicky-json", "yaml", "yml": + // Data-exchange formats keep the full {data, page} envelope. + case "json", "yaml", "yml": return false + // Render formats (clicky-json, table, csv, html, pretty, markdown, pdf) + // unwrap to the bare rows: the document root is the table and paging travels + // via the X-Total-Count / X-Page-* headers set above. default: return true } @@ -864,6 +868,9 @@ func extractFormatOpts(r *http.Request) formatOptions { case "application/json": opts.Format = "json" return opts + case "application/x-ndjson", "application/ndjson": + opts.Format = "ndjson" + return opts case "application/clicky+json", "application/json+clicky": opts.Format = "clicky-json" return opts @@ -921,6 +928,8 @@ func formatToContentType(format string) string { return "application/json+clicky" case "yaml", "yml": return "application/yaml" + case "ndjson": + return "application/x-ndjson" case "csv": return "text/csv; charset=utf-8" case "html", "html-react": diff --git a/rpc/types.go b/rpc/types.go index 40604d5f..94d51b48 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -13,6 +13,7 @@ type ( ClickySpecMeta = entity.ClickySpecMeta ClickySurface = entity.ClickySurface ClickyOperationMeta = entity.ClickyOperationMeta + ExportMeta = entity.ExportMeta RPCParameter = entity.RPCParameter RPCService = entity.RPCService Schema = entity.Schema From 021d1b61b464e3599cc247861efa8b17fa9ce542 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Wed, 15 Jul 2026 15:38:15 +0300 Subject: [PATCH 23/23] build(db): Update commons-db dependency to v0.1.14 Refresh related transitive dependencies to align with the updated database module. --- aichat/go.mod | 21 ++++++++++++++------- aichat/go.sum | 12 ++++++------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/aichat/go.mod b/aichat/go.mod index 17248a50..3cdfb583 100644 --- a/aichat/go.mod +++ b/aichat/go.mod @@ -6,6 +6,7 @@ require ( github.com/firebase/genkit/go v1.10.0 github.com/flanksource/captain v0.0.9 github.com/flanksource/clicky v1.21.37 + github.com/flanksource/commons-db v0.1.14 github.com/spf13/cobra v1.10.2 ) @@ -294,13 +295,19 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + gocloud.dev v0.43.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.286.0 // indirect google.golang.org/genai v1.51.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect diff --git a/aichat/go.sum b/aichat/go.sum index 925482fa..f2a96bd5 100644 --- a/aichat/go.sum +++ b/aichat/go.sum @@ -252,8 +252,8 @@ github.com/flanksource/captain v0.0.9 h1:salnAHq7R7duv6vfyDfM31Lu22JsG1gNwO1NS7q github.com/flanksource/captain v0.0.9/go.mod h1:QPXC+P386XbR8m5vyg/qz4L1RXdcDJXsVspUuk6PSAA= github.com/flanksource/commons v1.53.1 h1:WiMvY9XGG//L4ndYKTcmp0NjWXg4B7Wbn4hYWkITUmY= github.com/flanksource/commons v1.53.1/go.mod h1:ZII22jIDJ3fd/Mz7l0SzLFEv8aoSUg+xwfJAAVf8up4= -github.com/flanksource/commons-db v0.1.13 h1:ds+QRLiiFtHk7ewEfQoaGcBcXqcJxG0qW7yploLbrr8= -github.com/flanksource/commons-db v0.1.13/go.mod h1:EGQjugWW+QYvZcBhleb9QzDs1xhNrezf9V1NKsofQ6g= +github.com/flanksource/commons-db v0.1.14 h1:XXTRrFmHEQQKlUVjUx1rq9lAE3CGhoVEQhevd6lfN3A= +github.com/flanksource/commons-db v0.1.14/go.mod h1:EGQjugWW+QYvZcBhleb9QzDs1xhNrezf9V1NKsofQ6g= github.com/flanksource/gomplate/v3 v3.24.82 h1:22HOZYeNRMX40G8OF3qRAqRoR5Lkf8Vm4x3WDqugZkE= github.com/flanksource/gomplate/v3 v3.24.82/go.mod h1:NMMZkFsjbLy/8iY8Fip5N86Y0PP6lZeq+kmPwpVVIL0= github.com/flanksource/is-healthy v1.0.88 h1:ATQuKoNdp8Qfzf41/eMFajmT0qzOmZlZNG5eLK41RFo= @@ -754,10 +754,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=