diff --git a/.github/workflows/_publish-registry.yml b/.github/workflows/_publish-registry.yml index 69f843c98..0a25d39fe 100644 --- a/.github/workflows/_publish-registry.yml +++ b/.github/workflows/_publish-registry.yml @@ -102,100 +102,10 @@ jobs: cat iii-engine.log || true exit 1 - # The harness bundle contains sub-workers (auth-credentials, - # provider-config) that call `database::execute` during - # registration to CREATE TABLE their backing stores. Without a - # running `iii-database` worker the harness aborts at boot with - # `function_not_found`, so we never reach interface collection. - # Fetch the latest published `database` release binary and start - # it before the harness bundle so the trigger resolves. - # - # Snapshotting the trigger baseline AFTER this step keeps - # `database::*` out of the published harness interface. - - name: Start dependency workers (harness) - if: inputs.worker == 'harness' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - # Resolve the most recent non-prerelease `database/v*` tag. - # gh release list returns newest-first; the registry tag we - # publish under is what production consumers actually pull, - # so match it here too. - db_tag=$(gh release list \ - --repo "$REPO" \ - --limit 100 \ - --exclude-drafts \ - --exclude-pre-releases \ - --json tagName \ - --jq '[.[] | select(.tagName | startswith("database/v"))][0].tagName') - - if [[ -z "$db_tag" || "$db_tag" == "null" ]]; then - echo "::error::could not resolve latest database/v* release tag" - exit 1 - fi - echo "Using database release: $db_tag" - - asset_url="https://github.com/$REPO/releases/download/$db_tag/database-x86_64-unknown-linux-gnu.tar.gz" - echo "Fetching database binary: $asset_url" - curl -fsSL "$asset_url" -o /tmp/database-bin.tar.gz - - db_dir="/tmp/iii-database" - rm -rf "$db_dir" - mkdir -p "$db_dir" - tar -xzf /tmp/database-bin.tar.gz -C "$db_dir" - chmod +x "$db_dir/database" - - # The pool name must match the harness database_name default - # (see harness/src/runtime/storage-config.ts DEFAULT_DATABASE_NAME). - # SQLite is sufficient for interface collection -- no data is - # persisted past the job. - cat > "$db_dir/config.yaml" <<'CFG' - databases: - harness: - url: sqlite:./iii.db - pool: - max: 4 - idle_timeout_ms: 30000 - acquire_timeout_ms: 5000 - CFG - - db_log="$PWD/iii-database.log" - pushd "$db_dir" >/dev/null - ./database > "$db_log" 2>&1 & - echo "$!" > "$PWD/database.pid" - popd >/dev/null - cp "$db_dir/database.pid" iii-database.pid - - # Wait for the database worker to register database::execute - # by issuing a trivial roundtrip. The engine returns - # function_not_found until registration completes. - ready=0 - for _ in {1..30}; do - if ! kill -0 "$(cat iii-database.pid)" 2>/dev/null; then - echo "::error::iii-database exited before becoming ready" - tail -n 200 "$db_log" || true - exit 1 - fi - if iii trigger 'database::execute' \ - --json '{"db":"harness","sql":"SELECT 1","params":[]}' \ - >/tmp/iii-database-ping.json 2>/tmp/iii-database-ping.err; then - ready=1 - break - fi - sleep 1 - done - - if [[ "$ready" != "1" ]]; then - echo "::error::iii-database did not register database::execute in time" - cat /tmp/iii-database-ping.err || true - tail -n 200 "$db_log" || true - exit 1 - fi - echo "iii-database ready" - + # The harness bundle no longer depends on the `database` worker — + # provider credentials/settings + permissions live in the built-in + # `configuration` worker (engine-default-enabled), so no dependency + # worker needs to be started before interface collection. - name: Snapshot engine trigger types baseline run: | set -euo pipefail diff --git a/README.md b/README.md index 6adf923a5..242993c90 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ asset for the host from the workers registry API. | Worker | Kind | Summary | |---|---|---| | [`acp`](acp/) | Rust | Agent Client Protocol surface — stdio JSON-RPC, exposes iii agents as ACP sessions. | -| [`harness`](harness/) | Node | TS port of the iii harness stack — bundles `harness`, `turn-orchestrator`, `approval-gate`, `session`, `hook-fanout`, `auth-credentials`, `models-catalog`, `provider-anthropic`, `provider-openai`, `llm-budget`, and `context-compaction` as one pnpm monorepo. See [`harness/README.md`](harness/README.md). | +| [`harness`](harness/) | Node | TS port of the iii harness stack — bundles `harness` (provider registry + credentials/settings/permissions via the `configuration` worker), `turn-orchestrator`, `approval-gate`, `session`, `hook-fanout`, `models-catalog`, the `provider-*` workers, `llm-budget`, and `context-compaction` as one pnpm monorepo. See [`harness/README.md`](harness/README.md). | | [`database`](database/) | Rust | PostgreSQL, MySQL, and SQLite client — query, execute, transactions, prepared statements, and change feeds. | | [`iii-directory`](iii-directory/) | Rust | Engine introspection (functions / triggers / workers), workers-registry proxy, and filesystem-backed skill + prompt reader. | | [`iii-lsp`](iii-lsp/) | Rust | Language Server for iii function ids, trigger configs, and worker discovery. Autocomplete / hover across JS/TS, Python, Rust. | diff --git a/console/web/src/components/chat/ModelPicker.tsx b/console/web/src/components/chat/ModelPicker.tsx index b4d9c1e67..d97e20481 100644 --- a/console/web/src/components/chat/ModelPicker.tsx +++ b/console/web/src/components/chat/ModelPicker.tsx @@ -1,25 +1,18 @@ import * as SelectPrimitive from '@radix-ui/react-select' -import { Settings } from 'lucide-react' -import { useState } from 'react' -import { ProviderSettingsDialog } from '@/components/providers/ProviderSettingsDialog' -import { - ACTIVE_PROVIDERS, - type ActiveProvider, - ENV_VAR_MAP, -} from '@/components/providers/provider-registry' +import { RefreshCw, Settings } from 'lucide-react' import { Select, type SelectGroup } from '@/components/ui/Select' +import { useConversationsCtxOptional } from '@/lib/conversations-context' +import { cn } from '@/lib/utils' import { CATALOG_MODEL_KEY_SEP, type ModelId, type ModelOption, } from '@/types/chat' -const ENV_VAR_BY_ID = new Map(ENV_VAR_MAP) -const ACTIVE_PROVIDER_SET: ReadonlySet = new Set(ACTIVE_PROVIDERS) - -function isActiveProvider(id: string): id is ActiveProvider { - return ACTIVE_PROVIDER_SET.has(id) -} +// Deep link to the harness configuration entry in the workers/config editor, +// where api keys + per-provider settings are now edited (the bespoke +// per-provider dialog was retired in favour of the schema-driven form). +const HARNESS_CONFIG_HASH = '#/configuration/workers/harness' interface ModelPickerProps { value: ModelId @@ -51,8 +44,14 @@ export function ModelPicker({ loading, className, }: ModelPickerProps) { - const [settingsProvider, setSettingsProvider] = - useState(null) + // Optional: present in the app, absent in isolated Storybook renders. + const ctx = useConversationsCtxOptional() + + // Providers present as harness workers (from harness::provider::list). + // Absent in Storybook or before the list resolves, in which case no empty + // provider groups or gears appear until the dynamic list arrives. + const presentIds = ctx?.presentProviders.map((p) => p.id) ?? [] + const presentSet = new Set(presentIds) const pickerOptions = options.length > 0 ? options : [{ id: value, label: value }] @@ -60,55 +59,84 @@ export function ModelPicker({ ? value : pickerOptions[0].id + // Groups from the registered models, plus an empty group for each present + // provider that has no models yet (present-but-unconfigured) so it still + // shows up with a gear to open its configuration. + const modelGroups = groupByProvider(pickerOptions) + const grouped = new Set(modelGroups.map((g) => g.label)) + const emptyGroups: SelectGroup[] = presentIds + .filter((id) => !grouped.has(id)) + .map((id) => ({ label: id, options: [] })) + const groups = [...modelGroups, ...emptyGroups].sort((a, b) => + a.label.localeCompare(b.label), + ) + return ( - <> + value={safeValue} - groups={groupByProvider(pickerOptions)} + groups={groups} onChange={onChange} disabled={disabled || loading} aria-label={loading ? 'model (loading catalog)' : 'model'} aria-busy={loading || undefined} className={className} - renderGroupHeader={(g) => ( -
- - {g.label} - - {isActiveProvider(g.label) ? ( - - ) : null} -
- )} + renderGroupHeader={(g) => { + const unconfigured = g.options.length === 0 + return ( +
+ + + {g.label} + + {unconfigured ? ( + + not configured + + ) : null} + + {presentSet.has(g.label) ? ( + + ) : null} +
+ ) + }} /> - {settingsProvider ? ( - { - if (!open) setSettingsProvider(null) + {ctx ? ( + ) : null} - +
) } diff --git a/console/web/src/components/providers/ProviderRow.tsx b/console/web/src/components/providers/ProviderRow.tsx deleted file mode 100644 index de83664fe..000000000 --- a/console/web/src/components/providers/ProviderRow.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Settings } from 'lucide-react' -import { useEffect, useState } from 'react' -import { ProviderSettingsDialog } from '@/components/providers/ProviderSettingsDialog' -import { - type ActiveProvider, - isLocalProvider, - PROVIDER_DISPLAY, -} from '@/components/providers/provider-registry' -import { StatusBadge } from '@/components/providers/StatusBadge' -import { useAuthStatus, useProviderConfig } from '@/hooks/use-providers' -import { cn } from '@/lib/utils' - -interface ProviderRowProps { - id: ActiveProvider - envVar: string - /** - * Position in the active providers list (0-based). Displayed as a - * 1-based shortcut number prefix on the row so the keyboard binding - * (press N to open provider N) is self-documenting — no hidden - * footnote needed. - */ - index?: number -} - -/** - * One row per active provider. The entire row is the affordance: a single - * ` - - setSavedFlash(true)} - /> - - ) -} diff --git a/console/web/src/components/providers/ProviderSettingsDialog.tsx b/console/web/src/components/providers/ProviderSettingsDialog.tsx deleted file mode 100644 index fc0cc266e..000000000 --- a/console/web/src/components/providers/ProviderSettingsDialog.tsx +++ /dev/null @@ -1,969 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { ChevronDown, ChevronUp, Eye, EyeOff, X } from 'lucide-react' -import { useEffect, useId, useMemo, useRef, useState } from 'react' -import { - type ActiveProvider, - isLocalProvider, - localBaseUrlEnv, - PROVIDER_DEFAULTS, - PROVIDER_DISPLAY, - PROVIDER_DOCS, - PROVIDER_DOCS_LABEL, - PROVIDER_KEY_PLACEHOLDER, -} from '@/components/providers/provider-registry' -import { StatusBadge } from '@/components/providers/StatusBadge' -import { Button } from '@/components/ui/Button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from '@/components/ui/Dialog' -import { Input } from '@/components/ui/Input' -import { - useAuthStatus, - useClearProviderConfig, - useDeleteToken, - useProviderConfig, - useSetProviderConfig, - useSetToken, -} from '@/hooks/use-providers' -import { - normalizeErrorMessage, - validateApiUrl, - validateMaxTokens, -} from '@/lib/providers' -import { cn } from '@/lib/utils' - -interface ProviderSettingsDialogProps { - provider: ActiveProvider - envVar: string - open: boolean - onOpenChange: (open: boolean) => void - /** Fires after any successful mutation so the row can flash. */ - onMutated?: () => void -} - -/** - * Errors surfaced inside the dialog always carry a recovery hint. The - * raw message names what failed; the hint tells the operator what to try - * next. Different mutations get different hints because the recovery - * path differs (key format vs URL reachability vs harness liveness). - */ -interface SurfaceError { - message: string - hint: string -} - -/** - * Post-destructive-action receipt. Holds the previous state so a `reset - * overrides` can be undone within the receipt window; `remove key` has - * no true undo (the credential isn't available client-side) so we only - * show a receipt — the operator can re-type the key in the field above. - * - * `expiresAt` (reset only) drives a visible countdown so operators - * know how long they have to act. Without it the affordance would be - * silently ephemeral — operators relying on it would discover its - * expiry by accident. - */ -type UndoState = - | { - kind: 'reset' - prev: { default_api_url?: string; default_max_tokens?: number } - expiresAt: number - } - | { kind: 'remove' } - | null - -const UNDO_WINDOW_MS = 8000 - -/** - * Single dialog with a single primary `save`. Reads the current credential - * status and stored overrides, lets the user touch any of them in a flat - * layout, then commits everything that's actually dirty in parallel on - * save. Advanced fields (base url + max tokens) live behind a "show - * advanced" disclosure but auto-expand whenever a non-default override is - * already stored, so the user always sees what's already in effect. - * - * Destructive surfaces (`reset overrides`, `remove key`) use an - * armed-confirm pattern -- one click reveals a confirmation chip, second - * click commits. Mirrors the `remove credential` pattern from earlier so - * nothing in this dialog is a one-tap footgun. - */ -export function ProviderSettingsDialog({ - provider, - envVar, - open, - onOpenChange, - onMutated, -}: ProviderSettingsDialogProps) { - const qc = useQueryClient() - const statusQuery = useAuthStatus(provider) - const status = statusQuery.data - const isStored = status?.source === 'stored' - const isConfigured = status?.configured ?? false - const defaults = PROVIDER_DEFAULTS[provider] - const docsUrl = PROVIDER_DOCS[provider] - - const configQuery = useProviderConfig(provider) - const setTokenMut = useSetToken() - const setConfigMut = useSetProviderConfig() - const clearConfigMut = useClearProviderConfig() - const deleteTokenMut = useDeleteToken() - - // Waits for the post-mutation refetch to settle before resolving so the - // dialog only closes once the row badge will render the new state. Without - // this, the dialog closes -> row flashes -> badge revalidation lags by one - // poll cycle, leaving the success moment desynchronized. - async function waitForFreshStatus() { - await Promise.all([ - qc.refetchQueries({ queryKey: ['provider', 'status', provider] }), - qc.refetchQueries({ queryKey: ['provider', 'config', provider] }), - ]) - } - - // --- form state --------------------------------------------------------- - - const [key, setKey] = useState('') - const [revealed, setRevealed] = useState(false) - const [apiUrl, setApiUrl] = useState('') - const [maxTokens, setMaxTokens] = useState('') - const [urlError, setUrlError] = useState(null) - const [maxTokensError, setMaxTokensError] = useState(null) - - const storedUrl = configQuery.data?.default_api_url ?? '' - const storedMaxTokens = - configQuery.data?.default_max_tokens != null - ? String(configQuery.data.default_max_tokens) - : '' - const hasOverride = !!storedUrl || !!storedMaxTokens - - // Populate from server overrides + reset key field every time the dialog - // re-opens or the provider changes. - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional dependency list — see the note inside the effect about mutation-hook identity churn - useEffect(() => { - if (!open) return - if (configQuery.isLoading) return - setKey('') - setRevealed(false) - setApiUrl(storedUrl) - setMaxTokens(storedMaxTokens) - setUrlError(null) - setMaxTokensError(null) - setShowAdvanced(hasOverride) - setArmed(null) - setUndo(null) - setTokenMut.reset() - setConfigMut.reset() - clearConfigMut.reset() - deleteTokenMut.reset() - // We only re-run on (open, provider, configQuery.data, configQuery.isLoading) - // because the mutation hooks change identity on every render. Including them - // (or the configQuery.data-derived stored* values) would wipe form state mid-save. - }, [open, provider, configQuery.data, configQuery.isLoading]) - - // --- collapsible advanced + armed confirm states ----------------------- - - const [showAdvanced, setShowAdvanced] = useState(hasOverride) - type Armed = 'reset' | 'remove' | null - const [armed, setArmed] = useState(null) - const [undo, setUndo] = useState(null) - const [nowTick, setNowTick] = useState(() => Date.now()) - const dialogBodyRef = useRef(null) - - // Tick every 500ms while a reset undo is active so the countdown - // label can re-render. Stops the moment the undo is consumed or - // expires (so we don't burn CPU on a dialog with no undo state). - useEffect(() => { - if (undo?.kind !== 'reset') return - const id = setInterval(() => setNowTick(Date.now()), 500) - return () => clearInterval(id) - }, [undo?.kind]) - - // Auto-clear the RESET receipt after the undo window closes — only - // for resets, because that's the action where undo actually does - // something (re-saves the snapshotted config). For REMOVE there's no - // true undo (credential isn't kept client-side), so a countdown - // would be a false-affordance: the operator watches a timer for an - // action they can't take. Instead the remove receipt persists until - // the user closes the dialog, framed as actionable guidance ("type - // the key above to re-add") rather than a recovery window. - useEffect(() => { - if (undo?.kind !== 'reset') return - const t = setTimeout(() => setUndo(null), UNDO_WINDOW_MS) - return () => clearTimeout(t) - }, [undo]) - - // Stable input ids for proper