From 69d97aa0cdb3b5db8ea0e4dd10227c96e5e8b05f Mon Sep 17 00:00:00 2001 From: telli Date: Thu, 16 Apr 2026 11:58:32 -0700 Subject: [PATCH 1/8] feat: add workspace knowledge, memory, and ACP editor flows --- Directory.Packages.props | 4 +- README.md | 20 +- docs/acp.md | 30 +- docs/providers.md | 27 +- docs/runtime.md | 13 +- extensions/vscode/README.md | 29 + extensions/vscode/package.json | 83 +++ extensions/vscode/src/extension.ts | 321 +++++++++ extensions/vscode/tsconfig.json | 17 + .../AcpApprovalCoordinator.cs | 125 ++++ .../AcpApprovalTransport.cs | 19 + .../AcpServiceCollectionExtensions.cs | 21 + src/SharpClaw.Code.Acp/AcpStdioHost.cs | 325 +++++++-- .../SharpClaw.Code.Acp.csproj | 3 + .../Services/AgentFrameworkBridge.cs | 6 +- .../CliServiceCollectionExtensions.cs | 7 +- .../Handlers/IndexCommandHandler.cs | 120 +++ .../Handlers/MemoryCommandHandler.cs | 177 +++++ .../Handlers/ModelsCommandHandler.cs | 40 +- .../SharpClaw.Code.Commands.csproj | 1 + .../Abstractions/IMemoryRecallService.cs | 18 + .../Abstractions/IPersistentMemoryStore.cs | 33 + .../Abstractions/IWorkspaceIndexService.cs | 19 + .../Abstractions/IWorkspaceKnowledgeStore.cs | 74 ++ .../Abstractions/IWorkspaceSearchService.cs | 17 + .../MemoryServiceCollectionExtensions.cs | 5 + .../Models/WorkspaceKnowledgeRecords.cs | 63 ++ .../Services/HashTextEmbeddingService.cs | 110 +++ .../Services/MemoryRecallService.cs | 68 ++ .../Services/PersistentMemoryStore.cs | 31 + .../Services/SqliteWorkspaceKnowledgeStore.cs | 681 ++++++++++++++++++ .../Services/WorkspaceIndexService.cs | 291 ++++++++ .../Services/WorkspaceSearchService.cs | 76 ++ .../SharpClaw.Code.Memory.csproj | 2 + .../Abstractions/IApprovalTransport.cs | 23 + .../Services/ApprovalService.cs | 15 +- .../Services/ConsoleApprovalService.cs | 3 +- .../Services/NonInteractiveApprovalService.cs | 3 +- .../Services/PermissionPolicyEngine.cs | 2 +- .../Models/ApprovalDecision.cs | 4 +- .../Models/KnowledgeModels.cs | 258 +++++++ .../Models/OpenCodeParityModels.cs | 10 +- .../Serialization/ProtocolJsonContext.cs | 18 + .../Abstractions/IProviderCatalogService.cs | 16 + .../AnthropicProvider.cs | 6 +- .../LocalRuntimeProfileOptions.cs | 49 ++ .../OpenAiCompatibleProviderOptions.cs | 27 + .../ProviderOptionsValidators.cs | 19 + .../Internal/ProviderAuthStatusFactory.cs | 13 +- .../OpenAiCompatibleProvider.cs | 46 +- .../ProvidersServiceCollectionExtensions.cs | 3 + .../Services/ProviderCatalogService.cs | 183 +++++ .../Services/ProviderRequestPreflight.cs | 33 + .../RuntimeServiceCollectionExtensions.cs | 1 + .../Context/PromptContextAssembler.cs | 33 +- .../Checks/LocalRuntimeCatalogCheck.cs | 48 ++ .../Prompts/PromptReferenceResolver.cs | 6 +- .../Workflow/ConversationCompactionService.cs | 18 + .../BuiltIn/SymbolSearchTool.cs | 46 ++ .../BuiltIn/WorkspaceSearchTool.cs | 50 ++ .../Models/ToolContracts.cs | 16 + .../SharpClaw.Code.Tools.csproj | 1 + .../ToolsServiceCollectionExtensions.cs | 7 + .../Smoke/CliCommandSurfaceTests.cs | 2 + .../Acp/AcpStdioHostTests.cs | 292 +++++++- .../Commands/FeatureCommandHandlersTests.cs | 138 ++++ .../WorkspaceKnowledgeServicesTests.cs | 128 ++++ .../PermissionPolicyEngineTests.cs | 3 +- .../Providers/ProviderCatalogServiceTests.cs | 131 ++++ .../ProviderConfigurationBindingTests.cs | 20 +- .../Runtime/LocalRuntimeCatalogCheckTests.cs | 61 ++ .../ShareAndCompactionServicesTests.cs | 22 +- 72 files changed, 4477 insertions(+), 153 deletions(-) create mode 100644 extensions/vscode/README.md create mode 100644 extensions/vscode/package.json create mode 100644 extensions/vscode/src/extension.ts create mode 100644 extensions/vscode/tsconfig.json create mode 100644 src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs create mode 100644 src/SharpClaw.Code.Acp/AcpApprovalTransport.cs create mode 100644 src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs create mode 100644 src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IMemoryRecallService.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IPersistentMemoryStore.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IWorkspaceIndexService.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IWorkspaceKnowledgeStore.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IWorkspaceSearchService.cs create mode 100644 src/SharpClaw.Code.Memory/Models/WorkspaceKnowledgeRecords.cs create mode 100644 src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs create mode 100644 src/SharpClaw.Code.Memory/Services/MemoryRecallService.cs create mode 100644 src/SharpClaw.Code.Memory/Services/PersistentMemoryStore.cs create mode 100644 src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs create mode 100644 src/SharpClaw.Code.Memory/Services/WorkspaceIndexService.cs create mode 100644 src/SharpClaw.Code.Memory/Services/WorkspaceSearchService.cs create mode 100644 src/SharpClaw.Code.Permissions/Abstractions/IApprovalTransport.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/KnowledgeModels.cs create mode 100644 src/SharpClaw.Code.Providers/Abstractions/IProviderCatalogService.cs create mode 100644 src/SharpClaw.Code.Providers/Configuration/LocalRuntimeProfileOptions.cs create mode 100644 src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs create mode 100644 src/SharpClaw.Code.Runtime/Diagnostics/Checks/LocalRuntimeCatalogCheck.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/SymbolSearchTool.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/WorkspaceSearchTool.cs create mode 100644 tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 09e29fc..e6fadd8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,9 +21,11 @@ + + - \ No newline at end of file + diff --git a/README.md b/README.md index 4a74792..ea5030a 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ dotnet run --project src/SharpClaw.Code.Cli -- prompt "Summarize this workspace" dotnet run --project src/SharpClaw.Code.Cli -- doctor dotnet run --project src/SharpClaw.Code.Cli -- status +# Refresh and query the workspace knowledge index +dotnet run --project src/SharpClaw.Code.Cli -- index refresh +dotnet run --project src/SharpClaw.Code.Cli -- index query WidgetService + +# Save and inspect durable memory +dotnet run --project src/SharpClaw.Code.Cli -- memory save --scope project "Keep prompts concise" +dotnet run --project src/SharpClaw.Code.Cli -- memory list --scope project + # Emit machine-readable output dotnet run --project src/SharpClaw.Code.Cli -- --output-format json doctor ``` @@ -73,6 +81,8 @@ Built-in REPL slash commands include `/help`, `/status`, `/doctor`, `/session`, Parity-oriented commands now include: +- `index` / `/index` +- `memory` / `/memory` - `models` / `/models` - `usage` / `/usage` - `cost` / `/cost` @@ -101,8 +111,11 @@ Primary workflow modes: | Durable sessions | Persist conversation state, turn history, checkpoints, and recovery metadata for longer-running agent work | | Permission-aware tools | Route file, shell, and plugin-backed actions through explicit policy and approval decisions | | Provider abstraction | Run against Anthropic and OpenAI-compatible backends through a typed runtime surface | +| Local runtime catalog | Surface Ollama, llama.cpp, and other OpenAI-compatible profiles with health, model discovery, and embedding defaults | | MCP support | Register, supervise, and integrate MCP servers with explicit lifecycle state | | Plugins and skills | Extend the runtime with trusted plugin manifests and discoverable workspace skills | +| Workspace knowledge | Build a durable local index for lexical, symbol, and semantic workspace search | +| Cross-session memory | Persist project and user memory so later sessions can recall repo-specific guidance and user preferences | | Structured telemetry | Emit runtime events and usage signals that support diagnostics, replay, and automation | | JSON-friendly CLI | Use the same runtime through human-readable terminal flows or machine-readable command output | | Spec workflow mode | Turn prompts into structured requirements, technical design, and task documents for feature proposals | @@ -168,7 +181,7 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests" | `--session ` | Reuse a specific SharpClaw session id for prompt execution | | `--agent ` | Select the active agent for prompt execution | -Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`. +Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`. ## Documentation Map @@ -205,7 +218,7 @@ Key runtime configuration sections: |---|---| | `SharpClaw:Providers:Catalog` | Default provider, model aliases | | `SharpClaw:Providers:Anthropic` | Anthropic API key, base URL, default model | -| `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible API key, base URL, default model | +| `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible base settings plus local runtime profiles, auth mode, and default embedding model | | `SharpClaw:Web` | Web search provider name, endpoint template, user agent | | `SharpClaw:Telemetry` | Runtime event ring buffer capacity | @@ -226,6 +239,9 @@ All options are validated at startup via `IValidateOptions` implementations. - The shared tooling layer is permission-aware across the runtime. - The current runtime includes multi-turn provider-backed tool execution, session-backed prompt replay, and durable conversation history. - Agent-driven tool calls flow through the same approval and allowlist enforcement path used by direct tool execution, including caller-aware interactive approval behavior. +- Workspace indexing, symbol search, and durable memory are available through both CLI commands and built-in tools. +- ACP now carries editor context, approval round-trips, model catalog queries, workspace search/index actions, and memory actions, which is enough for a real VS Code client over a single transport. +- OpenAI-compatible local runtime profiles can surface Ollama, llama.cpp, and similar endpoints with profile-aware auth and model discovery. - Operational commands support stable JSON output via `--output-format json`, which makes them suitable for scripts, editors, and automation. - The embedded server exposes local JSON and SSE endpoints for prompts, sessions, sharing, status, and doctor flows. diff --git a/docs/acp.md b/docs/acp.md index 1ce5067..3f248c9 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -17,18 +17,42 @@ Reads **one JSON-RPC request per line** from stdin; writes **one response object | `initialize` | Returns `protocolVersion`, `agentCapabilities`, and `serverInfo`. | | `session/new` | Creates a session; updates workspace attachment when applicable. | | `session/load` | Loads an existing session id. | -| `session/prompt` | Runs a turn via **`IConversationRuntime`**; may stream **`session/notification`** lines with chunks before the final result. | +| `session/prompt` | Runs a turn via **`IConversationRuntime`**; accepts `cwd`, `sessionId`, `prompt`, optional `model`, and optional `editorContext`. | +| `models/list` | Returns the provider catalog, including discovered local runtime profiles and models. | +| `workspace/index/refresh` | Refreshes the durable workspace knowledge index for `cwd`. | +| `workspace/search` | Executes hybrid workspace search against indexed files, symbols, and semantic chunks. | +| `memory/list` | Lists structured project/user memory entries. | +| `memory/save` | Saves a structured memory entry. | +| `memory/delete` | Deletes a structured memory entry. | +| `approval/respond` | Resolves a pending approval request emitted by the ACP host. | ## Capabilities / limits -The host advertises **`loadSession: true`** and **`promptCapabilities.embeddedContext: true`**; **`image`** and **`audio`** are **`false`**. +The host advertises: -**Intentionally unsupported** (errors or omissions vs full vendor ACP): streaming tool execution, interactive permission UI, rich media parts, MCP hot-plug, cancellation reliability guarantees, and non-core extensions. Callers should treat unknown methods as **unsupported** (JSON-RPC error). +- `loadSession: true` +- `approvalRequests: true` +- `models: true` +- `workspaceSearch: true` +- `workspaceIndex: true` +- `memory: true` +- `promptCapabilities.embeddedContext: true` + +`image` and `audio` remain `false`. + +Notifications use `session/notification` and currently include: + +- streamed assistant text chunks (`sessionUpdate = "agentMessageChunk"`) +- approval prompts (`sessionUpdate = "approvalRequest"`) + +**Intentionally unsupported** (errors or omissions vs full vendor ACP): streaming tool execution details, rich media parts, MCP hot-plug, cancellation reliability guarantees, and non-core extensions. Callers should treat unknown methods as **unsupported** (JSON-RPC error). ## Session attachment Behavior aligns with **`IWorkspaceSessionAttachmentStore`** and **`RunPromptRequest.SessionId`**: create/load sets attachment; prompts resolve cwd via params when provided. +When the client advertises approval support during `initialize`, ACP-driven prompts can participate in the same permission and approval flow as interactive CLI callers without opening a second transport. + ## Implementation See **`SharpClaw.Code.Acp`** / **`AcpStdioHost`**. diff --git a/docs/providers.md b/docs/providers.md index 6d7a7f7..bf42ad6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -13,12 +13,20 @@ Registered implementations (see **`ProvidersServiceCollectionExtensions`**): Both are registered as **`IModelProvider`** singletons; **`ModelProviderResolver`** builds a case-insensitive dictionary by **`ProviderName`**. +The provider layer also exposes **`IProviderCatalogService`**, which powers the CLI `models` command and ACP `models/list`. It centralizes: + +- provider auth status +- alias/default resolution +- discovered local runtime profiles +- model discovery for local runtimes + ## Resolution and preflight **`ProviderRequestPreflight`** (`IProviderRequestPreflight`) normalizes **`ProviderRequest`**: - Applies **`ProviderCatalogOptions.ModelAliases`** (e.g. `"default"` → provider + model id). - Supports qualified model forms (implementation parses `provider/model`). +- Also supports local runtime forms such as `ollama/qwen2.5-coder`, which route through the OpenAI-compatible provider with profile metadata attached. - Fills default **provider name** from **`ProviderCatalogOptions.DefaultProvider`** when missing. Default catalog (**`ProviderCatalogOptions`**) uses **`DefaultProvider = "openai-compatible"`** if not configured. @@ -31,14 +39,31 @@ When using **`AddSharpClawRuntime(IConfiguration)`** (CLI host): |---------|----------------| | `SharpClaw:Providers:Catalog` | **`ProviderCatalogOptions`** | | `SharpClaw:Providers:Anthropic` | **`AnthropicProviderOptions`** (`ProviderName` defaults to `"anthropic"`, `BaseUrl`, API key binding as in options class) | -| `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`) | +| `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`, supports auth mode, default embedding model, and named `LocalRuntimes`) | There is no checked-in **`appsettings.json`** in the repo; add one next to the CLI project or rely on environment variables / user secrets per standard .NET configuration. +## Local runtimes + +`OpenAiCompatibleProviderOptions.LocalRuntimes` supports named profiles for local or self-hosted runtimes such as Ollama and llama.cpp. + +Each profile carries: + +- runtime kind (`Generic`, `Ollama`, `LlamaCpp`) +- base URL +- default chat model +- optional default embedding model +- auth mode (`ApiKey`, `Optional`, `None`) +- capability hints for tool calling and embeddings + +At runtime the catalog service probes these profiles and surfaces health plus discovered models. Local runtimes do not assume API-key auth by default. + ## Auth **`IAuthFlowService`** / **`AuthFlowService`** answer whether a provider name is authenticated (used by **`ProviderBackedAgentKernel`**). If not authenticated, the kernel may return a **placeholder** completion (see kernel logs) rather than calling the remote API. +For the OpenAI-compatible provider, auth status now respects provider auth mode plus any configured auth-optional local runtimes. + Hard failures use **`ProviderExecutionException`** with **`ProviderFailureKind`**: **`MissingProvider`**, **`AuthenticationUnavailable`**, **`StreamFailed`**. ## Adding a provider diff --git a/docs/runtime.md b/docs/runtime.md index f819ddb..1d44e6a 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -42,7 +42,7 @@ This means the model-visible tool surface and the executor-visible tool surface ## Context assembly -**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, memory hooks, git context as wired today) into the prompt path before the agent runs. +**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, durable project/user memory, workspace index status, and git context) into the prompt path before the agent runs. It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces. @@ -55,6 +55,14 @@ When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a stru Conversation history is rebuilt from persisted session events and truncated by token budget before being attached to the next provider request. Assistant history prefers the persisted final turn output and only falls back to streamed provider deltas when needed. +Cross-session memory is sourced from: + +- manual project instructions such as `SHARPCLAW.md` +- structured project memory stored under `.sharpclaw/knowledge/knowledge.db` +- structured user memory stored under the SharpClaw user root + +The runtime injects only compact recall text and index freshness metadata. Detailed retrieval happens through explicit workspace-search tools and ACP/CLI search calls. + ## Spec workflow **`ISpecWorkflowService`** handles the post-processing path for **`spec`** mode: @@ -73,7 +81,7 @@ Each spec-mode prompt creates a fresh folder. If the same slug already exists, t **`OperationalDiagnosticsCoordinator`** runs injectable **`IOperationalCheck`** implementations: -- Workspace, configuration, session store, shell, git, provider auth, MCP registry/host, plugin registry. +- Workspace, configuration, session store, shell, git, provider auth, local runtime catalog health, MCP registry/host, plugin registry. Used by **`GetStatusAsync`**, **`RunDoctorAsync`**, and **`InspectSessionAsync`** to build **Protocol** reports (`DoctorReport`, `RuntimeStatusReport`, `SessionInspectionReport`). @@ -90,6 +98,7 @@ The parity layer adds several runtime-owned services: - **`ISharpClawConfigService`** — loads user/workspace `config.jsonc` + `sharpclaw.jsonc` and merges them by precedence - **`IAgentCatalogService`** — overlays configured specialist agents on top of built-in agents - **`IConversationCompactionService`** — creates durable session summaries stored in session metadata +- compaction also promotes a project-scoped summary memory entry for later session recall - **`IShareSessionService`** — creates and removes self-hosted share snapshots - **`IHookDispatcher`** — executes configured hook processes for turn/tool/share/server events and exposes hook inspection/testing - **`ITodoService`** — persists session and workspace todo items under session metadata and `.sharpclaw/tasks.json` diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md new file mode 100644 index 0000000..c807f75 --- /dev/null +++ b/extensions/vscode/README.md @@ -0,0 +1,29 @@ +# SharpClaw Code VS Code Extension + +This extension launches the SharpClaw ACP host as a subprocess and uses it as the single transport for prompts, workspace search, memory management, and approval requests. + +Available commands: + +- `SharpClaw: Prompt` +- `SharpClaw: Refresh Index` +- `SharpClaw: Search Workspace` +- `SharpClaw: Save Memory` +- `SharpClaw: List Memory` +- `SharpClaw: Select Model` + +What it currently does: + +- sends the active editor file and selection as `editorContext` on prompt requests +- streams assistant output into the SharpClaw output channel +- surfaces approval prompts from the ACP host and routes the response back over `approval/respond` +- refreshes and queries the workspace knowledge index +- saves and lists structured project/user memory +- lists models from the provider catalog, including local runtime profiles exposed through the OpenAI-compatible provider + +By default the extension runs: + +```bash +dotnet run --project src/SharpClaw.Code.Cli -- acp +``` + +Override that command through the `sharpClaw.cliCommand` and `sharpClaw.cliArgs` settings if you want to point the extension at an installed CLI or a different workspace layout. diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json new file mode 100644 index 0000000..43068b5 --- /dev/null +++ b/extensions/vscode/package.json @@ -0,0 +1,83 @@ +{ + "name": "sharpclaw-code", + "displayName": "SharpClaw Code", + "description": "VS Code integration for the SharpClaw ACP runtime.", + "version": "0.1.0", + "publisher": "clawdotnet", + "engines": { + "vscode": "^1.100.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onCommand:sharpClaw.prompt", + "onCommand:sharpClaw.refreshIndex", + "onCommand:sharpClaw.searchWorkspace", + "onCommand:sharpClaw.saveMemory", + "onCommand:sharpClaw.listMemory", + "onCommand:sharpClaw.selectModel" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "sharpClaw.prompt", + "title": "SharpClaw: Prompt" + }, + { + "command": "sharpClaw.refreshIndex", + "title": "SharpClaw: Refresh Index" + }, + { + "command": "sharpClaw.searchWorkspace", + "title": "SharpClaw: Search Workspace" + }, + { + "command": "sharpClaw.saveMemory", + "title": "SharpClaw: Save Memory" + }, + { + "command": "sharpClaw.listMemory", + "title": "SharpClaw: List Memory" + }, + { + "command": "sharpClaw.selectModel", + "title": "SharpClaw: Select Model" + } + ], + "configuration": { + "title": "SharpClaw Code", + "properties": { + "sharpClaw.cliCommand": { + "type": "string", + "default": "dotnet", + "description": "Command used to launch the SharpClaw ACP host." + }, + "sharpClaw.cliArgs": { + "type": "array", + "default": [ + "run", + "--project", + "src/SharpClaw.Code.Cli", + "--", + "acp" + ], + "items": { + "type": "string" + }, + "description": "Arguments passed to the SharpClaw CLI command." + } + } + } + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/vscode": "^1.100.0", + "typescript": "^5.8.3" + } +} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts new file mode 100644 index 0000000..25f994b --- /dev/null +++ b/extensions/vscode/src/extension.ts @@ -0,0 +1,321 @@ +import * as readline from "node:readline"; +import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +type JsonRpcResponse = { jsonrpc: string; id?: string | number; result?: unknown; error?: { code: number; message: string } }; +type ProviderCatalogEntry = { + providerName: string; + defaultModel: string; + availableModels?: Array<{ id: string; displayName: string }>; + localRuntimeProfiles?: Array<{ name: string; defaultChatModel: string; availableModels?: Array<{ id: string; displayName: string }> }>; +}; + +class AcpClient implements vscode.Disposable { + private process: ChildProcessWithoutNullStreams | undefined; + private initialized = false; + private nextId = 1; + private readonly pending = new Map(); + + constructor( + private readonly output: vscode.OutputChannel, + private readonly workspaceState: vscode.Memento, + private readonly context: vscode.ExtensionContext) {} + + dispose(): void { + this.process?.kill(); + this.process = undefined; + this.pending.clear(); + } + + async call(method: string, params: Record): Promise { + await this.ensureStarted(); + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + const response = await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.process!.stdin.write(payload + "\n", "utf8"); + }); + return response as T; + } + + async ensureSession(workspaceFolder: vscode.WorkspaceFolder): Promise { + const key = `session:${workspaceFolder.uri.toString()}`; + const existing = this.workspaceState.get(key); + if (existing) { + try { + await this.call("session/load", { cwd: workspaceFolder.uri.fsPath, sessionId: existing }); + return existing; + } catch { + } + } + + const created = await this.call<{ sessionId: string }>("session/new", { cwd: workspaceFolder.uri.fsPath }); + await this.workspaceState.update(key, created.sessionId); + return created.sessionId; + } + + private async ensureStarted(): Promise { + if (this.process && this.initialized) { + return; + } + + const config = vscode.workspace.getConfiguration("sharpClaw"); + const command = config.get("cliCommand", "dotnet"); + const args = config.get("cliArgs", ["run", "--project", "src/SharpClaw.Code.Cli", "--", "acp"]); + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? this.context.extensionPath; + this.process = spawn(command, args, { cwd, stdio: "pipe" }); + this.process.stderr.on("data", chunk => this.output.append(chunk.toString())); + const rl = readline.createInterface({ input: this.process.stdout }); + rl.on("line", line => this.handleLine(line)); + this.process.on("exit", code => { + for (const pending of this.pending.values()) { + pending.reject(new Error(`SharpClaw ACP exited with code ${code ?? 0}.`)); + } + this.pending.clear(); + this.initialized = false; + this.process = undefined; + }); + + await this.call("initialize", { clientCapabilities: { approvalRequests: true } }); + this.initialized = true; + } + + private handleLine(line: string): void { + if (!line.trim()) { + return; + } + + const message = JSON.parse(line) as JsonRpcResponse & { method?: string; params?: any }; + if (message.method === "session/notification") { + this.handleNotification(message.params); + return; + } + + if (typeof message.id !== "number") { + return; + } + + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message)); + return; + } + + pending.resolve(message.result); + } + + private async handleNotification(params: any): Promise { + const update = params?.update; + if (!update) { + return; + } + + if (update.sessionUpdate === "agentMessageChunk") { + const text = update.chunk?.content?.text; + if (typeof text === "string" && text.length > 0) { + this.output.appendLine(text); + this.output.show(true); + } + return; + } + + if (update.sessionUpdate === "approvalRequest") { + const approval = update.approval; + const choice = await vscode.window.showWarningMessage( + approval.prompt ?? `Approval required for ${approval.toolName}.`, + { modal: true }, + approval.canRememberDecision ? "Allow and Remember" : "Allow", + "Deny"); + await this.call("approval/respond", { + requestId: approval.requestId, + approved: choice === "Allow" || choice === "Allow and Remember", + remember: choice === "Allow and Remember" + }); + } + } +} + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("SharpClaw Code"); + const client = new AcpClient(output, context.workspaceState, context); + context.subscriptions.push(client, output); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.prompt", async () => { + const folder = requireWorkspaceFolder(); + const prompt = await vscode.window.showInputBox({ prompt: "Send a prompt to SharpClaw" }); + if (!prompt) { + return; + } + + const sessionId = await client.ensureSession(folder); + const model = context.workspaceState.get(`model:${folder.uri.toString()}`); + const editor = vscode.window.activeTextEditor; + const editorContext = editor ? buildEditorContext(editor, sessionId) : undefined; + output.show(true); + output.appendLine(`> ${prompt}`); + await client.call("session/prompt", { + cwd: folder.uri.fsPath, + sessionId, + model, + prompt, + editorContext + }); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.refreshIndex", async () => { + const folder = requireWorkspaceFolder(); + const result = await client.call<{ indexedFileCount: number }>("workspace/index/refresh", { cwd: folder.uri.fsPath }); + void vscode.window.showInformationMessage(`SharpClaw indexed ${result.indexedFileCount} file(s).`); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.searchWorkspace", async () => { + const folder = requireWorkspaceFolder(); + const query = await vscode.window.showInputBox({ prompt: "Search the indexed workspace" }); + if (!query) { + return; + } + + const result = await client.call<{ hits: Array<{ path: string; excerpt: string; startLine?: number }> }>("workspace/search", { + cwd: folder.uri.fsPath, + query, + limit: 20 + }); + + const picked = await vscode.window.showQuickPick( + result.hits.map(hit => ({ + label: hit.path, + description: hit.startLine ? `Line ${hit.startLine}` : undefined, + detail: hit.excerpt, + hit + })), + { placeHolder: "Workspace search results" }); + if (!picked) { + return; + } + + const target = vscode.Uri.file(path.join(folder.uri.fsPath, picked.hit.path)); + const document = await vscode.workspace.openTextDocument(target); + const editor = await vscode.window.showTextDocument(document); + if (picked.hit.startLine && picked.hit.startLine > 0) { + const line = Math.max(0, picked.hit.startLine - 1); + const position = new vscode.Position(line, 0); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(new vscode.Range(position, position)); + } + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.saveMemory", async () => { + const folder = requireWorkspaceFolder(); + const editor = vscode.window.activeTextEditor; + const selectionText = editor && !editor.selection.isEmpty ? editor.document.getText(editor.selection) : ""; + const content = await vscode.window.showInputBox({ + prompt: "Memory content", + value: selectionText + }); + if (!content) { + return; + } + + const scope = await vscode.window.showQuickPick(["project", "user"], { placeHolder: "Memory scope" }); + if (!scope) { + return; + } + + await client.call("memory/save", { + cwd: folder.uri.fsPath, + sessionId: await client.ensureSession(folder), + request: { + scope, + content, + source: editor ? "vscode-selection" : "vscode-manual", + relatedFilePath: editor ? vscode.workspace.asRelativePath(editor.document.uri, false) : undefined + } + }); + void vscode.window.showInformationMessage(`Saved ${scope} memory.`); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.listMemory", async () => { + const folder = requireWorkspaceFolder(); + const result = await client.call>("memory/list", { + cwd: folder.uri.fsPath, + limit: 30 + }); + await vscode.window.showQuickPick( + result.map(entry => ({ + label: entry.id, + description: entry.scope, + detail: entry.content + })), + { placeHolder: "Saved SharpClaw memory" }); + })); + + context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.selectModel", async () => { + const folder = requireWorkspaceFolder(); + const providers = await client.call("models/list", {}); + const picks = providers.flatMap(provider => { + const base = [{ + label: provider.defaultModel, + description: provider.providerName, + value: provider.defaultModel + }]; + const profilePicks = (provider.localRuntimeProfiles ?? []).flatMap(profile => { + const discovered = profile.availableModels ?? []; + if (discovered.length === 0) { + return [{ + label: `${profile.name}/${profile.defaultChatModel}`, + description: provider.providerName, + value: `${profile.name}/${profile.defaultChatModel}` + }]; + } + + return discovered.map(model => ({ + label: `${profile.name}/${model.id}`, + description: provider.providerName, + value: `${profile.name}/${model.id}` + })); + }); + return [...base, ...profilePicks]; + }); + const selected = await vscode.window.showQuickPick(picks, { placeHolder: "Select the model for ACP prompts" }); + if (!selected) { + return; + } + + await context.workspaceState.update(`model:${folder.uri.toString()}`, selected.value); + void vscode.window.showInformationMessage(`SharpClaw model set to ${selected.value}.`); + })); +} + +export function deactivate(): void { +} + +function requireWorkspaceFolder(): vscode.WorkspaceFolder { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + throw new Error("Open a workspace folder before using SharpClaw."); + } + + return folder; +} + +function buildEditorContext(editor: vscode.TextEditor, sessionId: string): Record { + const selection = editor.selection; + return { + workspaceRoot: vscode.workspace.getWorkspaceFolder(editor.document.uri)?.uri.fsPath, + currentFilePath: editor.document.uri.fsPath, + selection: selection.isEmpty + ? undefined + : { + start: editor.document.offsetAt(selection.start), + end: editor.document.offsetAt(selection.end), + text: editor.document.getText(selection) + }, + sessionId + }; +} diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json new file mode 100644 index 0000000..cc23947 --- /dev/null +++ b/extensions/vscode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2022", + "lib": [ + "es2022" + ], + "outDir": "out", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "esModuleInterop": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs b/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs new file mode 100644 index 0000000..f9cb51b --- /dev/null +++ b/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Acp; + +/// +/// Coordinates ACP approval notifications and replies for a single host process. +/// +public sealed class AcpApprovalCoordinator +{ + private readonly ConcurrentDictionary pending = new(StringComparer.Ordinal); + private Func? notificationSink; + + /// + /// Gets a value indicating whether the connected ACP client advertised approval round-trips. + /// + public bool SupportsApprovals { get; private set; } + + /// + /// Updates the active ACP notification sink and approval capability state for the current host run. + /// + /// Whether the client supports approval callbacks. + /// Notification writer used for approval requests. + public void Configure(bool supportsApprovals, Func notificationWriter) + { + SupportsApprovals = supportsApprovals; + notificationSink = notificationWriter; + } + + /// + /// Sends an approval request to the connected ACP client and waits for the response. + /// + /// Approval request details. + /// Permission evaluation context. + /// Cancellation token. + /// The resolved approval decision. + public async Task RequestApprovalAsync( + ApprovalRequest request, + PermissionEvaluationContext context, + CancellationToken cancellationToken) + { + if (!SupportsApprovals || notificationSink is null) + { + return new ApprovalDecision( + request.Scope, + Approved: false, + RequestedBy: request.RequestedBy, + ResolvedBy: "acp", + Reason: "ACP client does not support approval round-trips.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: false); + } + + var requestId = $"approval-{Guid.NewGuid():N}"; + var tcs = new TaskCompletionSource<(bool Approved, bool Remember)>(TaskCreationOptions.RunContinuationsAsynchronously); + pending[requestId] = new PendingApproval(context.SessionId, request, tcs); + + using var registration = cancellationToken.Register(() => + { + if (pending.TryRemove(requestId, out var removed)) + { + removed.Completion.TrySetCanceled(cancellationToken); + } + }); + + await notificationSink(new JsonObject + { + ["jsonrpc"] = "2.0", + ["method"] = "session/notification", + ["params"] = new JsonObject + { + ["sessionId"] = context.SessionId, + ["update"] = new JsonObject + { + ["sessionUpdate"] = "approvalRequest", + ["approval"] = new JsonObject + { + ["requestId"] = requestId, + ["scope"] = request.Scope.ToString(), + ["toolName"] = request.ToolName, + ["prompt"] = request.Prompt, + ["canRememberDecision"] = request.CanRememberDecision, + }, + }, + }, + }).ConfigureAwait(false); + + var response = await tcs.Task.ConfigureAwait(false); + return new ApprovalDecision( + request.Scope, + Approved: response.Approved, + RequestedBy: request.RequestedBy, + ResolvedBy: "acp", + Reason: response.Approved ? "Approved via ACP client." : "Denied via ACP client.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: response.Remember); + } + + /// + /// Resolves a pending ACP approval request. + /// + /// Approval request id. + /// Whether the request was approved. + /// Whether the decision should be remembered for the session. + /// when a pending request was resolved; otherwise . + public bool TryResolve(string requestId, bool approved, bool remember) + { + if (!pending.TryRemove(requestId, out var pendingApproval)) + { + return false; + } + + pendingApproval.Completion.TrySetResult((approved, remember)); + return true; + } + + private sealed record PendingApproval( + string SessionId, + ApprovalRequest Request, + TaskCompletionSource<(bool Approved, bool Remember)> Completion); +} diff --git a/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs b/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs new file mode 100644 index 0000000..b7441a7 --- /dev/null +++ b/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Acp; + +internal sealed class AcpApprovalTransport(AcpApprovalCoordinator coordinator) : IApprovalTransport +{ + public bool CanHandle(PermissionEvaluationContext context) + => context.IsInteractive + && coordinator.SupportsApprovals + && string.Equals(context.SourceName, "acp", StringComparison.OrdinalIgnoreCase); + + public Task RequestApprovalAsync( + ApprovalRequest request, + PermissionEvaluationContext context, + CancellationToken cancellationToken) + => coordinator.RequestApprovalAsync(request, context, cancellationToken); +} diff --git a/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs b/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs new file mode 100644 index 0000000..83e5ed9 --- /dev/null +++ b/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Permissions.Abstractions; + +namespace SharpClaw.Code.Acp; + +/// +/// Registers ACP-specific host services. +/// +public static class AcpServiceCollectionExtensions +{ + /// + /// Adds ACP host services, including approval round-trip support. + /// + public static IServiceCollection AddSharpClawAcp(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/SharpClaw.Code.Acp/AcpStdioHost.cs b/src/SharpClaw.Code.Acp/AcpStdioHost.cs index 652ee6d..224c8b2 100644 --- a/src/SharpClaw.Code.Acp/AcpStdioHost.cs +++ b/src/SharpClaw.Code.Acp/AcpStdioHost.cs @@ -2,8 +2,12 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Sessions.Abstractions; @@ -12,21 +16,32 @@ namespace SharpClaw.Code.Acp; /// /// Minimal Agent Client Protocol (ACP) JSON-RPC loop over stdio, aligned with common IDE subprocess integrations. /// -/// -/// Intentionally unsupported (JSON-RPC errors or no-ops): streaming tool execution updates, interactive permission prompts, -/// image/audio prompt parts, MCP hot-plug, session/cancel reliability, and vendor extensions. -/// public sealed class AcpStdioHost( IConversationRuntime conversationRuntime, IWorkspaceSessionAttachmentStore attachmentStore, + IEditorContextBuffer editorContextBuffer, + IWorkspaceIndexService workspaceIndexService, + IWorkspaceSearchService workspaceSearchService, + IPersistentMemoryStore persistentMemoryStore, + IProviderCatalogService providerCatalogService, + AcpApprovalCoordinator approvalCoordinator, IPathService pathService, ILogger logger) { + private readonly SemaphoreSlim writeLock = new(1, 1); + private Func? notificationWriter; + /// /// Processes newline-delimited JSON-RPC requests until the input stream ends. /// public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToken cancellationToken) { + approvalCoordinator.Configure( + supportsApprovals: approvalCoordinator.SupportsApprovals, + notificationWriter: payload => WriteJsonLineAsync(stdout, payload, cancellationToken)); + notificationWriter = payload => WriteJsonLineAsync(stdout, payload, cancellationToken); + + var inFlight = new List(); while (!cancellationToken.IsCancellationRequested) { var line = await stdin.ReadLineAsync(cancellationToken).ConfigureAwait(false); @@ -48,46 +63,52 @@ public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToke catch (JsonException ex) { logger.LogWarning(ex, "ACP received non-JSON line."); - await WriteErrorAsync(stdout, null, -32700, "Parse error.").ConfigureAwait(false); - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + await WriteErrorAsync(stdout, null, -32700, "Parse error.", cancellationToken).ConfigureAwait(false); continue; } if (root is not JsonObject requestObject) { - await WriteErrorAsync(stdout, null, -32600, "Invalid request.").ConfigureAwait(false); - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + await WriteErrorAsync(stdout, null, -32600, "Invalid request.", cancellationToken).ConfigureAwait(false); continue; } - var id = requestObject["id"]; - var method = requestObject["method"]?.GetValue(); - if (!string.Equals(requestObject["jsonrpc"]?.GetValue(), "2.0", StringComparison.Ordinal) - || string.IsNullOrWhiteSpace(method) - || id is null) - { - await WriteErrorAsync(stdout, id, -32600, "Invalid request.").ConfigureAwait(false); - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); - continue; - } + inFlight.Add(ProcessRequestAsync(requestObject, stdout, cancellationToken)); + inFlight.RemoveAll(static task => task.IsCompleted); + } - try - { - var response = await DispatchAsync(method, requestObject["params"], stdout, cancellationToken).ConfigureAwait(false); - await WriteResponseAsync(stdout, id, response).ConfigureAwait(false); - } - catch (AcpJsonRpcException ex) - { - logger.LogWarning(ex, "ACP request failed for method {Method} with JSON-RPC error {Code}.", method, ex.Code); - await WriteErrorAsync(stdout, id, ex.Code, ex.Message).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogError(ex, "ACP request failed for method {Method}.", method); - await WriteErrorAsync(stdout, id, -32603, ex.Message).ConfigureAwait(false); - } + if (inFlight.Count > 0) + { + await Task.WhenAll(inFlight).ConfigureAwait(false); + } + } - await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + private async Task ProcessRequestAsync(JsonObject requestObject, TextWriter stdout, CancellationToken cancellationToken) + { + var id = requestObject["id"]; + var method = requestObject["method"]?.GetValue(); + if (!string.Equals(requestObject["jsonrpc"]?.GetValue(), "2.0", StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(method) + || id is null) + { + await WriteErrorAsync(stdout, id, -32600, "Invalid request.", cancellationToken).ConfigureAwait(false); + return; + } + + try + { + var response = await DispatchAsync(method, requestObject["params"], stdout, cancellationToken).ConfigureAwait(false); + await WriteResponseAsync(stdout, id, response, cancellationToken).ConfigureAwait(false); + } + catch (AcpJsonRpcException ex) + { + logger.LogWarning(ex, "ACP request failed for method {Method} with JSON-RPC error {Code}.", method, ex.Code); + await WriteErrorAsync(stdout, id, ex.Code, ex.Message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "ACP request failed for method {Method}.", method); + await WriteErrorAsync(stdout, id, -32603, ex.Message, cancellationToken).ConfigureAwait(false); } } @@ -98,18 +119,37 @@ public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToke CancellationToken cancellationToken) => method switch { - "initialize" => await Task.FromResult(BuildInitializeResult()).ConfigureAwait(false), + "initialize" => await Task.FromResult(HandleInitialize(parameters)).ConfigureAwait(false), "session/new" => await HandleSessionNewAsync(parameters, cancellationToken).ConfigureAwait(false), "session/load" => await HandleSessionLoadAsync(parameters, cancellationToken).ConfigureAwait(false), "session/prompt" => await HandleSessionPromptAsync(parameters, stdout, cancellationToken).ConfigureAwait(false), + "models/list" => await HandleModelsListAsync(cancellationToken).ConfigureAwait(false), + "workspace/index/refresh" => await HandleWorkspaceIndexRefreshAsync(parameters, cancellationToken).ConfigureAwait(false), + "workspace/search" => await HandleWorkspaceSearchAsync(parameters, cancellationToken).ConfigureAwait(false), + "memory/list" => await HandleMemoryListAsync(parameters, cancellationToken).ConfigureAwait(false), + "memory/save" => await HandleMemorySaveAsync(parameters, cancellationToken).ConfigureAwait(false), + "memory/delete" => await HandleMemoryDeleteAsync(parameters, cancellationToken).ConfigureAwait(false), + "approval/respond" => await HandleApprovalRespondAsync(parameters).ConfigureAwait(false), _ => throw new AcpJsonRpcException(-32601, $"Method '{method}' was not found.") }; - private static JsonObject BuildInitializeResult() + private JsonObject HandleInitialize(JsonNode? parameters) { + var supportsApprovals = parameters?["clientCapabilities"]?["approvalRequests"]?.GetValue() ?? false; + approvalCoordinator.Configure( + supportsApprovals, + payload => notificationWriter is null + ? Task.CompletedTask + : notificationWriter(payload)); + var capabilities = new JsonObject { ["loadSession"] = true, + ["approvalRequests"] = true, + ["models"] = true, + ["workspaceSearch"] = true, + ["workspaceIndex"] = true, + ["memory"] = true, ["promptCapabilities"] = new JsonObject { ["image"] = false, @@ -131,8 +171,7 @@ private static JsonObject BuildInitializeResult() private async Task HandleSessionNewAsync(JsonNode? parameters, CancellationToken cancellationToken) { - var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/new requires cwd."); - var workspace = pathService.GetFullPath(cwd); + var workspace = RequireWorkspace(parameters); var session = await conversationRuntime .CreateSessionAsync(workspace, PermissionMode.WorkspaceWrite, OutputFormat.Json, cancellationToken) .ConfigureAwait(false); @@ -143,7 +182,9 @@ private async Task HandleSessionNewAsync(JsonNode? parameters, Cance ["models"] = new JsonObject { ["current"] = "default", - ["available"] = new JsonArray(), + ["available"] = JsonSerializer.SerializeToNode( + (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)).ToList(), + ProtocolJsonContext.Default.ListProviderModelCatalogEntry), }, }; } @@ -151,8 +192,7 @@ private async Task HandleSessionNewAsync(JsonNode? parameters, Cance private async Task HandleSessionLoadAsync(JsonNode? parameters, CancellationToken cancellationToken) { var sessionId = parameters?["sessionId"]?.GetValue() ?? throw new InvalidOperationException("session/load requires sessionId."); - var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/load requires cwd."); - var workspace = pathService.GetFullPath(cwd); + var workspace = RequireWorkspace(parameters); var session = await conversationRuntime .GetSessionAsync(workspace, sessionId, cancellationToken) .ConfigureAwait(false); @@ -177,10 +217,22 @@ private async Task HandleSessionPromptAsync( CancellationToken cancellationToken) { var sessionId = parameters?["sessionId"]?.GetValue() ?? throw new InvalidOperationException("session/prompt requires sessionId."); - var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/prompt requires cwd."); - var workspace = pathService.GetFullPath(cwd); - + var workspace = RequireWorkspace(parameters); var promptText = ExtractPromptText(parameters?["prompt"]); + var metadata = new Dictionary(StringComparer.Ordinal) + { + ["acp"] = "true", + }; + if (parameters?["model"]?.GetValue() is { Length: > 0 } model) + { + metadata["model"] = model; + } + + if (parameters?["editorContext"] is JsonNode editorContextNode) + { + var editorContext = DeserializeEditorContext(editorContextNode, workspace, sessionId); + editorContextBuffer.Publish(editorContext); + } var turn = await conversationRuntime .RunPromptAsync( @@ -190,29 +242,31 @@ private async Task HandleSessionPromptAsync( WorkingDirectory: workspace, PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Json, - Metadata: new Dictionary { ["acp"] = "true" }, - IsInteractive: false), + Metadata: metadata, + IsInteractive: approvalCoordinator.SupportsApprovals), cancellationToken) .ConfigureAwait(false); - var chunk = new JsonObject - { - ["jsonrpc"] = "2.0", - ["method"] = "session/notification", - ["params"] = new JsonObject + await WriteJsonLineAsync( + stdout, + new JsonObject { - ["sessionId"] = sessionId, - ["update"] = new JsonObject + ["jsonrpc"] = "2.0", + ["method"] = "session/notification", + ["params"] = new JsonObject { - ["sessionUpdate"] = "agentMessageChunk", - ["chunk"] = new JsonObject + ["sessionId"] = sessionId, + ["update"] = new JsonObject { - ["content"] = new JsonObject { ["type"] = "text", ["text"] = turn.FinalOutput ?? string.Empty }, + ["sessionUpdate"] = "agentMessageChunk", + ["chunk"] = new JsonObject + { + ["content"] = new JsonObject { ["type"] = "text", ["text"] = turn.FinalOutput ?? string.Empty }, + }, }, }, }, - }; - await stdout.WriteLineAsync(chunk.ToJsonString()).ConfigureAwait(false); + cancellationToken).ConfigureAwait(false); return new JsonObject { @@ -220,6 +274,109 @@ private async Task HandleSessionPromptAsync( }; } + private async Task HandleModelsListAsync(CancellationToken cancellationToken) + => JsonSerializer.SerializeToNode( + (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)).ToList(), + ProtocolJsonContext.Default.ListProviderModelCatalogEntry); + + private async Task HandleWorkspaceIndexRefreshAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = RequireWorkspace(parameters); + var result = await workspaceIndexService.RefreshAsync(workspace, cancellationToken).ConfigureAwait(false); + return JsonSerializer.SerializeToNode(result, ProtocolJsonContext.Default.WorkspaceIndexRefreshResult); + } + + private async Task HandleWorkspaceSearchAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = RequireWorkspace(parameters); + var request = new WorkspaceSearchRequest( + Query: parameters?["query"]?.GetValue() ?? string.Empty, + Limit: parameters?["limit"]?.GetValue(), + IncludeSymbols: parameters?["includeSymbols"]?.GetValue() ?? true, + IncludeSemantic: parameters?["includeSemantic"]?.GetValue() ?? true); + var result = await workspaceSearchService.SearchAsync(workspace, request, cancellationToken).ConfigureAwait(false); + return JsonSerializer.SerializeToNode(result, ProtocolJsonContext.Default.WorkspaceSearchResult); + } + + private async Task HandleMemoryListAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = parameters?["cwd"]?.GetValue(); + var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace); + var scopeText = parameters?["scope"]?.GetValue(); + var scope = Enum.TryParse(scopeText, true, out var parsedScope) ? (MemoryScope?)parsedScope : null; + var rows = await persistentMemoryStore + .ListAsync(normalizedWorkspace, scope, parameters?["query"]?.GetValue(), parameters?["limit"]?.GetValue() ?? 20, cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.SerializeToNode(rows.ToList(), ProtocolJsonContext.Default.ListMemoryEntry); + } + + private async Task HandleMemorySaveAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = parameters?["cwd"]?.GetValue(); + var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace); + var request = parameters?["request"]?.Deserialize(ProtocolJsonContext.Default.MemorySaveRequest) + ?? throw new InvalidOperationException("memory/save requires request."); + var now = DateTimeOffset.UtcNow; + var entry = new MemoryEntry( + Id: $"memory-{Guid.NewGuid():N}", + Scope: request.Scope, + Content: request.Content, + Source: request.Source, + SourceSessionId: parameters?["sessionId"]?.GetValue(), + SourceTurnId: null, + Tags: request.Tags ?? [], + Confidence: request.Confidence, + RelatedFilePath: request.RelatedFilePath, + RelatedSymbolName: request.RelatedSymbolName, + CreatedAtUtc: now, + UpdatedAtUtc: now); + var saved = await persistentMemoryStore + .SaveAsync(request.Scope == MemoryScope.Project ? normalizedWorkspace : null, entry, cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.SerializeToNode(saved, ProtocolJsonContext.Default.MemoryEntry); + } + + private async Task HandleMemoryDeleteAsync(JsonNode? parameters, CancellationToken cancellationToken) + { + var workspace = parameters?["cwd"]?.GetValue(); + var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace); + var scope = Enum.Parse(parameters?["scope"]?.GetValue() ?? MemoryScope.Project.ToString(), true); + var id = parameters?["id"]?.GetValue() ?? throw new InvalidOperationException("memory/delete requires id."); + var deleted = await persistentMemoryStore + .DeleteAsync(scope == MemoryScope.Project ? normalizedWorkspace : null, scope, id, cancellationToken) + .ConfigureAwait(false); + return new JsonObject { ["deleted"] = deleted }; + } + + private Task HandleApprovalRespondAsync(JsonNode? parameters) + { + var requestId = parameters?["requestId"]?.GetValue() ?? throw new InvalidOperationException("approval/respond requires requestId."); + var approved = parameters?["approved"]?.GetValue() ?? false; + var remember = parameters?["remember"]?.GetValue() ?? false; + var resolved = approvalCoordinator.TryResolve(requestId, approved, remember); + return Task.FromResult(new JsonObject { ["resolved"] = resolved }); + } + + private string RequireWorkspace(JsonNode? parameters) + { + var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("cwd is required."); + return pathService.GetFullPath(cwd); + } + + private static EditorContextPayload DeserializeEditorContext(JsonNode node, string workspaceRoot, string sessionId) + { + if (node.Deserialize(ProtocolJsonContext.Default.EditorContextPayload) is { } payload) + { + return string.IsNullOrWhiteSpace(payload.SessionId) ? payload with { SessionId = sessionId } : payload; + } + + return new EditorContextPayload( + WorkspaceRoot: workspaceRoot, + CurrentFilePath: node["currentFilePath"]?.GetValue(), + Selection: node["selection"]?.Deserialize(ProtocolJsonContext.Default.TextSelectionRange), + SessionId: sessionId); + } + private static string ExtractPromptText(JsonNode? promptNode) { if (promptNode is JsonValue v && v.TryGetValue(out var single)) @@ -245,31 +402,45 @@ private static string ExtractPromptText(JsonNode? promptNode) throw new InvalidOperationException("session/prompt requires a text prompt payload."); } - private static async Task WriteResponseAsync(TextWriter stdout, JsonNode id, JsonNode? result) + private async Task WriteJsonLineAsync(TextWriter stdout, JsonNode node, CancellationToken cancellationToken) { - var response = new JsonObject + await writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - ["jsonrpc"] = "2.0", - ["id"] = id.DeepClone(), - ["result"] = result ?? new JsonObject(), - }; - await stdout.WriteLineAsync(response.ToJsonString()).ConfigureAwait(false); + await stdout.WriteLineAsync(node.ToJsonString()).ConfigureAwait(false); + await stdout.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + writeLock.Release(); + } } - private static async Task WriteErrorAsync(TextWriter stdout, JsonNode? id, int code, string message) - { - var response = new JsonObject - { - ["jsonrpc"] = "2.0", - ["id"] = id?.DeepClone(), - ["error"] = new JsonObject + private Task WriteResponseAsync(TextWriter stdout, JsonNode id, JsonNode? result, CancellationToken cancellationToken) + => WriteJsonLineAsync( + stdout, + new JsonObject { - ["code"] = code, - ["message"] = message, + ["jsonrpc"] = "2.0", + ["id"] = id.DeepClone(), + ["result"] = result ?? new JsonObject(), }, - }; - await stdout.WriteLineAsync(response.ToJsonString()).ConfigureAwait(false); - } + cancellationToken); + + private Task WriteErrorAsync(TextWriter stdout, JsonNode? id, int code, string message, CancellationToken cancellationToken) + => WriteJsonLineAsync( + stdout, + new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["error"] = new JsonObject + { + ["code"] = code, + ["message"] = message, + }, + }, + cancellationToken); private sealed class AcpJsonRpcException(int code, string message) : Exception(message) { diff --git a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj index 3b86ce5..c583f17 100644 --- a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj +++ b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj @@ -13,6 +13,9 @@ + + + diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs index 8bb9073..25d23f6 100644 --- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs +++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs @@ -46,7 +46,11 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel AllowDangerousBypass: false, IsInteractive: request.Context.IsInteractive, SourceKind: PermissionRequestSourceKind.Runtime, - SourceName: null, + SourceName: request.Context.Metadata is not null + && request.Context.Metadata.TryGetValue("acp", out var acp) + && string.Equals(acp, "true", StringComparison.OrdinalIgnoreCase) + ? "acp" + : null, TrustedPluginNames: null, TrustedMcpServerNames: null, PrimaryMode: request.Context.PrimaryMode, diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index 631b7d7..8c49d5c 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class CliServiceCollectionExtensions /// The updated service collection. public static IServiceCollection AddSharpClawCli(this IServiceCollection services) { + services.AddSharpClawAcp(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -27,8 +28,6 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -38,7 +37,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -62,7 +63,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs new file mode 100644 index 0000000..01b0f0f --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs @@ -0,0 +1,120 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Commands; + +/// +/// Refreshes and queries the persisted workspace knowledge index. +/// +public sealed class IndexCommandHandler( + IWorkspaceIndexService workspaceIndexService, + IWorkspaceSearchService workspaceSearchService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "index"; + + /// + public string Description => "Refreshes, inspects, and queries the workspace knowledge index."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var refresh = new Command("refresh", "Refreshes the workspace index."); + refresh.SetAction((parseResult, cancellationToken) => ExecuteRefreshAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(refresh); + + var stats = new Command("stats", "Shows workspace index status."); + stats.SetAction((parseResult, cancellationToken) => ExecuteStatsAsync(globalOptions.Resolve(parseResult), cancellationToken)); + command.Subcommands.Add(stats); + + var query = new Command("query", "Searches the workspace index."); + var queryArgument = new Argument("query") { Description = "Search query." }; + var limitOption = new Option("--limit") { Description = "Maximum number of hits to return." }; + query.Arguments.Add(queryArgument); + query.Options.Add(limitOption); + query.SetAction((parseResult, cancellationToken) => ExecuteQueryAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(queryArgument) ?? string.Empty, + parseResult.GetValue(limitOption), + cancellationToken)); + command.Subcommands.Add(query); + + command.SetAction((parseResult, cancellationToken) => ExecuteStatsAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "refresh", StringComparison.OrdinalIgnoreCase)) + { + return ExecuteRefreshAsync(context, cancellationToken); + } + + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "query", StringComparison.OrdinalIgnoreCase)) + { + var query = string.Join(' ', command.Arguments.Skip(1)); + return ExecuteQueryAsync(context, query, null, cancellationToken); + } + + return ExecuteStatsAsync(context, cancellationToken); + } + + private async Task ExecuteRefreshAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var result = await workspaceIndexService.RefreshAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + return await RenderAsync(context, result, $"Indexed {result.IndexedFileCount} file(s).", cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteStatsAsync(CommandExecutionContext context, CancellationToken cancellationToken) + { + var result = await workspaceIndexService.GetStatusAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + return await RenderAsync( + context, + result, + result.RefreshedAtUtc is null + ? "Workspace index has not been built yet." + : $"Workspace index refreshed {result.RefreshedAtUtc:O}.", + cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteQueryAsync( + CommandExecutionContext context, + string query, + int? limit, + CancellationToken cancellationToken) + { + var result = await workspaceSearchService + .SearchAsync(context.WorkingDirectory, new WorkspaceSearchRequest(query, limit), cancellationToken) + .ConfigureAwait(false); + return await RenderAsync(context, result, $"{result.Hits.Length} workspace hit(s).", cancellationToken).ConfigureAwait(false); + } + + private async Task RenderAsync( + CommandExecutionContext context, + TPayload payload, + string message, + CancellationToken cancellationToken) + { + var commandResult = new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.Options)); + await outputRendererDispatcher.RenderCommandResultAsync(commandResult, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs new file mode 100644 index 0000000..554d1f4 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs @@ -0,0 +1,177 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists, saves, and deletes structured memory entries. +/// +public sealed class MemoryCommandHandler( + IPersistentMemoryStore persistentMemoryStore, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "memory"; + + /// + public string Description => "Lists, saves, and deletes durable project and user memory entries."; + + /// + public string CommandName => Name; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + + var list = new Command("list", "Lists memory entries."); + var scopeOption = new Option("--scope") { Description = "Optional memory scope filter." }; + var queryOption = new Option("--query") { Description = "Optional free-text filter." }; + var limitOption = new Option("--limit") { Description = "Maximum number of rows to return." }; + list.Options.Add(scopeOption); + list.Options.Add(queryOption); + list.Options.Add(limitOption); + list.SetAction((parseResult, cancellationToken) => ExecuteListAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(scopeOption), + parseResult.GetValue(queryOption), + parseResult.GetValue(limitOption), + cancellationToken)); + command.Subcommands.Add(list); + + var save = new Command("save", "Saves a memory entry."); + var saveScope = new Option("--scope") { Description = "Memory scope.", DefaultValueFactory = _ => MemoryScope.Project }; + var sourceOption = new Option("--source") { Description = "Source label.", DefaultValueFactory = _ => "manual" }; + var contentArgument = new Argument("content") { Description = "Memory content." }; + save.Options.Add(saveScope); + save.Options.Add(sourceOption); + save.Arguments.Add(contentArgument); + save.SetAction((parseResult, cancellationToken) => ExecuteSaveAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(saveScope), + parseResult.GetValue(sourceOption) ?? "manual", + parseResult.GetValue(contentArgument) ?? string.Empty, + cancellationToken)); + command.Subcommands.Add(save); + + var delete = new Command("delete", "Deletes a memory entry."); + var deleteScope = new Option("--scope") { Description = "Memory scope.", DefaultValueFactory = _ => MemoryScope.Project }; + var idArgument = new Argument("id") { Description = "Memory id." }; + delete.Options.Add(deleteScope); + delete.Arguments.Add(idArgument); + delete.SetAction((parseResult, cancellationToken) => ExecuteDeleteAsync( + globalOptions.Resolve(parseResult), + parseResult.GetValue(deleteScope), + parseResult.GetValue(idArgument) ?? string.Empty, + cancellationToken)); + command.Subcommands.Add(delete); + + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), null, null, null, cancellationToken)); + return command; + } + + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "save", StringComparison.OrdinalIgnoreCase)) + { + var parsedScope = MemoryScope.Project; + var hasExplicitScope = command.Arguments.Length > 1 && Enum.TryParse(command.Arguments[1], true, out parsedScope); + var scope = hasExplicitScope ? parsedScope : MemoryScope.Project; + var content = string.Join(' ', command.Arguments.Skip(hasExplicitScope ? 2 : 1)); + return ExecuteSaveAsync(context, scope, "manual", content, cancellationToken); + } + + if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "delete", StringComparison.OrdinalIgnoreCase)) + { + var id = command.Arguments.Length > 1 ? command.Arguments[1] : string.Empty; + return ExecuteDeleteAsync(context, MemoryScope.Project, id, cancellationToken); + } + + var query = command.Arguments.Length > 1 && string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase) + ? string.Join(' ', command.Arguments.Skip(1)) + : string.Join(' ', command.Arguments); + return ExecuteListAsync(context, null, string.IsNullOrWhiteSpace(query) ? null : query, null, cancellationToken); + } + + private async Task ExecuteListAsync( + CommandExecutionContext context, + MemoryScope? scope, + string? query, + int? limit, + CancellationToken cancellationToken) + { + var rows = await persistentMemoryStore + .ListAsync(context.WorkingDirectory, scope, query, Math.Clamp(limit.GetValueOrDefault(20), 1, 100), cancellationToken) + .ConfigureAwait(false); + return await RenderAsync(context, rows.ToList(), $"{rows.Count} memory entr{(rows.Count == 1 ? "y" : "ies")}.", cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteSaveAsync( + CommandExecutionContext context, + MemoryScope scope, + string source, + string content, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + var entry = new MemoryEntry( + Id: $"memory-{Guid.NewGuid():N}", + Scope: scope, + Content: content, + Source: source, + SourceSessionId: context.SessionId, + SourceTurnId: null, + Tags: [], + Confidence: null, + RelatedFilePath: null, + RelatedSymbolName: null, + CreatedAtUtc: now, + UpdatedAtUtc: now); + var saved = await persistentMemoryStore + .SaveAsync(scope == MemoryScope.Project ? context.WorkingDirectory : null, entry, cancellationToken) + .ConfigureAwait(false); + return await RenderAsync(context, saved, $"Saved {scope.ToString().ToLowerInvariant()} memory {saved.Id}.", cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteDeleteAsync( + CommandExecutionContext context, + MemoryScope scope, + string id, + CancellationToken cancellationToken) + { + var deleted = await persistentMemoryStore + .DeleteAsync(scope == MemoryScope.Project ? context.WorkingDirectory : null, scope, id, cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + deleted, + deleted ? 0 : 1, + context.OutputFormat, + deleted ? $"Deleted memory {id}." : $"Memory {id} was not found.", + null); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return result.ExitCode; + } + + private async Task RenderAsync( + CommandExecutionContext context, + TPayload payload, + string message, + CancellationToken cancellationToken) + { + var result = new CommandResult( + true, + 0, + context.OutputFormat, + message, + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.Options)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs index 5cdc014..25b0d2f 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs @@ -1,12 +1,9 @@ using System.CommandLine; using System.Text.Json; -using Microsoft.Extensions.Options; using SharpClaw.Code.Commands.Models; using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Providers.Abstractions; -using SharpClaw.Code.Providers.Configuration; using SharpClaw.Code.Protocol.Commands; -using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; namespace SharpClaw.Code.Commands; @@ -15,11 +12,7 @@ namespace SharpClaw.Code.Commands; /// Lists the configured provider/model surface available to SharpClaw. /// public sealed class ModelsCommandHandler( - IEnumerable modelProviders, - IAuthFlowService authFlowService, - IOptions providerCatalogOptions, - IOptions anthropicOptions, - IOptions openAiCompatibleOptions, + IProviderCatalogService providerCatalogService, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { /// @@ -45,39 +38,16 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) { - var entries = new List(); - var aliasesByProvider = providerCatalogOptions.Value.ModelAliases - .GroupBy(static pair => pair.Value.ProviderName, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - static group => group.Key, - static group => group.Select(pair => pair.Key).OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray(), - StringComparer.OrdinalIgnoreCase); - - foreach (var provider in modelProviders.OrderBy(static provider => provider.ProviderName, StringComparer.OrdinalIgnoreCase)) - { - var auth = await authFlowService.GetStatusAsync(provider.ProviderName, cancellationToken).ConfigureAwait(false); - entries.Add( - new ProviderModelCatalogEntry( - provider.ProviderName, - ResolveDefaultModel(provider.ProviderName), - aliasesByProvider.TryGetValue(provider.ProviderName, out var aliases) ? aliases : [], - auth)); - } + var entries = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); + var payload = entries.ToList(); var result = new CommandResult( true, 0, context.OutputFormat, - $"{entries.Count} provider model surface(s).", - JsonSerializer.Serialize(entries, ProtocolJsonContext.Default.ListProviderModelCatalogEntry)); + $"{payload.Count} provider model surface(s).", + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.ListProviderModelCatalogEntry)); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; } - - private string ResolveDefaultModel(string providerName) - => string.Equals(providerName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) - ? anthropicOptions.Value.DefaultModel - : string.Equals(providerName, openAiCompatibleOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) - ? openAiCompatibleOptions.Value.DefaultModel - : "default"; } diff --git a/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj b/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj index 4ac17c4..b15dedc 100644 --- a/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj +++ b/src/SharpClaw.Code.Commands/SharpClaw.Code.Commands.csproj @@ -11,6 +11,7 @@ + diff --git a/src/SharpClaw.Code.Memory/Abstractions/IMemoryRecallService.cs b/src/SharpClaw.Code.Memory/Abstractions/IMemoryRecallService.cs new file mode 100644 index 0000000..da232b6 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IMemoryRecallService.cs @@ -0,0 +1,18 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// +/// Recalls the most relevant structured memory entries for a prompt. +/// +public interface IMemoryRecallService +{ + /// + /// Recalls project and user memory entries for the supplied prompt text. + /// + Task> RecallAsync( + string workspaceRoot, + string prompt, + int limit, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IPersistentMemoryStore.cs b/src/SharpClaw.Code.Memory/Abstractions/IPersistentMemoryStore.cs new file mode 100644 index 0000000..bde6d43 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IPersistentMemoryStore.cs @@ -0,0 +1,33 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// +/// Stores durable structured memory entries at project and user scope. +/// +public interface IPersistentMemoryStore +{ + /// + /// Saves or updates a memory entry. + /// + Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken); + + /// + /// Deletes a memory entry. + /// + Task DeleteAsync( + string? workspaceRoot, + MemoryScope scope, + string id, + CancellationToken cancellationToken); + + /// + /// Lists memory entries with optional filtering. + /// + Task> ListAsync( + string? workspaceRoot, + MemoryScope? scope, + string? query, + int limit, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceIndexService.cs b/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceIndexService.cs new file mode 100644 index 0000000..fcd0659 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceIndexService.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// +/// Builds and refreshes the persisted workspace knowledge index. +/// +public interface IWorkspaceIndexService +{ + /// + /// Refreshes the workspace index. + /// + Task RefreshAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Gets the current workspace index status. + /// + Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceKnowledgeStore.cs b/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceKnowledgeStore.cs new file mode 100644 index 0000000..4194b96 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceKnowledgeStore.cs @@ -0,0 +1,74 @@ +using SharpClaw.Code.Memory.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// +/// Persists workspace knowledge, search data, and structured memory state. +/// +public interface IWorkspaceKnowledgeStore +{ + /// + /// Replaces the persisted workspace index snapshot. + /// + Task ReplaceWorkspaceIndexAsync( + string workspaceRoot, + WorkspaceIndexDocument document, + DateTimeOffset refreshedAtUtc, + CancellationToken cancellationToken); + + /// + /// Gets the current workspace index status. + /// + Task GetWorkspaceIndexStatusAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Searches indexed chunks lexically through the persisted FTS catalog. + /// + Task> SearchChunksLexicalAsync( + string workspaceRoot, + string query, + int limit, + CancellationToken cancellationToken); + + /// + /// Searches indexed symbols by name and container. + /// + Task> SearchSymbolsAsync( + string workspaceRoot, + string query, + int limit, + CancellationToken cancellationToken); + + /// + /// Lists all indexed chunks for semantic ranking. + /// + Task> ListChunksAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Saves or updates a structured memory entry. + /// + Task SaveMemoryAsync( + string? workspaceRoot, + MemoryEntry entry, + CancellationToken cancellationToken); + + /// + /// Deletes a structured memory entry. + /// + Task DeleteMemoryAsync( + string? workspaceRoot, + MemoryScope scope, + string id, + CancellationToken cancellationToken); + + /// + /// Lists memory entries with optional filtering. + /// + Task> ListMemoryAsync( + string? workspaceRoot, + MemoryScope? scope, + string? query, + int limit, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceSearchService.cs b/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceSearchService.cs new file mode 100644 index 0000000..906b1d3 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IWorkspaceSearchService.cs @@ -0,0 +1,17 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// +/// Executes hybrid workspace search over indexed chunks and symbols. +/// +public interface IWorkspaceSearchService +{ + /// + /// Searches the indexed workspace. + /// + Task SearchAsync( + string workspaceRoot, + WorkspaceSearchRequest request, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs b/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs index 2ca1dd5..b1c71ed 100644 --- a/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs @@ -18,6 +18,11 @@ public static class MemoryServiceCollectionExtensions public static IServiceCollection AddSharpClawMemory(this IServiceCollection services) { services.AddSharpClawInfrastructure(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/SharpClaw.Code.Memory/Models/WorkspaceKnowledgeRecords.cs b/src/SharpClaw.Code.Memory/Models/WorkspaceKnowledgeRecords.cs new file mode 100644 index 0000000..5deca46 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Models/WorkspaceKnowledgeRecords.cs @@ -0,0 +1,63 @@ +namespace SharpClaw.Code.Memory.Models; + +/// +/// Represents one indexed text chunk stored in the workspace knowledge store. +/// +/// Stable chunk identifier. +/// Workspace-relative path. +/// Detected language tag. +/// Concise excerpt. +/// Full chunk content. +/// 1-based starting line. +/// 1-based ending line. +/// Deterministic embedding vector. +public sealed record IndexedWorkspaceChunk( + string Id, + string Path, + string Language, + string Excerpt, + string Content, + int StartLine, + int EndLine, + float[] Embedding); + +/// +/// Represents one indexed symbol extracted from the workspace. +/// +/// Stable symbol identifier. +/// Workspace-relative path. +/// Symbol name. +/// Symbol kind. +/// Containing type or namespace. +/// 1-based line number. +/// 1-based column number. +public sealed record IndexedWorkspaceSymbol( + string Id, + string Path, + string Name, + string Kind, + string? Container, + int Line, + int Column); + +/// +/// Represents one project or package dependency edge discovered during indexing. +/// +/// Source project path. +/// Target project or package identifier. +/// Edge kind. +public sealed record IndexedWorkspaceProjectEdge( + string SourcePath, + string Target, + string Kind); + +/// +/// Represents the full workspace index document persisted in the knowledge store. +/// +/// Indexed chunks. +/// Indexed symbols. +/// Indexed dependency edges. +public sealed record WorkspaceIndexDocument( + IReadOnlyList Chunks, + IReadOnlyList Symbols, + IReadOnlyList ProjectEdges); diff --git a/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs b/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs new file mode 100644 index 0000000..e1e270f --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Text.Json; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Generates deterministic local embeddings so semantic ranking works without a remote embedding model. +/// +internal static class HashTextEmbeddingService +{ + private const int Dimensions = 64; + + public static float[] Embed(string? text) + { + var vector = new float[Dimensions]; + if (string.IsNullOrWhiteSpace(text)) + { + return vector; + } + + foreach (var token in Tokenize(text)) + { + var hash = string.GetHashCode(token, StringComparison.Ordinal); + var index = Math.Abs(hash % Dimensions); + vector[index] += 1f; + } + + Normalize(vector); + return vector; + } + + public static double Cosine(float[] left, float[] right) + { + if (left.Length != right.Length || left.Length == 0) + { + return 0d; + } + + double dot = 0; + double leftMag = 0; + double rightMag = 0; + for (var i = 0; i < left.Length; i++) + { + dot += left[i] * right[i]; + leftMag += left[i] * left[i]; + rightMag += right[i] * right[i]; + } + + if (leftMag <= 0 || rightMag <= 0) + { + return 0d; + } + + return dot / (Math.Sqrt(leftMag) * Math.Sqrt(rightMag)); + } + + public static string Serialize(float[] vector) + => JsonSerializer.Serialize(vector); + + public static float[] Deserialize(string? json) + => string.IsNullOrWhiteSpace(json) + ? new float[Dimensions] + : JsonSerializer.Deserialize(json) ?? new float[Dimensions]; + + private static void Normalize(float[] vector) + { + double magnitude = 0; + foreach (var value in vector) + { + magnitude += value * value; + } + + if (magnitude <= 0) + { + return; + } + + var scale = 1d / Math.Sqrt(magnitude); + for (var i = 0; i < vector.Length; i++) + { + vector[i] = (float)(vector[i] * scale); + } + } + + private static IEnumerable Tokenize(string text) + { + var buffer = new List(32); + foreach (var ch in text) + { + if (char.IsLetterOrDigit(ch) || ch == '_' || ch == '-') + { + buffer.Add(char.ToLowerInvariant(ch)); + continue; + } + + if (buffer.Count == 0) + { + continue; + } + + yield return new string([.. buffer]); + buffer.Clear(); + } + + if (buffer.Count > 0) + { + yield return new string([.. buffer]); + } + } +} diff --git a/src/SharpClaw.Code.Memory/Services/MemoryRecallService.cs b/src/SharpClaw.Code.Memory/Services/MemoryRecallService.cs new file mode 100644 index 0000000..6200e24 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/MemoryRecallService.cs @@ -0,0 +1,68 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Recalls the highest-signal structured memory entries for a prompt. +/// +public sealed class MemoryRecallService(IPersistentMemoryStore persistentMemoryStore) : IMemoryRecallService +{ + /// + public async Task> RecallAsync( + string workspaceRoot, + string prompt, + int limit, + CancellationToken cancellationToken) + { + var candidateCount = Math.Max(limit * 3, limit); + var entries = await persistentMemoryStore + .ListAsync(workspaceRoot, scope: null, prompt, Math.Max(limit * 3, limit), cancellationToken) + .ConfigureAwait(false); + if (entries.Count == 0) + { + entries = await persistentMemoryStore + .ListAsync(workspaceRoot, scope: null, query: null, Math.Max(candidateCount, 50), cancellationToken) + .ConfigureAwait(false); + } + + if (entries.Count == 0) + { + return []; + } + + var queryVector = HashTextEmbeddingService.Embed(prompt); + var promptTokens = Tokenize(prompt); + return entries + .Select(entry => new + { + Entry = entry, + Score = Score(entry, queryVector, prompt, promptTokens), + }) + .OrderByDescending(static item => item.Score) + .ThenByDescending(static item => item.Entry.UpdatedAtUtc) + .Take(limit) + .Select(static item => item.Entry) + .ToArray(); + } + + private static double Score(MemoryEntry entry, float[] queryVector, string prompt, HashSet promptTokens) + { + var lexical = entry.Content.Contains(prompt, StringComparison.OrdinalIgnoreCase) ? 1d : 0d; + if (lexical == 0d && promptTokens.Count > 0) + { + var entryTokens = Tokenize(entry.Content); + lexical = promptTokens.Intersect(entryTokens, StringComparer.OrdinalIgnoreCase).Count() / (double)promptTokens.Count; + } + + var semantic = HashTextEmbeddingService.Cosine(queryVector, HashTextEmbeddingService.Embed(entry.Content)); + return semantic * 0.75d + lexical * 0.25d; + } + + private static HashSet Tokenize(string text) + => text + .Split([' ', '\r', '\n', '\t', '.', ',', ':', ';', '-', '_', '/', '\\', '(', ')', '[', ']', '{', '}', '!', '?'], StringSplitOptions.RemoveEmptyEntries) + .Select(static token => token.Trim().ToLowerInvariant()) + .Where(static token => token.Length > 1) + .ToHashSet(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/SharpClaw.Code.Memory/Services/PersistentMemoryStore.cs b/src/SharpClaw.Code.Memory/Services/PersistentMemoryStore.cs new file mode 100644 index 0000000..465a8bf --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/PersistentMemoryStore.cs @@ -0,0 +1,31 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Stores durable project and user memory entries through the workspace knowledge store. +/// +public sealed class PersistentMemoryStore(IWorkspaceKnowledgeStore knowledgeStore) : IPersistentMemoryStore +{ + /// + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + => knowledgeStore.SaveMemoryAsync(workspaceRoot, entry, cancellationToken); + + /// + public Task DeleteAsync( + string? workspaceRoot, + MemoryScope scope, + string id, + CancellationToken cancellationToken) + => knowledgeStore.DeleteMemoryAsync(workspaceRoot, scope, id, cancellationToken); + + /// + public Task> ListAsync( + string? workspaceRoot, + MemoryScope? scope, + string? query, + int limit, + CancellationToken cancellationToken) + => knowledgeStore.ListMemoryAsync(workspaceRoot, scope, query, limit, cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs b/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs new file mode 100644 index 0000000..dbaec24 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs @@ -0,0 +1,681 @@ +using System.Globalization; +using System.Data.Common; +using Microsoft.Data.Sqlite; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Persists workspace knowledge and durable memory in SQLite databases under SharpClaw storage roots. +/// +public sealed class SqliteWorkspaceKnowledgeStore( + IFileSystem fileSystem, + IPathService pathService, + IUserProfilePaths userProfilePaths) : IWorkspaceKnowledgeStore +{ + private const string WorkspaceKnowledgeDirectoryName = "knowledge"; + private const string WorkspaceKnowledgeFileName = "knowledge.db"; + private const string UserMemoryFileName = "user-memory.db"; + + /// + public async Task ReplaceWorkspaceIndexAsync( + string workspaceRoot, + WorkspaceIndexDocument document, + DateTimeOffset refreshedAtUtc, + CancellationToken cancellationToken) + { + await using var connection = await OpenWorkspaceConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + await ExecuteNonQueryAsync(connection, transaction, "DELETE FROM indexed_chunks;", cancellationToken).ConfigureAwait(false); + await ExecuteNonQueryAsync(connection, transaction, "DELETE FROM indexed_chunks_fts;", cancellationToken).ConfigureAwait(false); + await ExecuteNonQueryAsync(connection, transaction, "DELETE FROM indexed_symbols;", cancellationToken).ConfigureAwait(false); + await ExecuteNonQueryAsync(connection, transaction, "DELETE FROM project_edges;", cancellationToken).ConfigureAwait(false); + + foreach (var chunk in document.Chunks) + { + await InsertChunkAsync(connection, transaction, chunk, cancellationToken).ConfigureAwait(false); + } + + foreach (var symbol in document.Symbols) + { + await InsertSymbolAsync(connection, transaction, symbol, cancellationToken).ConfigureAwait(false); + } + + foreach (var edge in document.ProjectEdges) + { + await InsertEdgeAsync(connection, transaction, edge, cancellationToken).ConfigureAwait(false); + } + + await UpsertMetadataAsync(connection, transaction, "workspace_root", pathService.GetFullPath(workspaceRoot), cancellationToken).ConfigureAwait(false); + await UpsertMetadataAsync(connection, transaction, "refreshed_at_utc", refreshedAtUtc.ToString("O", CultureInfo.InvariantCulture), cancellationToken).ConfigureAwait(false); + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetWorkspaceIndexStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + { + await using var connection = await OpenWorkspaceConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var refreshedAt = await TryReadMetadataDateAsync(connection, "refreshed_at_utc", cancellationToken).ConfigureAwait(false); + return new WorkspaceIndexStatus( + WorkspaceRoot: pathService.GetFullPath(workspaceRoot), + RefreshedAtUtc: refreshedAt, + IndexedFileCount: await CountDistinctAsync(connection, "indexed_chunks", "path", cancellationToken).ConfigureAwait(false), + ChunkCount: await CountAsync(connection, "indexed_chunks", cancellationToken).ConfigureAwait(false), + SymbolCount: await CountAsync(connection, "indexed_symbols", cancellationToken).ConfigureAwait(false), + ProjectEdgeCount: await CountAsync(connection, "project_edges", cancellationToken).ConfigureAwait(false)); + } + + /// + public async Task> SearchChunksLexicalAsync( + string workspaceRoot, + string query, + int limit, + CancellationToken cancellationToken) + { + var normalizedQuery = NormalizeFtsQuery(query); + if (string.IsNullOrWhiteSpace(normalizedQuery)) + { + return []; + } + + await using var connection = await OpenWorkspaceConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var results = new List(); + var sql = """ + SELECT c.path, c.excerpt, c.start_line, c.end_line, ABS(bm25(indexed_chunks_fts)) AS score + FROM indexed_chunks_fts f + JOIN indexed_chunks c ON c.id = f.id + WHERE indexed_chunks_fts MATCH $query + ORDER BY bm25(indexed_chunks_fts) + LIMIT $limit; + """; + await using var command = connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("$query", normalizedQuery); + command.Parameters.AddWithValue("$limit", limit); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new WorkspaceSearchHit( + Path: reader.GetString(0), + Kind: WorkspaceSearchHitKind.Lexical, + Score: 1d / (1d + reader.GetDouble(4)), + Excerpt: reader.GetString(1), + SymbolName: null, + SymbolKind: null, + StartLine: reader.GetInt32(2), + EndLine: reader.GetInt32(3))); + } + + return results; + } + + /// + public async Task> SearchSymbolsAsync( + string workspaceRoot, + string query, + int limit, + CancellationToken cancellationToken) + { + await using var connection = await OpenWorkspaceConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var results = new List(); + var sql = """ + SELECT path, name, kind, container, line, column_number + FROM indexed_symbols + WHERE name LIKE $pattern OR COALESCE(container, '') LIKE $pattern + ORDER BY CASE WHEN name = $exact THEN 0 WHEN name LIKE $prefix THEN 1 ELSE 2 END, name + LIMIT $limit; + """; + await using var command = connection.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("$pattern", $"%{query}%"); + command.Parameters.AddWithValue("$exact", query); + command.Parameters.AddWithValue("$prefix", $"{query}%"); + command.Parameters.AddWithValue("$limit", limit); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var name = reader.GetString(1); + var container = reader.IsDBNull(3) ? null : reader.GetString(3); + results.Add(new WorkspaceSearchHit( + Path: reader.GetString(0), + Kind: WorkspaceSearchHitKind.Symbol, + Score: ScoreSymbolHit(query, name, container), + Excerpt: container is null ? name : $"{container}.{name}", + SymbolName: name, + SymbolKind: reader.GetString(2), + StartLine: reader.GetInt32(4), + EndLine: reader.GetInt32(4))); + } + + return results; + } + + /// + public async Task> ListChunksAsync(string workspaceRoot, CancellationToken cancellationToken) + { + await using var connection = await OpenWorkspaceConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var sql = """ + SELECT id, path, language, excerpt, content, start_line, end_line, embedding + FROM indexed_chunks; + """; + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new IndexedWorkspaceChunk( + Id: reader.GetString(0), + Path: reader.GetString(1), + Language: reader.GetString(2), + Excerpt: reader.GetString(3), + Content: reader.GetString(4), + StartLine: reader.GetInt32(5), + EndLine: reader.GetInt32(6), + Embedding: HashTextEmbeddingService.Deserialize(reader.GetString(7)))); + } + + return results; + } + + /// + public Task SaveMemoryAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + => SaveMemoryCoreAsync(workspaceRoot, entry, cancellationToken); + + /// + public async Task DeleteMemoryAsync( + string? workspaceRoot, + MemoryScope scope, + string id, + CancellationToken cancellationToken) + { + await using var connection = await OpenMemoryConnectionAsync(workspaceRoot, scope, cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + var deleted = await ExecuteDeleteByIdAsync(connection, transaction, "memory_entries", id, cancellationToken).ConfigureAwait(false); + await ExecuteDeleteByIdAsync(connection, transaction, "memory_entries_fts", id, cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return deleted > 0; + } + + /// + public async Task> ListMemoryAsync( + string? workspaceRoot, + MemoryScope? scope, + string? query, + int limit, + CancellationToken cancellationToken) + { + var entries = new List(); + var scopes = scope is null ? new[] { MemoryScope.Project, MemoryScope.User } : [scope.Value]; + foreach (var candidateScope in scopes) + { + await using var connection = await OpenMemoryConnectionAsync(workspaceRoot, candidateScope, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(query)) + { + entries.AddRange(await ListMemoryWithoutQueryAsync(connection, limit, cancellationToken).ConfigureAwait(false)); + } + else + { + entries.AddRange(await ListMemoryWithQueryAsync(connection, query!, limit, cancellationToken).ConfigureAwait(false)); + } + } + + return entries + .OrderByDescending(static entry => entry.UpdatedAtUtc) + .Take(limit) + .ToArray(); + } + + private async Task SaveMemoryCoreAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + await using var connection = await OpenMemoryConnectionAsync(workspaceRoot, entry.Scope, cancellationToken).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + await ExecuteDeleteByIdAsync(connection, transaction, "memory_entries", entry.Id, cancellationToken).ConfigureAwait(false); + await ExecuteDeleteByIdAsync(connection, transaction, "memory_entries_fts", entry.Id, cancellationToken).ConfigureAwait(false); + + await using (var command = connection.CreateCommand()) + { + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO memory_entries ( + id, scope, content, source, source_session_id, source_turn_id, tags_json, confidence, + related_file_path, related_symbol_name, created_at_utc, updated_at_utc, embedding) + VALUES ( + $id, $scope, $content, $source, $sourceSessionId, $sourceTurnId, $tagsJson, $confidence, + $relatedFilePath, $relatedSymbolName, $createdAtUtc, $updatedAtUtc, $embedding); + """; + BindMemoryParameters(command, entry); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await using (var command = connection.CreateCommand()) + { + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO memory_entries_fts (id, content, tags) + VALUES ($id, $content, $tags); + """; + command.Parameters.AddWithValue("$id", entry.Id); + command.Parameters.AddWithValue("$content", entry.Content); + command.Parameters.AddWithValue("$tags", string.Join(' ', entry.Tags)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return entry; + } + + private async Task> ListMemoryWithoutQueryAsync( + SqliteConnection connection, + int limit, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, scope, content, source, source_session_id, source_turn_id, tags_json, confidence, + related_file_path, related_symbol_name, created_at_utc, updated_at_utc, embedding + FROM memory_entries + ORDER BY updated_at_utc DESC + LIMIT $limit; + """; + command.Parameters.AddWithValue("$limit", limit); + return await ReadMemoryEntriesAsync(command, cancellationToken).ConfigureAwait(false); + } + + private async Task> ListMemoryWithQueryAsync( + SqliteConnection connection, + string query, + int limit, + CancellationToken cancellationToken) + { + var normalizedQuery = NormalizeFtsQuery(query); + if (string.IsNullOrWhiteSpace(normalizedQuery)) + { + return []; + } + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT e.id, e.scope, e.content, e.source, e.source_session_id, e.source_turn_id, e.tags_json, e.confidence, + e.related_file_path, e.related_symbol_name, e.created_at_utc, e.updated_at_utc, e.embedding + FROM memory_entries_fts f + JOIN memory_entries e ON e.id = f.id + WHERE memory_entries_fts MATCH $query + ORDER BY bm25(memory_entries_fts) + LIMIT $limit; + """; + command.Parameters.AddWithValue("$query", normalizedQuery); + command.Parameters.AddWithValue("$limit", limit); + return await ReadMemoryEntriesAsync(command, cancellationToken).ConfigureAwait(false); + } + + private static async Task> ReadMemoryEntriesAsync(SqliteCommand command, CancellationToken cancellationToken) + { + var results = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + results.Add(new MemoryEntry( + Id: reader.GetString(0), + Scope: Enum.Parse(reader.GetString(1), ignoreCase: true), + Content: reader.GetString(2), + Source: reader.GetString(3), + SourceSessionId: reader.IsDBNull(4) ? null : reader.GetString(4), + SourceTurnId: reader.IsDBNull(5) ? null : reader.GetString(5), + Tags: System.Text.Json.JsonSerializer.Deserialize(reader.GetString(6)) ?? [], + Confidence: reader.IsDBNull(7) ? null : reader.GetDouble(7), + RelatedFilePath: reader.IsDBNull(8) ? null : reader.GetString(8), + RelatedSymbolName: reader.IsDBNull(9) ? null : reader.GetString(9), + CreatedAtUtc: DateTimeOffset.Parse(reader.GetString(10), CultureInfo.InvariantCulture), + UpdatedAtUtc: DateTimeOffset.Parse(reader.GetString(11), CultureInfo.InvariantCulture))); + } + + return results; + } + + private static void BindMemoryParameters(SqliteCommand command, MemoryEntry entry) + { + command.Parameters.AddWithValue("$id", entry.Id); + command.Parameters.AddWithValue("$scope", entry.Scope.ToString()); + command.Parameters.AddWithValue("$content", entry.Content); + command.Parameters.AddWithValue("$source", entry.Source); + command.Parameters.AddWithValue("$sourceSessionId", (object?)entry.SourceSessionId ?? DBNull.Value); + command.Parameters.AddWithValue("$sourceTurnId", (object?)entry.SourceTurnId ?? DBNull.Value); + command.Parameters.AddWithValue("$tagsJson", System.Text.Json.JsonSerializer.Serialize(entry.Tags)); + command.Parameters.AddWithValue("$confidence", entry.Confidence is null ? DBNull.Value : entry.Confidence.Value); + command.Parameters.AddWithValue("$relatedFilePath", (object?)entry.RelatedFilePath ?? DBNull.Value); + command.Parameters.AddWithValue("$relatedSymbolName", (object?)entry.RelatedSymbolName ?? DBNull.Value); + command.Parameters.AddWithValue("$createdAtUtc", entry.CreatedAtUtc.ToString("O", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$updatedAtUtc", entry.UpdatedAtUtc.ToString("O", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$embedding", HashTextEmbeddingService.Serialize(HashTextEmbeddingService.Embed(entry.Content))); + } + + private static async Task InsertChunkAsync( + SqliteConnection connection, + DbTransaction transaction, + IndexedWorkspaceChunk chunk, + CancellationToken cancellationToken) + { + await using (var command = connection.CreateCommand()) + { + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO indexed_chunks (id, path, language, excerpt, content, start_line, end_line, embedding) + VALUES ($id, $path, $language, $excerpt, $content, $startLine, $endLine, $embedding); + """; + command.Parameters.AddWithValue("$id", chunk.Id); + command.Parameters.AddWithValue("$path", chunk.Path); + command.Parameters.AddWithValue("$language", chunk.Language); + command.Parameters.AddWithValue("$excerpt", chunk.Excerpt); + command.Parameters.AddWithValue("$content", chunk.Content); + command.Parameters.AddWithValue("$startLine", chunk.StartLine); + command.Parameters.AddWithValue("$endLine", chunk.EndLine); + command.Parameters.AddWithValue("$embedding", HashTextEmbeddingService.Serialize(chunk.Embedding)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await using (var command = connection.CreateCommand()) + { + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO indexed_chunks_fts (id, path, excerpt, content) + VALUES ($id, $path, $excerpt, $content); + """; + command.Parameters.AddWithValue("$id", chunk.Id); + command.Parameters.AddWithValue("$path", chunk.Path); + command.Parameters.AddWithValue("$excerpt", chunk.Excerpt); + command.Parameters.AddWithValue("$content", chunk.Content); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task InsertSymbolAsync( + SqliteConnection connection, + DbTransaction transaction, + IndexedWorkspaceSymbol symbol, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO indexed_symbols (id, path, name, kind, container, line, column_number) + VALUES ($id, $path, $name, $kind, $container, $line, $column); + """; + command.Parameters.AddWithValue("$id", symbol.Id); + command.Parameters.AddWithValue("$path", symbol.Path); + command.Parameters.AddWithValue("$name", symbol.Name); + command.Parameters.AddWithValue("$kind", symbol.Kind); + command.Parameters.AddWithValue("$container", (object?)symbol.Container ?? DBNull.Value); + command.Parameters.AddWithValue("$line", symbol.Line); + command.Parameters.AddWithValue("$column", symbol.Column); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task InsertEdgeAsync( + SqliteConnection connection, + DbTransaction transaction, + IndexedWorkspaceProjectEdge edge, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO project_edges (source_path, target, kind) + VALUES ($sourcePath, $target, $kind); + """; + command.Parameters.AddWithValue("$sourcePath", edge.SourcePath); + command.Parameters.AddWithValue("$target", edge.Target); + command.Parameters.AddWithValue("$kind", edge.Kind); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task OpenWorkspaceConnectionAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var dbPath = GetWorkspaceDatabasePath(workspaceRoot); + return await OpenConnectionAsync(dbPath, cancellationToken).ConfigureAwait(false); + } + + private async Task OpenMemoryConnectionAsync( + string? workspaceRoot, + MemoryScope scope, + CancellationToken cancellationToken) + { + var dbPath = scope == MemoryScope.Project + ? GetWorkspaceDatabasePath(workspaceRoot ?? throw new InvalidOperationException("Workspace root is required for project-scoped memory.")) + : GetUserMemoryDatabasePath(); + return await OpenConnectionAsync(dbPath, cancellationToken).ConfigureAwait(false); + } + + private async Task OpenConnectionAsync(string dbPath, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(dbPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + fileSystem.CreateDirectory(directory); + } + + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Shared, + }.ToString()); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + + private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + var commands = new[] + { + """ + CREATE TABLE IF NOT EXISTS index_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS indexed_chunks ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + language TEXT NOT NULL, + excerpt TEXT NOT NULL, + content TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + embedding TEXT NOT NULL + ); + """, + """ + CREATE VIRTUAL TABLE IF NOT EXISTS indexed_chunks_fts USING fts5( + id UNINDEXED, + path, + excerpt, + content + ); + """, + """ + CREATE TABLE IF NOT EXISTS indexed_symbols ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + container TEXT NULL, + line INTEGER NOT NULL, + column_number INTEGER NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS project_edges ( + source_path TEXT NOT NULL, + target TEXT NOT NULL, + kind TEXT NOT NULL + ); + """, + """ + CREATE TABLE IF NOT EXISTS memory_entries ( + id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT NOT NULL, + source_session_id TEXT NULL, + source_turn_id TEXT NULL, + tags_json TEXT NOT NULL, + confidence REAL NULL, + related_file_path TEXT NULL, + related_symbol_name TEXT NULL, + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL, + embedding TEXT NOT NULL + ); + """, + """ + CREATE VIRTUAL TABLE IF NOT EXISTS memory_entries_fts USING fts5( + id UNINDEXED, + content, + tags + ); + """ + }; + + foreach (var sql in commands) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task ExecuteNonQueryAsync( + SqliteConnection connection, + DbTransaction transaction, + string sql, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = sql; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ExecuteDeleteByIdAsync( + SqliteConnection connection, + DbTransaction transaction, + string tableName, + string id, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = $"DELETE FROM {tableName} WHERE id = $id;"; + command.Parameters.AddWithValue("$id", id); + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task UpsertMetadataAsync( + SqliteConnection connection, + DbTransaction transaction, + string key, + string value, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = (SqliteTransaction)transaction; + command.CommandText = """ + INSERT INTO index_metadata (key, value) + VALUES ($key, $value) + ON CONFLICT(key) DO UPDATE SET value = excluded.value; + """; + command.Parameters.AddWithValue("$key", key); + command.Parameters.AddWithValue("$value", value); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task CountAsync(SqliteConnection connection, string tableName, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {tableName};"; + return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), CultureInfo.InvariantCulture); + } + + private static async Task CountDistinctAsync( + SqliteConnection connection, + string tableName, + string columnName, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(DISTINCT {columnName}) FROM {tableName};"; + return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false), CultureInfo.InvariantCulture); + } + + private static async Task TryReadMetadataDateAsync( + SqliteConnection connection, + string key, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT value FROM index_metadata WHERE key = $key LIMIT 1;"; + command.Parameters.AddWithValue("$key", key); + var scalar = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return scalar is string text && DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed) + ? parsed + : null; + } + + private string GetWorkspaceDatabasePath(string workspaceRoot) + { + var normalized = pathService.GetFullPath(workspaceRoot); + return pathService.Combine(normalized, ".sharpclaw", WorkspaceKnowledgeDirectoryName, WorkspaceKnowledgeFileName); + } + + private string GetUserMemoryDatabasePath() + => pathService.Combine(userProfilePaths.GetUserSharpClawRoot(), WorkspaceKnowledgeDirectoryName, UserMemoryFileName); + + private static string NormalizeFtsQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return string.Empty; + } + + var tokens = query + .Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries) + .Select(static token => new string(token.Where(static character => char.IsLetterOrDigit(character) || character == '_').ToArray())) + .Where(static token => !string.IsNullOrWhiteSpace(token)) + .Select(static token => token.EndsWith('*') ? token : $"{token}*"); + + return string.Join(" ", tokens); + } + + private static double ScoreSymbolHit(string query, string name, string? container) + { + if (string.Equals(query, name, StringComparison.OrdinalIgnoreCase)) + { + return 1d; + } + + if (name.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + { + return 0.9d; + } + + if (!string.IsNullOrWhiteSpace(container) && container.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return 0.65d; + } + + return 0.55d; + } +} diff --git a/src/SharpClaw.Code.Memory/Services/WorkspaceIndexService.cs b/src/SharpClaw.Code.Memory/Services/WorkspaceIndexService.cs new file mode 100644 index 0000000..cbd0064 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/WorkspaceIndexService.cs @@ -0,0 +1,291 @@ +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Builds a workspace knowledge index from source files, symbols, and project references. +/// +public sealed class WorkspaceIndexService( + IFileSystem fileSystem, + IPathService pathService, + IWorkspaceKnowledgeStore knowledgeStore) : IWorkspaceIndexService +{ + private static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase) + { + ".git", + ".sharpclaw", + "bin", + "obj", + "node_modules" + }; + + private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cs", ".csproj", ".sln", ".md", ".txt", ".json", ".jsonc", ".yml", ".yaml", ".xml", ".props", ".targets", ".config", ".editorconfig", ".ts", ".tsx", ".js", ".jsx", ".sh", ".ps1", ".cmd" + }; + + /// + public async Task RefreshAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var normalizedRoot = pathService.GetFullPath(workspaceRoot); + var files = EnumerateCandidateFiles(normalizedRoot).ToArray(); + var chunks = new List(); + var symbols = new List(); + var edges = new List(); + var skipped = new List(); + + foreach (var filePath in files) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsTextCandidate(filePath)) + { + skipped.Add(Relativize(normalizedRoot, filePath)); + continue; + } + + var content = await fileSystem.ReadAllTextIfExistsAsync(filePath, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + var relativePath = Relativize(normalizedRoot, filePath); + var language = DetectLanguage(filePath); + chunks.AddRange(CreateChunks(relativePath, content, language)); + + if (language == "csharp") + { + symbols.AddRange(ExtractCSharpSymbols(relativePath, content)); + } + else if (filePath.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + edges.AddRange(ExtractProjectEdges(relativePath, content)); + } + } + + var refreshedAt = DateTimeOffset.UtcNow; + await knowledgeStore.ReplaceWorkspaceIndexAsync( + normalizedRoot, + new WorkspaceIndexDocument(chunks, symbols, edges), + refreshedAt, + cancellationToken).ConfigureAwait(false); + + return new WorkspaceIndexRefreshResult( + WorkspaceRoot: normalizedRoot, + RefreshedAtUtc: refreshedAt, + IndexedFileCount: files.Length - skipped.Count, + ChunkCount: chunks.Count, + SymbolCount: symbols.Count, + ProjectEdgeCount: edges.Count, + SkippedPaths: skipped.ToArray()); + } + + /// + public Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + => knowledgeStore.GetWorkspaceIndexStatusAsync(workspaceRoot, cancellationToken); + + private IEnumerable EnumerateCandidateFiles(string root) + { + var pending = new Stack(); + pending.Push(root); + + while (pending.Count > 0) + { + var current = pending.Pop(); + foreach (var directory in fileSystem.EnumerateDirectories(current)) + { + var name = Path.GetFileName(directory); + if (IgnoredDirectories.Contains(name)) + { + continue; + } + + pending.Push(directory); + } + + foreach (var file in fileSystem.EnumerateFiles(current, "*")) + { + yield return file; + } + } + } + + private static bool IsTextCandidate(string filePath) + => TextExtensions.Contains(Path.GetExtension(filePath)); + + private static string DetectLanguage(string filePath) + => Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".cs" => "csharp", + ".md" => "markdown", + ".json" or ".jsonc" => "json", + ".xml" or ".csproj" or ".props" or ".targets" => "xml", + ".ts" or ".tsx" => "typescript", + ".js" or ".jsx" => "javascript", + ".yml" or ".yaml" => "yaml", + _ => "text" + }; + + private static IReadOnlyList CreateChunks(string relativePath, string content, string language) + { + var lines = content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + const int chunkSize = 40; + var results = new List(); + for (var index = 0; index < lines.Length; index += chunkSize) + { + var startLine = index + 1; + var endLine = Math.Min(lines.Length, index + chunkSize); + var chunkText = string.Join(Environment.NewLine, lines[index..endLine]).Trim(); + if (string.IsNullOrWhiteSpace(chunkText)) + { + continue; + } + + var excerpt = chunkText.Length <= 240 ? chunkText : chunkText[..240].TrimEnd() + "..."; + var id = $"{relativePath}:{startLine}-{endLine}"; + results.Add(new IndexedWorkspaceChunk( + Id: id, + Path: relativePath, + Language: language, + Excerpt: excerpt, + Content: chunkText, + StartLine: startLine, + EndLine: endLine, + Embedding: HashTextEmbeddingService.Embed(chunkText))); + } + + return results; + } + + private static IReadOnlyList ExtractCSharpSymbols(string relativePath, string content) + { + var syntaxTree = CSharpSyntaxTree.ParseText(content); + var root = syntaxTree.GetRoot(); + var results = new List(); + var walker = new SymbolCollector(relativePath, root.SyntaxTree, results); + walker.Visit(root); + return results; + } + + private static IReadOnlyList ExtractProjectEdges(string relativePath, string content) + { + try + { + var document = XDocument.Parse(content); + var edges = new List(); + foreach (var projectReference in document.Descendants().Where(static element => element.Name.LocalName == "ProjectReference")) + { + if (projectReference.Attribute("Include")?.Value is { Length: > 0 } include) + { + edges.Add(new IndexedWorkspaceProjectEdge(relativePath, include, "project-reference")); + } + } + + foreach (var packageReference in document.Descendants().Where(static element => element.Name.LocalName == "PackageReference")) + { + if (packageReference.Attribute("Include")?.Value is { Length: > 0 } include) + { + edges.Add(new IndexedWorkspaceProjectEdge(relativePath, include, "package-reference")); + } + } + + return edges; + } + catch + { + return []; + } + } + + private static string Relativize(string root, string path) + => Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/'); + + private sealed class SymbolCollector(string relativePath, SyntaxTree syntaxTree, List symbols) : CSharpSyntaxWalker + { + private readonly Stack containers = new(); + + public override void VisitNamespaceDeclaration(NamespaceDeclarationSyntax node) + { + containers.Push(node.Name.ToString()); + base.VisitNamespaceDeclaration(node); + containers.Pop(); + } + + public override void VisitFileScopedNamespaceDeclaration(FileScopedNamespaceDeclarationSyntax node) + { + containers.Push(node.Name.ToString()); + base.VisitFileScopedNamespaceDeclaration(node); + containers.Pop(); + } + + public override void VisitClassDeclaration(ClassDeclarationSyntax node) + { + Add(node.Identifier.Text, "class", node.Identifier.GetLocation()); + VisitContainer(node.Identifier.Text, () => base.VisitClassDeclaration(node)); + } + + public override void VisitRecordDeclaration(RecordDeclarationSyntax node) + { + Add(node.Identifier.Text, "record", node.Identifier.GetLocation()); + VisitContainer(node.Identifier.Text, () => base.VisitRecordDeclaration(node)); + } + + public override void VisitStructDeclaration(StructDeclarationSyntax node) + { + Add(node.Identifier.Text, "struct", node.Identifier.GetLocation()); + VisitContainer(node.Identifier.Text, () => base.VisitStructDeclaration(node)); + } + + public override void VisitInterfaceDeclaration(InterfaceDeclarationSyntax node) + { + Add(node.Identifier.Text, "interface", node.Identifier.GetLocation()); + VisitContainer(node.Identifier.Text, () => base.VisitInterfaceDeclaration(node)); + } + + public override void VisitEnumDeclaration(EnumDeclarationSyntax node) + { + Add(node.Identifier.Text, "enum", node.Identifier.GetLocation()); + VisitContainer(node.Identifier.Text, () => base.VisitEnumDeclaration(node)); + } + + public override void VisitMethodDeclaration(MethodDeclarationSyntax node) + { + Add(node.Identifier.Text, "method", node.Identifier.GetLocation()); + base.VisitMethodDeclaration(node); + } + + public override void VisitPropertyDeclaration(PropertyDeclarationSyntax node) + { + Add(node.Identifier.Text, "property", node.Identifier.GetLocation()); + base.VisitPropertyDeclaration(node); + } + + private void VisitContainer(string name, Action inner) + { + containers.Push(name); + inner(); + containers.Pop(); + } + + private void Add(string name, string kind, Location location) + { + var lineSpan = syntaxTree.GetLineSpan(location.SourceSpan); + var container = containers.Count == 0 ? null : string.Join('.', containers.Reverse()); + symbols.Add(new IndexedWorkspaceSymbol( + Id: $"{relativePath}:{kind}:{name}:{lineSpan.StartLinePosition.Line + 1}:{lineSpan.StartLinePosition.Character + 1}", + Path: relativePath, + Name: name, + Kind: kind, + Container: container, + Line: lineSpan.StartLinePosition.Line + 1, + Column: lineSpan.StartLinePosition.Character + 1)); + } + } +} diff --git a/src/SharpClaw.Code.Memory/Services/WorkspaceSearchService.cs b/src/SharpClaw.Code.Memory/Services/WorkspaceSearchService.cs new file mode 100644 index 0000000..df9f670 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/WorkspaceSearchService.cs @@ -0,0 +1,76 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Executes hybrid search across lexical chunk matches, symbols, and deterministic semantic similarity. +/// +public sealed class WorkspaceSearchService(IWorkspaceKnowledgeStore knowledgeStore) : IWorkspaceSearchService +{ + /// + public async Task SearchAsync( + string workspaceRoot, + WorkspaceSearchRequest request, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workspaceRoot); + ArgumentNullException.ThrowIfNull(request); + + var limit = Math.Clamp(request.Limit.GetValueOrDefault(8), 1, 50); + var lexical = await knowledgeStore + .SearchChunksLexicalAsync(workspaceRoot, request.Query, limit * 2, cancellationToken) + .ConfigureAwait(false); + var symbols = request.IncludeSymbols + ? await knowledgeStore.SearchSymbolsAsync(workspaceRoot, request.Query, limit * 2, cancellationToken).ConfigureAwait(false) + : []; + var semantic = request.IncludeSemantic + ? await ComputeSemanticHitsAsync(workspaceRoot, request.Query, limit * 2, cancellationToken).ConfigureAwait(false) + : []; + var status = await knowledgeStore.GetWorkspaceIndexStatusAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + + var merged = lexical + .Concat(symbols) + .Concat(semantic) + .GroupBy(static hit => $"{hit.Kind}:{hit.Path}:{hit.StartLine}:{hit.EndLine}:{hit.SymbolName}", StringComparer.Ordinal) + .Select(static group => group.OrderByDescending(static hit => hit.Score).First()) + .OrderByDescending(static hit => hit.Score) + .Take(limit) + .ToArray(); + + return new WorkspaceSearchResult( + Query: request.Query, + GeneratedAtUtc: DateTimeOffset.UtcNow, + IndexRefreshedAtUtc: status.RefreshedAtUtc, + Hits: merged); + } + + private async Task> ComputeSemanticHitsAsync( + string workspaceRoot, + string query, + int limit, + CancellationToken cancellationToken) + { + var chunks = await knowledgeStore.ListChunksAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + if (chunks.Count == 0) + { + return []; + } + + var queryVector = HashTextEmbeddingService.Embed(query); + return chunks + .Select(chunk => new WorkspaceSearchHit( + Path: chunk.Path, + Kind: WorkspaceSearchHitKind.Semantic, + Score: HashTextEmbeddingService.Cosine(queryVector, chunk.Embedding), + Excerpt: chunk.Excerpt, + SymbolName: null, + SymbolKind: null, + StartLine: chunk.StartLine, + EndLine: chunk.EndLine)) + .Where(static hit => hit.Score > 0) + .OrderByDescending(static hit => hit.Score) + .Take(limit) + .ToArray(); + } +} diff --git a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj index 839d28c..8cc19e4 100644 --- a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj +++ b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj @@ -2,6 +2,8 @@ + + diff --git a/src/SharpClaw.Code.Permissions/Abstractions/IApprovalTransport.cs b/src/SharpClaw.Code.Permissions/Abstractions/IApprovalTransport.cs new file mode 100644 index 0000000..1ec4c9a --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Abstractions/IApprovalTransport.cs @@ -0,0 +1,23 @@ +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Permissions.Abstractions; + +/// +/// Handles interactive approval requests for a specific caller transport. +/// +public interface IApprovalTransport +{ + /// + /// Returns whether the transport can handle approvals for the supplied context. + /// + bool CanHandle(PermissionEvaluationContext context); + + /// + /// Requests approval through the transport. + /// + Task RequestApprovalAsync( + ApprovalRequest request, + PermissionEvaluationContext context, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs b/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs index 7b996ec..75afd24 100644 --- a/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs +++ b/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs @@ -9,14 +9,25 @@ namespace SharpClaw.Code.Permissions.Services; /// public sealed class ApprovalService( ConsoleApprovalService consoleApprovalService, - NonInteractiveApprovalService nonInteractiveApprovalService) : IApprovalService + NonInteractiveApprovalService nonInteractiveApprovalService, + IEnumerable approvalTransports) : IApprovalService { + private readonly IApprovalTransport[] approvalTransports = approvalTransports.ToArray(); + /// public Task RequestApprovalAsync( ApprovalRequest request, PermissionEvaluationContext context, CancellationToken cancellationToken) - => context.IsInteractive + { + var transport = approvalTransports.FirstOrDefault(candidate => candidate.CanHandle(context)); + if (transport is not null) + { + return transport.RequestApprovalAsync(request, context, cancellationToken); + } + + return context.IsInteractive ? consoleApprovalService.RequestApprovalAsync(request, context, cancellationToken) : nonInteractiveApprovalService.RequestApprovalAsync(request, context, cancellationToken); + } } diff --git a/src/SharpClaw.Code.Permissions/Services/ConsoleApprovalService.cs b/src/SharpClaw.Code.Permissions/Services/ConsoleApprovalService.cs index f9e2073..45bb1ae 100644 --- a/src/SharpClaw.Code.Permissions/Services/ConsoleApprovalService.cs +++ b/src/SharpClaw.Code.Permissions/Services/ConsoleApprovalService.cs @@ -37,6 +37,7 @@ public Task RequestApprovalAsync( ResolvedBy: "console", Reason: approved ? "Approved via interactive console." : "Denied via interactive console.", ResolvedAtUtc: DateTimeOffset.UtcNow, - ExpiresAtUtc: null)); + ExpiresAtUtc: null, + RememberForSession: approved && request.CanRememberDecision)); } } diff --git a/src/SharpClaw.Code.Permissions/Services/NonInteractiveApprovalService.cs b/src/SharpClaw.Code.Permissions/Services/NonInteractiveApprovalService.cs index 9aa026d..44d1208 100644 --- a/src/SharpClaw.Code.Permissions/Services/NonInteractiveApprovalService.cs +++ b/src/SharpClaw.Code.Permissions/Services/NonInteractiveApprovalService.cs @@ -21,5 +21,6 @@ public Task RequestApprovalAsync( ResolvedBy: "non-interactive", Reason: "Approval was required but the caller is non-interactive.", ResolvedAtUtc: DateTimeOffset.UtcNow, - ExpiresAtUtc: null)); + ExpiresAtUtc: null, + RememberForSession: false)); } diff --git a/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs b/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs index 114ac8c..88e0069 100644 --- a/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs +++ b/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs @@ -135,7 +135,7 @@ private async Task ResolveApprovalAsync( CanRememberDecision: ruleResult.CanRememberApproval); var approvalDecision = await approvalService.RequestApprovalAsync(approvalRequest, context, cancellationToken).ConfigureAwait(false); - if (approvalDecision.Approved && ruleResult.CanRememberApproval) + if (approvalDecision.Approved && ruleResult.CanRememberApproval && approvalDecision.RememberForSession) { sessionApprovalMemory.Store(context.SessionId, approvalKey, new ApprovalMemoryEntry(approvalDecision)); } diff --git a/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs b/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs index 06f86ef..7e91488 100644 --- a/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs +++ b/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs @@ -12,6 +12,7 @@ namespace SharpClaw.Code.Protocol.Models; /// A concise explanation for the approval outcome, if available. /// The UTC timestamp when the approval was resolved. /// An optional UTC expiration timestamp for the approval. +/// Whether the approval should be remembered for the current session when allowed. public sealed record ApprovalDecision( ApprovalScope Scope, bool Approved, @@ -19,4 +20,5 @@ public sealed record ApprovalDecision( string? ResolvedBy, string? Reason, DateTimeOffset ResolvedAtUtc, - DateTimeOffset? ExpiresAtUtc); + DateTimeOffset? ExpiresAtUtc, + bool RememberForSession = false); diff --git a/src/SharpClaw.Code.Protocol/Models/KnowledgeModels.cs b/src/SharpClaw.Code.Protocol/Models/KnowledgeModels.cs new file mode 100644 index 0000000..e8621e2 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/KnowledgeModels.cs @@ -0,0 +1,258 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Identifies the durable scope for a memory entry. +/// +public enum MemoryScope +{ + /// + /// Memory applies to the current workspace only. + /// + Project, + + /// + /// Memory applies across workspaces for the current user. + /// + User, +} + +/// +/// Identifies the category of a workspace search hit. +/// +public enum WorkspaceSearchHitKind +{ + /// + /// The hit was produced from a lexical chunk match. + /// + Lexical, + + /// + /// The hit was produced from symbol metadata. + /// + Symbol, + + /// + /// The hit was produced from semantic embedding similarity. + /// + Semantic, +} + +/// +/// Describes the configured local runtime flavor for an OpenAI-compatible endpoint. +/// +public enum LocalRuntimeKind +{ + /// + /// A generic OpenAI-compatible runtime. + /// + Generic, + + /// + /// Ollama. + /// + Ollama, + + /// + /// llama.cpp server mode. + /// + LlamaCpp, +} + +/// +/// Describes the authentication mode for a provider or runtime profile. +/// +public enum ProviderAuthMode +{ + /// + /// API key is required. + /// + ApiKey, + + /// + /// Authentication is optional. + /// + Optional, + + /// + /// No authentication is expected. + /// + None, +} + +/// +/// Represents one structured memory entry stored by SharpClaw. +/// +/// Stable memory entry identifier. +/// Project or user scope. +/// Memory content. +/// Origin of the memory item. +/// Source session id when applicable. +/// Source turn id when applicable. +/// Optional tags. +/// Optional confidence score. +/// Optional related file path. +/// Optional related symbol name. +/// Creation timestamp. +/// Last update timestamp. +public sealed record MemoryEntry( + string Id, + MemoryScope Scope, + string Content, + string Source, + string? SourceSessionId, + string? SourceTurnId, + string[] Tags, + double? Confidence, + string? RelatedFilePath, + string? RelatedSymbolName, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + +/// +/// Represents one ranked workspace search hit. +/// +/// Workspace-relative or absolute path. +/// Hit category. +/// Combined search score. +/// Concise excerpt or summary. +/// Related symbol name when applicable. +/// Related symbol kind when applicable. +/// Optional 1-based starting line. +/// Optional 1-based ending line. +public sealed record WorkspaceSearchHit( + string Path, + WorkspaceSearchHitKind Kind, + double Score, + string Excerpt, + string? SymbolName, + string? SymbolKind, + int? StartLine, + int? EndLine); + +/// +/// Describes a workspace search request. +/// +/// The search text. +/// Requested maximum hit count. +/// Whether symbol hits should be included. +/// Whether semantic ranking should be included. +public sealed record WorkspaceSearchRequest( + string Query, + int? Limit, + bool IncludeSymbols = true, + bool IncludeSemantic = true); + +/// +/// Represents a ranked workspace search response. +/// +/// Executed query. +/// Search timestamp. +/// Last successful index refresh, if any. +/// Ranked hits. +public sealed record WorkspaceSearchResult( + string Query, + DateTimeOffset GeneratedAtUtc, + DateTimeOffset? IndexRefreshedAtUtc, + WorkspaceSearchHit[] Hits); + +/// +/// Summarizes the current workspace knowledge index state. +/// +/// Indexed workspace root. +/// Last successful refresh timestamp. +/// Indexed file count. +/// Indexed text chunk count. +/// Indexed symbol count. +/// Indexed project/dependency edge count. +public sealed record WorkspaceIndexStatus( + string WorkspaceRoot, + DateTimeOffset? RefreshedAtUtc, + int IndexedFileCount, + int ChunkCount, + int SymbolCount, + int ProjectEdgeCount); + +/// +/// Represents the result of refreshing the workspace knowledge index. +/// +/// Indexed workspace root. +/// Refresh timestamp. +/// Indexed file count. +/// Indexed text chunk count. +/// Indexed symbol count. +/// Indexed project/dependency edge count. +/// Paths skipped during indexing. +public sealed record WorkspaceIndexRefreshResult( + string WorkspaceRoot, + DateTimeOffset RefreshedAtUtc, + int IndexedFileCount, + int ChunkCount, + int SymbolCount, + int ProjectEdgeCount, + string[] SkippedPaths); + +/// +/// Describes one discovered model surfaced by a provider or local runtime. +/// +/// Stable model identifier. +/// Human-readable name. +/// Whether chat tool calling is expected to work. +/// Whether the model can be used for embeddings. +public sealed record ProviderDiscoveredModel( + string Id, + string DisplayName, + bool SupportsTools, + bool SupportsEmbeddings); + +/// +/// Summarizes one configured local runtime profile. +/// +/// Profile name. +/// Runtime kind. +/// Runtime base URL. +/// Default chat model. +/// Default embedding model, if any. +/// Configured auth mode. +/// Whether the last health probe succeeded. +/// Health probe detail. +/// Discovered models for the profile. +public sealed record LocalRuntimeProfileSummary( + string Name, + LocalRuntimeKind Kind, + string BaseUrl, + string DefaultChatModel, + string? DefaultEmbeddingModel, + ProviderAuthMode AuthMode, + bool IsHealthy, + string? HealthDetail, + ProviderDiscoveredModel[] AvailableModels); + +/// +/// Represents an ACP memory save request. +/// +/// Target memory scope. +/// Memory content. +/// Memory source label. +/// Optional tags. +/// Optional confidence score. +/// Optional related file path. +/// Optional related symbol name. +public sealed record MemorySaveRequest( + MemoryScope Scope, + string Content, + string Source, + string[]? Tags = null, + double? Confidence = null, + string? RelatedFilePath = null, + string? RelatedSymbolName = null); + +/// +/// Represents an ACP memory list request. +/// +/// Optional scope filter. +/// Optional free-text query. +/// Maximum result count. +public sealed record MemoryListRequest( + MemoryScope? Scope, + string? Query, + int? Limit); diff --git a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs index dd603c1..eb25f04 100644 --- a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs @@ -231,11 +231,19 @@ public sealed record AgentCatalogEntry( /// Provider default model. /// Aliases that resolve to this provider. /// Current authentication state. +/// Whether tool calling is supported. +/// Whether embeddings are supported. +/// Discovered models for the provider. +/// Configured local runtime profiles, if any. public sealed record ProviderModelCatalogEntry( string ProviderName, string DefaultModel, string[] Aliases, - AuthStatus AuthStatus); + AuthStatus AuthStatus, + bool SupportsToolCalls = true, + bool SupportsEmbeddings = false, + ProviderDiscoveredModel[]? AvailableModels = null, + LocalRuntimeProfileSummary[]? LocalRuntimeProfiles = null); /// /// Summarizes a browser-connectable target. diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index 62e628c..d04b83a 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -47,6 +47,24 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(ProviderDeltaEvent))] [JsonSerializable(typeof(ProviderStartedEvent))] [JsonSerializable(typeof(ProjectMemory))] +[JsonSerializable(typeof(MemoryScope))] +[JsonSerializable(typeof(MemoryEntry))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(MemorySaveRequest))] +[JsonSerializable(typeof(MemoryListRequest))] +[JsonSerializable(typeof(WorkspaceSearchHitKind))] +[JsonSerializable(typeof(WorkspaceSearchHit))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(WorkspaceSearchRequest))] +[JsonSerializable(typeof(WorkspaceSearchResult))] +[JsonSerializable(typeof(WorkspaceIndexStatus))] +[JsonSerializable(typeof(WorkspaceIndexRefreshResult))] +[JsonSerializable(typeof(LocalRuntimeKind))] +[JsonSerializable(typeof(ProviderAuthMode))] +[JsonSerializable(typeof(ProviderDiscoveredModel))] +[JsonSerializable(typeof(ProviderDiscoveredModel[]))] +[JsonSerializable(typeof(LocalRuntimeProfileSummary))] +[JsonSerializable(typeof(LocalRuntimeProfileSummary[]))] [JsonSerializable(typeof(ProviderEvent))] [JsonSerializable(typeof(ProviderRequest))] [JsonSerializable(typeof(RecoveryContext))] diff --git a/src/SharpClaw.Code.Providers/Abstractions/IProviderCatalogService.cs b/src/SharpClaw.Code.Providers/Abstractions/IProviderCatalogService.cs new file mode 100644 index 0000000..acbf155 --- /dev/null +++ b/src/SharpClaw.Code.Providers/Abstractions/IProviderCatalogService.cs @@ -0,0 +1,16 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Providers.Abstractions; + +/// +/// Resolves the surfaced provider/model catalog, including local runtime profiles. +/// +public interface IProviderCatalogService +{ + /// + /// Lists the effective provider model catalog. + /// + /// Cancellation token. + /// The catalog entries. + Task> ListAsync(CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Providers/AnthropicProvider.cs b/src/SharpClaw.Code.Providers/AnthropicProvider.cs index 283dc8f..c370587 100644 --- a/src/SharpClaw.Code.Providers/AnthropicProvider.cs +++ b/src/SharpClaw.Code.Providers/AnthropicProvider.cs @@ -27,7 +27,11 @@ public sealed class AnthropicProvider( /// public Task GetAuthStatusAsync(CancellationToken cancellationToken) - => Task.FromResult(Internal.ProviderAuthStatusFactory.FromApiKeyPresence(ProviderName, _options.ApiKey)); + => Task.FromResult(Internal.ProviderAuthStatusFactory.FromConfiguration( + ProviderName, + _options.ApiKey, + ProviderAuthMode.ApiKey, + hasAuthOptionalRuntime: false)); /// public async Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) diff --git a/src/SharpClaw.Code.Providers/Configuration/LocalRuntimeProfileOptions.cs b/src/SharpClaw.Code.Providers/Configuration/LocalRuntimeProfileOptions.cs new file mode 100644 index 0000000..5852939 --- /dev/null +++ b/src/SharpClaw.Code.Providers/Configuration/LocalRuntimeProfileOptions.cs @@ -0,0 +1,49 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Providers.Configuration; + +/// +/// Configures a named local runtime profile that rides on the OpenAI-compatible provider. +/// +public sealed class LocalRuntimeProfileOptions +{ + /// + /// Gets or sets the runtime kind. + /// + public LocalRuntimeKind Kind { get; set; } = LocalRuntimeKind.Generic; + + /// + /// Gets or sets the runtime base URL. + /// + public string BaseUrl { get; set; } = "http://127.0.0.1:11434/v1/"; + + /// + /// Gets or sets the default chat model id. + /// + public string DefaultChatModel { get; set; } = "default"; + + /// + /// Gets or sets the default embedding model id. + /// + public string? DefaultEmbeddingModel { get; set; } + + /// + /// Gets or sets the runtime auth mode. + /// + public ProviderAuthMode AuthMode { get; set; } = ProviderAuthMode.Optional; + + /// + /// Gets or sets whether chat tool calling is expected to work. + /// + public bool SupportsToolCalls { get; set; } = true; + + /// + /// Gets or sets whether embeddings are expected to work. + /// + public bool SupportsEmbeddings { get; set; } + + /// + /// Gets or sets the optional API key for the local runtime. + /// + public string? ApiKey { get; set; } +} diff --git a/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs b/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs index 6968868..f5e6368 100644 --- a/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs +++ b/src/SharpClaw.Code.Providers/Configuration/OpenAiCompatibleProviderOptions.cs @@ -1,3 +1,5 @@ +using SharpClaw.Code.Protocol.Models; + namespace SharpClaw.Code.Providers.Configuration; /// @@ -20,8 +22,33 @@ public sealed class OpenAiCompatibleProviderOptions /// public string? ApiKey { get; set; } + /// + /// Gets or sets the authentication mode used when the base provider endpoint is selected directly. + /// + public ProviderAuthMode AuthMode { get; set; } = ProviderAuthMode.ApiKey; + /// /// Gets or sets the default model id. /// public string DefaultModel { get; set; } = "gpt-4.1-mini"; + + /// + /// Gets or sets the optional default embedding model id. + /// + public string? DefaultEmbeddingModel { get; set; } + + /// + /// Gets or sets whether the endpoint supports tool calling. + /// + public bool SupportsToolCalls { get; set; } = true; + + /// + /// Gets or sets whether the endpoint supports embeddings. + /// + public bool SupportsEmbeddings { get; set; } + + /// + /// Gets the configured named local runtime profiles. + /// + public Dictionary LocalRuntimes { get; } = new(StringComparer.OrdinalIgnoreCase); } diff --git a/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs b/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs index 4a39b01..a75cdb0 100644 --- a/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs +++ b/src/SharpClaw.Code.Providers/Configuration/ProviderOptionsValidators.cs @@ -71,6 +71,25 @@ public ValidateOptionsResult Validate(string? name, OpenAiCompatibleProviderOpti return ValidateOptionsResult.Fail($"{nameof(OpenAiCompatibleProviderOptions.DefaultModel)} must be set."); } + foreach (var runtime in options.LocalRuntimes) + { + if (string.IsNullOrWhiteSpace(runtime.Key)) + { + return ValidateOptionsResult.Fail("Local runtime profile names must be non-empty."); + } + + if (!string.IsNullOrWhiteSpace(runtime.Value.BaseUrl) + && !Uri.TryCreate(runtime.Value.BaseUrl, UriKind.Absolute, out _)) + { + return ValidateOptionsResult.Fail($"Local runtime '{runtime.Key}' must define an absolute BaseUrl."); + } + + if (string.IsNullOrWhiteSpace(runtime.Value.DefaultChatModel)) + { + return ValidateOptionsResult.Fail($"Local runtime '{runtime.Key}' must define a DefaultChatModel."); + } + } + return ValidateOptionsResult.Success; } } diff --git a/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs b/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs index 58c9282..47350f1 100644 --- a/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs +++ b/src/SharpClaw.Code.Providers/Internal/ProviderAuthStatusFactory.cs @@ -4,10 +4,19 @@ namespace SharpClaw.Code.Providers.Internal; internal static class ProviderAuthStatusFactory { - public static AuthStatus FromApiKeyPresence(string providerName, string? apiKey) + public static AuthStatus FromConfiguration( + string providerName, + string? apiKey, + ProviderAuthMode authMode, + bool hasAuthOptionalRuntime) { ArgumentException.ThrowIfNullOrWhiteSpace(providerName); - var ok = !string.IsNullOrWhiteSpace(apiKey); + var ok = authMode switch + { + ProviderAuthMode.None => true, + ProviderAuthMode.Optional => true, + _ => !string.IsNullOrWhiteSpace(apiKey) || hasAuthOptionalRuntime + }; return new AuthStatus( SubjectId: null, IsAuthenticated: ok, diff --git a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs index 930d49a..a675a16 100644 --- a/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs +++ b/src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs @@ -23,13 +23,18 @@ public sealed class OpenAiCompatibleProvider( { private readonly OpenAiCompatibleProviderOptions _options = options.Value; private OpenAIClient? _cachedOpenAiClient; + internal const string RuntimeProfileMetadataKey = "openai-compatible.profile"; /// public string ProviderName => _options.ProviderName; /// public Task GetAuthStatusAsync(CancellationToken cancellationToken) - => Task.FromResult(Internal.ProviderAuthStatusFactory.FromApiKeyPresence(ProviderName, _options.ApiKey)); + => Task.FromResult(Internal.ProviderAuthStatusFactory.FromConfiguration( + ProviderName, + _options.ApiKey, + _options.AuthMode, + _options.LocalRuntimes.Values.Any(static runtime => runtime.AuthMode != ProviderAuthMode.ApiKey))); /// public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) @@ -42,8 +47,11 @@ private async IAsyncEnumerable StreamEventsAsync( ProviderRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { - var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault(request.Model, _options.DefaultModel); - var openAiClient = GetOrCreateOpenAiClient(); + var profile = ResolveProfile(request.Metadata); + var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault( + request.Model, + profile?.DefaultChatModel ?? _options.DefaultModel); + var openAiClient = GetOrCreateOpenAiClient(profile); var nativeClient = openAiClient.GetChatClient(modelId); using var chatClient = nativeClient.AsIChatClient(); @@ -76,23 +84,29 @@ private async IAsyncEnumerable StreamEventsAsync( } } - private OpenAIClient GetOrCreateOpenAiClient() + private OpenAIClient GetOrCreateOpenAiClient(LocalRuntimeProfileOptions? profile) { - if (_cachedOpenAiClient is not null) + if (profile is null && _cachedOpenAiClient is not null) { return _cachedOpenAiClient; } var openAiOptions = new OpenAIClientOptions(); - var normalized = Internal.ProviderHttpHelpers.NormalizeBaseUrl(_options.BaseUrl); + var normalized = Internal.ProviderHttpHelpers.NormalizeBaseUrl(profile?.BaseUrl ?? _options.BaseUrl); if (normalized is not null) { openAiOptions.Endpoint = new Uri(normalized); } - var credential = new ApiKeyCredential(_options.ApiKey ?? string.Empty); - _cachedOpenAiClient = new OpenAIClient(credential, openAiOptions); - return _cachedOpenAiClient; + var apiKey = profile?.ApiKey ?? _options.ApiKey ?? "local-runtime"; + var credential = new ApiKeyCredential(apiKey); + var client = new OpenAIClient(credential, openAiOptions); + if (profile is null) + { + _cachedOpenAiClient = client; + } + + return client; } private static List BuildChatMessages(ProviderRequest request) @@ -106,4 +120,18 @@ private OpenAIClient GetOrCreateOpenAiClient() messages.Add(new Microsoft.Extensions.AI.ChatMessage(ChatRole.User, request.Prompt)); return messages; } + + private LocalRuntimeProfileOptions? ResolveProfile(IReadOnlyDictionary? metadata) + { + if (metadata is null + || !metadata.TryGetValue(RuntimeProfileMetadataKey, out var profileName) + || string.IsNullOrWhiteSpace(profileName)) + { + return null; + } + + return _options.LocalRuntimes.TryGetValue(profileName, out var profile) + ? profile + : null; + } } diff --git a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs index 168f05b..ddb5776 100644 --- a/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Providers/ProvidersServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class ProvidersServiceCollectionExtensions /// Optional provider catalog configuration. /// Optional Anthropic provider configuration. /// Optional OpenAI-compatible provider configuration. + /// Optional provider resilience configuration. /// The updated service collection. public static IServiceCollection AddSharpClawProviders( this IServiceCollection services, @@ -58,6 +59,7 @@ public static IServiceCollection AddSharpClawProviders( /// Optional provider catalog configuration. /// Optional Anthropic provider configuration. /// Optional OpenAI-compatible provider configuration. + /// Optional provider resilience configuration. /// The updated service collection. public static IServiceCollection AddSharpClawProviders( this IServiceCollection services, @@ -112,6 +114,7 @@ private static IServiceCollection AddSharpClawProvidersCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(serviceProvider => WrapWithResilience(serviceProvider, serviceProvider.GetRequiredService())); diff --git a/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs b/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs new file mode 100644 index 0000000..721de60 --- /dev/null +++ b/src/SharpClaw.Code.Providers/Services/ProviderCatalogService.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Providers; + +/// +/// Builds the surfaced provider and local runtime catalog, including health probes and discovered models. +/// +public sealed class ProviderCatalogService( + IEnumerable modelProviders, + IAuthFlowService authFlowService, + IOptions catalogOptions, + IOptions anthropicOptions, + IOptions openAiOptions) : IProviderCatalogService +{ + /// + public async Task> ListAsync(CancellationToken cancellationToken) + { + var aliasesByProvider = catalogOptions.Value.ModelAliases + .GroupBy(static pair => pair.Value.ProviderName, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Select(pair => pair.Key).OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray(), + StringComparer.OrdinalIgnoreCase); + + var results = new List(); + foreach (var provider in modelProviders.OrderBy(static provider => provider.ProviderName, StringComparer.OrdinalIgnoreCase)) + { + var auth = await authFlowService.GetStatusAsync(provider.ProviderName, cancellationToken).ConfigureAwait(false); + var defaultModel = ResolveDefaultModel(provider.ProviderName); + var supportsToolCalls = !string.Equals(provider.ProviderName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? openAiOptions.Value.SupportsToolCalls + : true; + var supportsEmbeddings = string.Equals(provider.ProviderName, openAiOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + && (openAiOptions.Value.SupportsEmbeddings || !string.IsNullOrWhiteSpace(openAiOptions.Value.DefaultEmbeddingModel)); + var localProfiles = string.Equals(provider.ProviderName, openAiOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? await BuildLocalRuntimeProfilesAsync(cancellationToken).ConfigureAwait(false) + : []; + var availableModels = localProfiles + .SelectMany(static profile => profile.AvailableModels) + .GroupBy(static model => model.Id, StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .ToArray(); + + results.Add(new ProviderModelCatalogEntry( + ProviderName: provider.ProviderName, + DefaultModel: defaultModel, + Aliases: aliasesByProvider.TryGetValue(provider.ProviderName, out var aliases) ? aliases : [], + AuthStatus: auth, + SupportsToolCalls: supportsToolCalls, + SupportsEmbeddings: supportsEmbeddings, + AvailableModels: availableModels, + LocalRuntimeProfiles: localProfiles)); + } + + return results; + } + + private async Task BuildLocalRuntimeProfilesAsync(CancellationToken cancellationToken) + { + if (openAiOptions.Value.LocalRuntimes.Count == 0) + { + return []; + } + + var results = new List(); + foreach (var pair in openAiOptions.Value.LocalRuntimes.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + { + var (isHealthy, detail, models) = await ProbeRuntimeAsync(pair.Value, cancellationToken).ConfigureAwait(false); + results.Add(new LocalRuntimeProfileSummary( + Name: pair.Key, + Kind: pair.Value.Kind, + BaseUrl: pair.Value.BaseUrl, + DefaultChatModel: pair.Value.DefaultChatModel, + DefaultEmbeddingModel: pair.Value.DefaultEmbeddingModel, + AuthMode: pair.Value.AuthMode, + IsHealthy: isHealthy, + HealthDetail: detail, + AvailableModels: models)); + } + + return results.ToArray(); + } + + private async Task<(bool IsHealthy, string? Detail, ProviderDiscoveredModel[] Models)> ProbeRuntimeAsync( + LocalRuntimeProfileOptions profile, + CancellationToken cancellationToken) + { + var routes = profile.Kind == LocalRuntimeKind.Ollama + ? new[] { "models", CreateAbsoluteRoute(profile.BaseUrl, "api/tags") } + : new[] { "models" }; + + try + { + using var httpClient = new HttpClient + { + BaseAddress = new Uri(Internal.ProviderHttpHelpers.NormalizeBaseUrl(profile.BaseUrl) ?? profile.BaseUrl), + Timeout = TimeSpan.FromSeconds(5), + }; + + if (!string.IsNullOrWhiteSpace(profile.ApiKey)) + { + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", profile.ApiKey); + } + + foreach (var route in routes) + { + using var response = await httpClient.GetAsync(route, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + continue; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var models = ParseModels(document.RootElement, profile); + return (true, $"{models.Length} model(s) discovered.", models); + } + + return (false, "Runtime probe failed.", []); + } + catch (Exception exception) + { + return (false, exception.Message, []); + } + } + + private static ProviderDiscoveredModel[] ParseModels(JsonElement root, LocalRuntimeProfileOptions profile) + { + if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array) + { + return data.EnumerateArray() + .Select(element => BuildModel(element.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "unknown" : "unknown", profile)) + .ToArray(); + } + + if (root.TryGetProperty("models", out var models) && models.ValueKind == JsonValueKind.Array) + { + return models.EnumerateArray() + .Select(element => + { + var id = element.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() ?? "unknown" + : element.TryGetProperty("model", out var modelProp) + ? modelProp.GetString() ?? "unknown" + : "unknown"; + return BuildModel(id, profile); + }) + .ToArray(); + } + + return []; + } + + private static ProviderDiscoveredModel BuildModel(string id, LocalRuntimeProfileOptions profile) + => new( + Id: id, + DisplayName: id, + SupportsTools: profile.SupportsToolCalls, + SupportsEmbeddings: profile.SupportsEmbeddings + || string.Equals(id, profile.DefaultEmbeddingModel, StringComparison.OrdinalIgnoreCase)); + + private static string CreateAbsoluteRoute(string baseUrl, string route) + { + var uri = new Uri(baseUrl, UriKind.Absolute); + var builder = new UriBuilder(uri) + { + Path = route.TrimStart('/') + }; + return builder.Uri.ToString(); + } + + private string ResolveDefaultModel(string providerName) + => string.Equals(providerName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? anthropicOptions.Value.DefaultModel + : string.Equals(providerName, openAiOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase) + ? openAiOptions.Value.DefaultModel + : "default"; +} diff --git a/src/SharpClaw.Code.Providers/Services/ProviderRequestPreflight.cs b/src/SharpClaw.Code.Providers/Services/ProviderRequestPreflight.cs index e695835..45f2423 100644 --- a/src/SharpClaw.Code.Providers/Services/ProviderRequestPreflight.cs +++ b/src/SharpClaw.Code.Providers/Services/ProviderRequestPreflight.cs @@ -28,6 +28,11 @@ public ProviderRequest Prepare(ProviderRequest request) providerName = string.IsNullOrWhiteSpace(providerName) ? alias.ProviderName : providerName; model = alias.ModelId; } + else if (TryResolveLocalRuntimeQualifiedModel(model, out var runtimeProfile, out var runtimeModel)) + { + providerName = openAiCompatibleOptions.ProviderName; + model = runtimeModel; + } else if (TryParseQualifiedModel(model, out var parsedProviderName, out var parsedModel)) { providerName = string.IsNullOrWhiteSpace(providerName) ? parsedProviderName : providerName; @@ -49,10 +54,19 @@ public ProviderRequest Prepare(ProviderRequest request) $"No default model configured for provider '{providerName}'. Specify a model explicitly."); } + var metadata = request.Metadata is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(request.Metadata, StringComparer.Ordinal); + if (TryResolveLocalRuntimeQualifiedModel(request.Model?.Trim() ?? string.Empty, out var selectedRuntimeProfile, out _)) + { + metadata[OpenAiCompatibleProvider.RuntimeProfileMetadataKey] = selectedRuntimeProfile; + } + return request with { ProviderName = providerName, Model = model, + Metadata = metadata, }; } @@ -87,4 +101,23 @@ private static bool TryParseQualifiedModel(string model, out string providerName providerModel = model[(separatorIndex + 1)..]; return true; } + + private bool TryResolveLocalRuntimeQualifiedModel(string model, out string runtimeProfile, out string runtimeModel) + { + runtimeProfile = string.Empty; + runtimeModel = string.Empty; + if (!TryParseQualifiedModel(model, out var providerSegment, out var providerModel)) + { + return false; + } + + if (!openAiCompatibleOptions.LocalRuntimes.ContainsKey(providerSegment)) + { + return false; + } + + runtimeProfile = providerSegment; + runtimeModel = providerModel; + return true; + } } diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index d67001c..5bddf1e 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -138,6 +138,7 @@ private static void AddOperationalDiagnostics(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new McpRegistryHealthCheck( sp.GetRequiredService(), sp.GetService())); diff --git a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs index 3150425..6829a29 100644 --- a/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs +++ b/src/SharpClaw.Code.Runtime/Context/PromptContextAssembler.cs @@ -16,12 +16,14 @@ namespace SharpClaw.Code.Runtime.Context; /// public sealed class PromptContextAssembler( IProjectMemoryService projectMemoryService, + IMemoryRecallService memoryRecallService, ISessionSummaryService sessionSummaryService, ISkillRegistry skillRegistry, IGitWorkspaceService gitWorkspaceService, IPromptReferenceResolver promptReferenceResolver, ISpecWorkflowService specWorkflowService, IWorkspaceDiagnosticsService workspaceDiagnosticsService, + IWorkspaceIndexService workspaceIndexService, ITodoService todoService, IEventStore eventStore) : IPromptContextAssembler { @@ -44,15 +46,17 @@ public async Task AssembleAsync( var skillsTask = skillRegistry.ListAsync(workspaceRoot, cancellationToken); var gitTask = gitWorkspaceService.GetSnapshotAsync(workspaceRoot, cancellationToken); var diagnosticsTask = workspaceDiagnosticsService.BuildSnapshotAsync(workspaceRoot, cancellationToken); + var indexStatusTask = workspaceIndexService.GetStatusAsync(workspaceRoot, cancellationToken); var todoTask = todoService.GetSnapshotAsync(workspaceRoot, session.Id, cancellationToken); - await Task.WhenAll(memoryContextTask, sessionSummaryTask, skillsTask, gitTask, diagnosticsTask, todoTask).ConfigureAwait(false); + await Task.WhenAll(memoryContextTask, sessionSummaryTask, skillsTask, gitTask, diagnosticsTask, indexStatusTask, todoTask).ConfigureAwait(false); var memoryContext = await memoryContextTask.ConfigureAwait(false); var sessionSummary = await sessionSummaryTask.ConfigureAwait(false); var skills = await skillsTask.ConfigureAwait(false); var gitSnapshot = await gitTask.ConfigureAwait(false); var diagnostics = await diagnosticsTask.ConfigureAwait(false); + var indexStatus = await indexStatusTask.ConfigureAwait(false); var todoSnapshot = await todoTask.ConfigureAwait(false); var metadata = request.Metadata is null @@ -78,6 +82,13 @@ public async Task AssembleAsync( metadata["gitBranch"] = gitSnapshot.CurrentBranch ?? string.Empty; } + if (indexStatus.RefreshedAtUtc is { } refreshedAtUtc) + { + metadata["workspaceIndexRefreshedAtUtc"] = refreshedAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture); + metadata["workspaceIndexChunkCount"] = indexStatus.ChunkCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + metadata["workspaceIndexSymbolCount"] = indexStatus.SymbolCount.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + var workDir = string.IsNullOrWhiteSpace(request.WorkingDirectory) ? session.WorkingDirectory ?? workspaceRoot : request.WorkingDirectory; @@ -98,6 +109,9 @@ public async Task AssembleAsync( metadata[SharpClawWorkflowMetadataKeys.PromptReferencesJson] = JsonSerializer.Serialize( refResolution.References, ProtocolJsonContext.Default.ListPromptReference); + var recalledMemory = await memoryRecallService + .RecallAsync(workspaceRoot, refResolution.ExpandedPrompt, limit: 5, cancellationToken) + .ConfigureAwait(false); var sections = new List(); var memorySection = memoryContext.RenderForPrompt(); @@ -106,6 +120,15 @@ public async Task AssembleAsync( sections.Add(memorySection); } + if (recalledMemory.Count > 0) + { + sections.Add( + "Relevant memory:\n" + + string.Join( + Environment.NewLine, + recalledMemory.Select(static entry => $"- [{entry.Scope}] {entry.Content.Trim()}"))); + } + if (!string.IsNullOrWhiteSpace(sessionSummary)) { sections.Add($"Session summary:\n{sessionSummary}"); @@ -151,6 +174,14 @@ public async Task AssembleAsync( + string.Join(Environment.NewLine, topDiagnostics)); } + if (indexStatus.RefreshedAtUtc is { } refreshedAt) + { + sections.Add( + "Workspace knowledge:\n" + + $"Refreshed: {refreshedAt:O}\n" + + $"Indexed files: {indexStatus.IndexedFileCount}, chunks: {indexStatus.ChunkCount}, symbols: {indexStatus.SymbolCount}"); + } + if (effectivePrimary == Protocol.Enums.PrimaryMode.Spec) { sections.Add(specWorkflowService.BuildPromptInstructions()); diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/Checks/LocalRuntimeCatalogCheck.cs b/src/SharpClaw.Code.Runtime/Diagnostics/Checks/LocalRuntimeCatalogCheck.cs new file mode 100644 index 0000000..1b17072 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Diagnostics/Checks/LocalRuntimeCatalogCheck.cs @@ -0,0 +1,48 @@ +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Protocol.Operational; + +namespace SharpClaw.Code.Runtime.Diagnostics.Checks; + +/// +/// Reports local runtime profile health, discovered model counts, and embedding availability. +/// +public sealed class LocalRuntimeCatalogCheck(IProviderCatalogService providerCatalogService) : IOperationalCheck +{ + /// + public string Id => "provider.local-runtimes"; + + /// + public async Task ExecuteAsync(OperationalDiagnosticsContext context, CancellationToken cancellationToken) + { + _ = context; + + var profiles = (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)) + .SelectMany(static entry => entry.LocalRuntimeProfiles ?? []) + .ToArray(); + + if (profiles.Length == 0) + { + return new OperationalCheckItem( + Id, + OperationalCheckStatus.Ok, + "No local runtime profiles are configured.", + null); + } + + var status = profiles.All(static profile => profile.IsHealthy) + ? OperationalCheckStatus.Ok + : OperationalCheckStatus.Warn; + var detail = string.Join( + "; ", + profiles.Select(profile => + $"{profile.Name} ({profile.Kind}): {(profile.IsHealthy ? "healthy" : "unhealthy")}, " + + $"{profile.AvailableModels.Length} model(s), " + + $"embedding default {(string.IsNullOrWhiteSpace(profile.DefaultEmbeddingModel) ? "not configured" : profile.DefaultEmbeddingModel)}")); + + return new OperationalCheckItem( + Id, + status, + "Local runtime profile probe complete.", + detail); + } +} diff --git a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs index 55e1df7..422b893 100644 --- a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs +++ b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs @@ -68,6 +68,9 @@ await EnsureOutsideWorkspaceAllowedAsync( request.PermissionMode, primaryMode, isInteractive, + request.Metadata is not null + && request.Metadata.TryGetValue("acp", out var acp) + && string.Equals(acp, "true", StringComparison.OrdinalIgnoreCase), resolvedFull, cancellationToken).ConfigureAwait(false); } @@ -110,6 +113,7 @@ private async Task EnsureOutsideWorkspaceAllowedAsync( PermissionMode permissionMode, PrimaryMode primaryMode, bool isInteractive, + bool isAcp, string absolutePath, CancellationToken cancellationToken) { @@ -136,7 +140,7 @@ private async Task EnsureOutsideWorkspaceAllowedAsync( AllowDangerousBypass: false, IsInteractive: isInteractive, SourceKind: PermissionRequestSourceKind.Runtime, - SourceName: null, + SourceName: isAcp ? "acp" : null, TrustedPluginNames: null, TrustedMcpServerNames: null, PrimaryMode: primaryMode); diff --git a/src/SharpClaw.Code.Runtime/Workflow/ConversationCompactionService.cs b/src/SharpClaw.Code.Runtime/Workflow/ConversationCompactionService.cs index fca7f6f..16245ae 100644 --- a/src/SharpClaw.Code.Runtime/Workflow/ConversationCompactionService.cs +++ b/src/SharpClaw.Code.Runtime/Workflow/ConversationCompactionService.cs @@ -1,5 +1,6 @@ using System.Text; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Runtime.Abstractions; @@ -14,6 +15,7 @@ public sealed class ConversationCompactionService( ISessionStore sessionStore, IEventStore eventStore, ITodoService todoService, + IPersistentMemoryStore persistentMemoryStore, ISystemClock systemClock) : IConversationCompactionService { /// @@ -42,6 +44,22 @@ public sealed class ConversationCompactionService( }; await sessionStore.SaveAsync(workspaceRoot, updated, cancellationToken).ConfigureAwait(false); + await persistentMemoryStore.SaveAsync( + workspaceRoot, + new MemoryEntry( + Id: $"session-summary-{session.Id}", + Scope: MemoryScope.Project, + Content: summary, + Source: "session-compaction", + SourceSessionId: session.Id, + SourceTurnId: null, + Tags: ["compaction", "summary"], + Confidence: 0.85d, + RelatedFilePath: null, + RelatedSymbolName: null, + CreatedAtUtc: systemClock.UtcNow, + UpdatedAtUtc: systemClock.UtcNow), + cancellationToken).ConfigureAwait(false); return (updated, summary); } diff --git a/src/SharpClaw.Code.Tools/BuiltIn/SymbolSearchTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/SymbolSearchTool.cs new file mode 100644 index 0000000..0c3f9ea --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/SymbolSearchTool.cs @@ -0,0 +1,46 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// +/// Searches indexed workspace symbols only. +/// +public sealed class SymbolSearchTool(IWorkspaceKnowledgeStore knowledgeStore) : SharpClawToolBase +{ + /// + /// Gets the stable tool name. + /// + public const string ToolName = "symbol_search"; + + /// + public override ToolDefinition Definition { get; } = new( + Name: ToolName, + Description: "Search indexed workspace symbols by name or container.", + ApprovalScope: ApprovalScope.ToolExecution, + IsDestructive: false, + RequiresApproval: false, + InputTypeName: nameof(SymbolSearchToolArguments), + InputDescription: "JSON object with query and optional limit.", + Tags: ["search", "symbols", "workspace"]); + + /// + public override async Task ExecuteAsync( + ToolExecutionContext context, + ToolExecutionRequest request, + CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request); + var hits = await knowledgeStore + .SearchSymbolsAsync(context.WorkspaceRoot, arguments.Query, Math.Clamp(arguments.Limit.GetValueOrDefault(8), 1, 50), cancellationToken) + .ConfigureAwait(false); + var payload = new WorkspaceSearchResult(arguments.Query, DateTimeOffset.UtcNow, null, hits.ToArray()); + var text = hits.Count == 0 + ? "No matching symbols were found." + : string.Join(Environment.NewLine, hits.Select(hit => $"{hit.SymbolKind}: {hit.Excerpt} ({hit.Path}:{hit.StartLine})")); + + return CreateSuccessResult(context, request, text, payload); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/WorkspaceSearchTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/WorkspaceSearchTool.cs new file mode 100644 index 0000000..466b0f0 --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/WorkspaceSearchTool.cs @@ -0,0 +1,50 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// +/// Searches the persisted workspace knowledge index. +/// +public sealed class WorkspaceSearchTool(IWorkspaceSearchService workspaceSearchService) : SharpClawToolBase +{ + /// + /// Gets the stable tool name. + /// + public const string ToolName = "workspace_search"; + + /// + public override ToolDefinition Definition { get; } = new( + Name: ToolName, + Description: "Search indexed workspace content, symbols, and semantic matches.", + ApprovalScope: ApprovalScope.ToolExecution, + IsDestructive: false, + RequiresApproval: false, + InputTypeName: nameof(WorkspaceSearchToolArguments), + InputDescription: "JSON object with query, limit, and optional symbol/semantic flags.", + Tags: ["search", "workspace", "semantic", "symbols"]); + + /// + public override async Task ExecuteAsync( + ToolExecutionContext context, + ToolExecutionRequest request, + CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request); + var result = await workspaceSearchService + .SearchAsync( + context.WorkspaceRoot, + new WorkspaceSearchRequest(arguments.Query, arguments.Limit, arguments.IncludeSymbols, arguments.IncludeSemantic), + cancellationToken) + .ConfigureAwait(false); + var text = result.Hits.Length == 0 + ? "No workspace matches were found." + : string.Join( + Environment.NewLine, + result.Hits.Select(hit => $"{hit.Kind}: {hit.Path}" + (hit.StartLine is null ? string.Empty : $":{hit.StartLine}") + $" ({hit.Score:F2})")); + + return CreateSuccessResult(context, request, text, result); + } +} diff --git a/src/SharpClaw.Code.Tools/Models/ToolContracts.cs b/src/SharpClaw.Code.Tools/Models/ToolContracts.cs index f5bf34b..aab145d 100644 --- a/src/SharpClaw.Code.Tools/Models/ToolContracts.cs +++ b/src/SharpClaw.Code.Tools/Models/ToolContracts.cs @@ -114,6 +114,22 @@ public sealed record ToolSearchToolArguments(string? Query, int? Limit); /// The matching tool definitions. public sealed record ToolSearchToolResult(ToolDefinition[] Tools); +/// +/// Arguments for hybrid workspace search. +/// +/// Search query. +/// Maximum number of hits. +/// Whether symbol hits should be included. +/// Whether semantic hits should be included. +public sealed record WorkspaceSearchToolArguments(string Query, int? Limit, bool IncludeSymbols = true, bool IncludeSemantic = true); + +/// +/// Arguments for symbol-only workspace search. +/// +/// Symbol query. +/// Maximum number of hits. +public sealed record SymbolSearchToolArguments(string Query, int? Limit); + /// /// Arguments for performing a structured web search. /// diff --git a/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj b/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj index 3aa88eb..9fabf57 100644 --- a/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj +++ b/src/SharpClaw.Code.Tools/SharpClaw.Code.Tools.csproj @@ -4,6 +4,7 @@ + diff --git a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs index b3e00ac..261d250 100644 --- a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using SharpClaw.Code.Infrastructure; using SharpClaw.Code.Permissions; using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Memory; using SharpClaw.Code.Plugins; using SharpClaw.Code.Plugins.Abstractions; using SharpClaw.Code.Tools.Abstractions; @@ -31,6 +32,7 @@ public static IServiceCollection AddSharpClawTools(this IServiceCollection servi ArgumentNullException.ThrowIfNull(configuration); services.AddSharpClawTelemetry(configuration); services.AddSharpClawInfrastructure(); + services.AddSharpClawMemory(); services.AddSharpClawPermissions(); services.AddSharpClawPlugins(); services.AddSharpClawWeb(configuration); @@ -46,6 +48,7 @@ public static IServiceCollection AddSharpClawTools(this IServiceCollection servi { services.AddSharpClawTelemetry(); services.AddSharpClawInfrastructure(); + services.AddSharpClawMemory(); services.AddSharpClawPermissions(); services.AddSharpClawPlugins(); services.AddSharpClawWeb(); @@ -62,6 +65,8 @@ private static IServiceCollection AddSharpClawToolsCore(IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(serviceProvider => new ToolSearchTool(() => serviceProvider.GetRequiredService())); @@ -73,6 +78,8 @@ private static IServiceCollection AddSharpClawToolsCore(IServiceCollection servi services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(serviceProvider => new ToolRegistry( diff --git a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs index c0b9525..39ddbbc 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs @@ -41,6 +41,7 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "commands", "connect", "mcp", + "memory", "models", "plugins", "prompt", @@ -49,6 +50,7 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "share", "status", "doctor", + "index", "unshare", "version", "repl" diff --git a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs index 6a780ea..3cb443a 100644 --- a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs @@ -1,11 +1,15 @@ using System.Text; +using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using SharpClaw.Code.Acp; using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Sessions.Abstractions; @@ -52,17 +56,189 @@ public async Task RunAsync_should_return_method_not_found_for_unknown_method() output.ToString().Should().Contain(@"""code"":-32601"); } - private static AcpStdioHost CreateHost() + [Fact] + public async Task RunAsync_should_flow_model_and_editor_context_into_prompt_requests() + { + var runtime = new StubConversationRuntime(); + var editorBuffer = new StubEditorContextBuffer(); + var host = CreateHost(runtime, editorBuffer); + using var initInput = new StringReader("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientCapabilities":{"approvalRequests":true}}}"""); + using var initOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(initInput, initOutput, CancellationToken.None); + + using var promptInput = new StringReader("""{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"cwd":"/tmp/workspace","sessionId":"session-1","model":"ollama/qwen2.5-coder","prompt":"Summarize","editorContext":{"workspaceRoot":"/tmp/workspace","currentFilePath":"/tmp/workspace/src/App.cs","selection":{"start":0,"end":9,"text":"Summarize"}}}}"""); + using var promptOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(promptInput, promptOutput, CancellationToken.None); + + runtime.LastRequest.Should().NotBeNull(); + runtime.LastRequest!.Metadata.Should().ContainKey("model"); + runtime.LastRequest.Metadata!["model"].Should().Be("ollama/qwen2.5-coder"); + runtime.LastRequest.IsInteractive.Should().BeTrue(); + editorBuffer.LastPublished.Should().NotBeNull(); + editorBuffer.LastPublished!.CurrentFilePath.Should().Be("/tmp/workspace/src/App.cs"); + } + + [Fact] + public async Task RunAsync_should_return_provider_catalog_for_models_list() + { + var catalog = new StubProviderCatalogService + { + Entries = + [ + new ProviderModelCatalogEntry( + ProviderName: "openai-compatible", + DefaultModel: "gpt-4.1-mini", + Aliases: ["default"], + AuthStatus: new AuthStatus(null, false, "openai-compatible", null, null, []), + SupportsToolCalls: true, + SupportsEmbeddings: true, + AvailableModels: [], + LocalRuntimeProfiles: + [ + new LocalRuntimeProfileSummary( + Name: "ollama", + Kind: LocalRuntimeKind.Ollama, + BaseUrl: "http://127.0.0.1:11434/v1/", + DefaultChatModel: "qwen2.5-coder", + DefaultEmbeddingModel: "nomic-embed-text", + AuthMode: ProviderAuthMode.Optional, + IsHealthy: true, + HealthDetail: "1 model(s) discovered.", + AvailableModels: + [ + new ProviderDiscoveredModel("qwen2.5-coder", "qwen2.5-coder", true, false) + ]) + ]) + ] + }; + var host = CreateHost(providerCatalogService: catalog); + using var input = new StringReader("""{"jsonrpc":"2.0","id":"models","method":"models/list","params":{}}"""); + using var output = new StringWriter(new StringBuilder()); + + await host.RunAsync(input, output, CancellationToken.None); + + var payload = JsonSerializer.Deserialize( + ReadResponseResult(output, "models").ToJsonString(), + ProtocolJsonContext.Default.ListProviderModelCatalogEntry); + payload.Should().NotBeNull(); + payload![0].ProviderName.Should().Be("openai-compatible"); + payload[0].LocalRuntimeProfiles.Should().ContainSingle(profile => profile.Name == "ollama" && profile.IsHealthy); + } + + [Fact] + public async Task RunAsync_should_dispatch_workspace_index_and_search_requests() + { + var indexService = new StubWorkspaceIndexService(); + var searchService = new StubWorkspaceSearchService(); + var host = CreateHost(workspaceIndexService: indexService, workspaceSearchService: searchService); + + using var refreshInput = new StringReader("""{"jsonrpc":"2.0","id":"refresh","method":"workspace/index/refresh","params":{"cwd":"/tmp/workspace"}}"""); + using var refreshOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(refreshInput, refreshOutput, CancellationToken.None); + + using var searchInput = new StringReader("""{"jsonrpc":"2.0","id":"search","method":"workspace/search","params":{"cwd":"/tmp/workspace","query":"WidgetService","limit":5,"includeSymbols":true,"includeSemantic":false}}"""); + using var searchOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(searchInput, searchOutput, CancellationToken.None); + + indexService.LastWorkspaceRoot.Should().Be("/tmp/workspace"); + searchService.LastWorkspaceRoot.Should().Be("/tmp/workspace"); + searchService.LastRequest.Should().Be(new WorkspaceSearchRequest("WidgetService", 5, true, false)); + + var refresh = JsonSerializer.Deserialize( + ReadResponseResult(refreshOutput, "refresh").ToJsonString(), + ProtocolJsonContext.Default.WorkspaceIndexRefreshResult); + var search = JsonSerializer.Deserialize( + ReadResponseResult(searchOutput, "search").ToJsonString(), + ProtocolJsonContext.Default.WorkspaceSearchResult); + + refresh.Should().NotBeNull(); + refresh!.IndexedFileCount.Should().Be(3); + search.Should().NotBeNull(); + search!.Hits.Should().ContainSingle(hit => hit.SymbolName == "WidgetService"); + } + + [Fact] + public async Task RunAsync_should_round_trip_memory_save_list_and_delete_requests() + { + var memoryStore = new StubPersistentMemoryStore(); + var host = CreateHost(persistentMemoryStore: memoryStore); + + using var saveInput = new StringReader( + """{"jsonrpc":"2.0","id":"save","method":"memory/save","params":{"cwd":"/tmp/workspace","sessionId":"session-1","request":{"scope":"Project","content":"Keep prompts concise.","source":"manual","tags":["style"],"confidence":0.8,"relatedFilePath":"src/App.cs","relatedSymbolName":"App"}}}"""); + using var saveOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(saveInput, saveOutput, CancellationToken.None); + + var saved = JsonSerializer.Deserialize( + ReadResponseResult(saveOutput, "save").ToJsonString(), + ProtocolJsonContext.Default.MemoryEntry); + saved.Should().NotBeNull(); + saved!.Scope.Should().Be(MemoryScope.Project); + saved.SourceSessionId.Should().Be("session-1"); + memoryStore.LastSaveWorkspaceRoot.Should().Be("/tmp/workspace"); + + using var listInput = new StringReader("""{"jsonrpc":"2.0","id":"list","method":"memory/list","params":{"cwd":"/tmp/workspace","scope":"Project","query":"concise","limit":10}}"""); + using var listOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(listInput, listOutput, CancellationToken.None); + + var rows = JsonSerializer.Deserialize( + ReadResponseResult(listOutput, "list").ToJsonString(), + ProtocolJsonContext.Default.ListMemoryEntry); + rows.Should().NotBeNull(); + rows.Should().ContainSingle(entry => entry.Id == saved.Id); + + using var deleteInput = new StringReader( + $@"{{""jsonrpc"":""2.0"",""id"":""delete"",""method"":""memory/delete"",""params"":{{""cwd"":""/tmp/workspace"",""scope"":""Project"",""id"":""{saved.Id}""}}}}"); + using var deleteOutput = new StringWriter(new StringBuilder()); + await host.RunAsync(deleteInput, deleteOutput, CancellationToken.None); + + memoryStore.LastDeleteWorkspaceRoot.Should().Be("/tmp/workspace"); + memoryStore.LastDeleteScope.Should().Be(MemoryScope.Project); + ReadResponseResult(deleteOutput, "delete")["deleted"]!.GetValue().Should().BeTrue(); + } + + private static AcpStdioHost CreateHost( + StubConversationRuntime? runtime = null, + StubEditorContextBuffer? editorBuffer = null, + StubWorkspaceIndexService? workspaceIndexService = null, + StubWorkspaceSearchService? workspaceSearchService = null, + StubPersistentMemoryStore? persistentMemoryStore = null, + StubProviderCatalogService? providerCatalogService = null) => new( - new StubConversationRuntime(), + runtime ?? new StubConversationRuntime(), new StubAttachmentStore(), + editorBuffer ?? new StubEditorContextBuffer(), + workspaceIndexService ?? new StubWorkspaceIndexService(), + workspaceSearchService ?? new StubWorkspaceSearchService(), + persistentMemoryStore ?? new StubPersistentMemoryStore(), + providerCatalogService ?? new StubProviderCatalogService(), + new AcpApprovalCoordinator(), new PathService(), NullLogger.Instance); + private static System.Text.Json.Nodes.JsonNode ReadResponseResult(StringWriter output, string id) + { + foreach (var line in output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)) + { + if (System.Text.Json.Nodes.JsonNode.Parse(line) is not System.Text.Json.Nodes.JsonObject payload) + { + continue; + } + + if (payload["id"]?.GetValue() == id) + { + return payload["result"]!; + } + } + + throw new InvalidOperationException($"Could not find JSON-RPC response with id '{id}'."); + } + private sealed class StubConversationRuntime : IConversationRuntime { + public RunPromptRequest? LastRequest { get; private set; } + public Task CreateSessionAsync(string workspacePath, PermissionMode permissionMode, OutputFormat outputFormat, CancellationToken cancellationToken) - => throw new NotSupportedException(); + => Task.FromResult(new ConversationSession("session-1", "Test", SessionLifecycleState.Active, permissionMode, outputFormat, workspacePath, workspacePath, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null)); public Task ExecuteAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); @@ -73,10 +249,20 @@ public Task ForkSessionAsync(string workspacePath, string? => throw new NotSupportedException(); public Task GetSessionAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) - => throw new NotSupportedException(); + => Task.FromResult(new ConversationSession(sessionId, "Loaded", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Json, workspacePath, workspacePath, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null)); public Task RunPromptAsync(RunPromptRequest request, CancellationToken cancellationToken) - => throw new NotSupportedException(); + { + LastRequest = request; + return Task.FromResult(new TurnExecutionResult( + new ConversationSession("session-1", "Prompt", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Json, request.WorkingDirectory ?? "/tmp/workspace", request.WorkingDirectory ?? "/tmp/workspace", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, null), + new ConversationTurn("turn-1", "session-1", 1, request.Prompt, "ok", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "primary-coding-agent", null, null, null), + "ok", + [], + null, + null, + [])); + } } private sealed class StubAttachmentStore : IWorkspaceSessionAttachmentStore @@ -87,4 +273,100 @@ private sealed class StubAttachmentStore : IWorkspaceSessionAttachmentStore public Task SetAttachedSessionIdAsync(string workspacePath, string? sessionId, CancellationToken cancellationToken) => Task.CompletedTask; } + + private sealed class StubEditorContextBuffer : IEditorContextBuffer + { + public EditorContextPayload? LastPublished { get; private set; } + + public EditorContextPayload? Peek(string normalizedWorkspaceRoot) => null; + + public void Publish(EditorContextPayload payload) + { + LastPublished = payload; + } + + public EditorContextPayload? TryConsume(string normalizedWorkspaceRoot) => null; + } + + private sealed class StubWorkspaceIndexService : IWorkspaceIndexService + { + public string? LastWorkspaceRoot { get; private set; } + + public Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceIndexStatus(workspaceRoot, null, 0, 0, 0, 0)); + + public Task RefreshAsync(string workspaceRoot, CancellationToken cancellationToken) + { + LastWorkspaceRoot = workspaceRoot; + return Task.FromResult(new WorkspaceIndexRefreshResult(workspaceRoot, DateTimeOffset.UtcNow, 3, 6, 2, 1, [])); + } + } + + private sealed class StubWorkspaceSearchService : IWorkspaceSearchService + { + public string? LastWorkspaceRoot { get; private set; } + + public WorkspaceSearchRequest? LastRequest { get; private set; } + + public Task SearchAsync(string workspaceRoot, WorkspaceSearchRequest request, CancellationToken cancellationToken) + { + LastWorkspaceRoot = workspaceRoot; + LastRequest = request; + return Task.FromResult(new WorkspaceSearchResult( + request.Query, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + [new WorkspaceSearchHit("src/WidgetService.cs", WorkspaceSearchHitKind.Symbol, 1.0d, "WidgetService", "WidgetService", "class", 3, 3)])); + } + } + + private sealed class StubPersistentMemoryStore : IPersistentMemoryStore + { + private readonly List entries = []; + + public string? LastSaveWorkspaceRoot { get; private set; } + + public string? LastDeleteWorkspaceRoot { get; private set; } + + public MemoryScope? LastDeleteScope { get; private set; } + + public Task DeleteAsync(string? workspaceRoot, MemoryScope scope, string id, CancellationToken cancellationToken) + { + LastDeleteWorkspaceRoot = workspaceRoot; + LastDeleteScope = scope; + return Task.FromResult(entries.RemoveAll(entry => entry.Id == id) > 0); + } + + public Task> ListAsync(string? workspaceRoot, MemoryScope? scope, string? query, int limit, CancellationToken cancellationToken) + { + IEnumerable result = entries; + if (scope is not null) + { + result = result.Where(entry => entry.Scope == scope.Value); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + result = result.Where(entry => entry.Content.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult>(result.Take(limit).ToArray()); + } + + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + LastSaveWorkspaceRoot = workspaceRoot; + entries.RemoveAll(existing => existing.Id == entry.Id); + entries.Add(entry); + return Task.FromResult(entry); + } + } + + private sealed class StubProviderCatalogService : IProviderCatalogService + { + public IReadOnlyList Entries { get; init; } = []; + + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult(Entries); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs index a214152..595cba9 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs @@ -2,10 +2,12 @@ using FluentAssertions; using SharpClaw.Code.Commands; using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; namespace SharpClaw.Code.UnitTests.Commands; @@ -42,6 +44,60 @@ public async Task Hooks_command_should_execute_named_test_from_slash_command() renderer.LastResult!.Succeeded.Should().BeTrue(); } + [Fact] + public async Task Models_command_should_render_provider_catalog_payload() + { + var renderer = new RecordingRenderer(); + var handler = new ModelsCommandHandler(new StubProviderCatalogService(), new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "models", []), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.ListProviderModelCatalogEntry); + payload.Should().ContainSingle(entry => entry.ProviderName == "openai-compatible" && entry.LocalRuntimeProfiles!.Length == 1); + } + + [Fact] + public async Task Index_command_should_render_workspace_search_payload() + { + var renderer = new RecordingRenderer(); + var handler = new IndexCommandHandler(new StubWorkspaceIndexService(), new StubWorkspaceSearchService(), new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "index", ["query", "WidgetService"]), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.WorkspaceSearchResult); + payload!.Hits.Should().ContainSingle(hit => hit.SymbolName == "WidgetService"); + } + + [Fact] + public async Task Memory_command_should_save_and_list_entries() + { + var renderer = new RecordingRenderer(); + var store = new StubPersistentMemoryStore(); + var handler = new MemoryCommandHandler(store, new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build, "session-1"); + + var saveExitCode = await handler.ExecuteAsync( + new SlashCommandParseResult(true, "memory", ["save", "User", "Prefer concise summaries"]), + context, + CancellationToken.None); + + saveExitCode.Should().Be(0); + store.Entries.Should().ContainSingle(entry => entry.Scope == MemoryScope.User); + + var listExitCode = await handler.ExecuteAsync( + new SlashCommandParseResult(true, "memory", ["list", "concise"]), + context, + CancellationToken.None); + + listExitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.ListMemoryEntry); + payload.Should().ContainSingle(entry => entry.Content.Contains("concise", StringComparison.OrdinalIgnoreCase)); + } + private sealed class RecordingRenderer : IOutputRenderer { public OutputFormat Format => OutputFormat.Json; @@ -93,4 +149,86 @@ public Task TestAsync(string workspaceRoot, string hookName, str return Task.FromResult(new HookTestResult(hookName, HookTriggerKind.TurnCompleted, true, "Hook executed successfully.", DateTimeOffset.UtcNow)); } } + + private sealed class StubProviderCatalogService : IProviderCatalogService + { + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult>( + [ + new ProviderModelCatalogEntry( + "openai-compatible", + "gpt-4.1-mini", + ["default"], + new AuthStatus(null, false, "openai-compatible", null, null, []), + SupportsToolCalls: true, + SupportsEmbeddings: true, + AvailableModels: + [ + new ProviderDiscoveredModel("gpt-4.1-mini", "gpt-4.1-mini", true, true) + ], + LocalRuntimeProfiles: + [ + new LocalRuntimeProfileSummary( + "ollama", + LocalRuntimeKind.Ollama, + "http://127.0.0.1:11434/v1/", + "qwen2.5-coder", + "nomic-embed-text", + ProviderAuthMode.Optional, + true, + "healthy", + []) + ]) + ]); + } + + private sealed class StubWorkspaceIndexService : IWorkspaceIndexService + { + public Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceIndexStatus(workspaceRoot, DateTimeOffset.UtcNow, 4, 8, 2, 1)); + + public Task RefreshAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceIndexRefreshResult(workspaceRoot, DateTimeOffset.UtcNow, 4, 8, 2, 1, [])); + } + + private sealed class StubWorkspaceSearchService : IWorkspaceSearchService + { + public Task SearchAsync(string workspaceRoot, WorkspaceSearchRequest request, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceSearchResult( + request.Query, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow, + [new WorkspaceSearchHit("src/WidgetService.cs", WorkspaceSearchHitKind.Symbol, 1d, "WidgetService", "WidgetService", "class", 3, 3)])); + } + + private sealed class StubPersistentMemoryStore : IPersistentMemoryStore + { + public List Entries { get; } = []; + + public Task DeleteAsync(string? workspaceRoot, MemoryScope scope, string id, CancellationToken cancellationToken) + => Task.FromResult(Entries.RemoveAll(entry => entry.Id == id && entry.Scope == scope) > 0); + + public Task> ListAsync(string? workspaceRoot, MemoryScope? scope, string? query, int limit, CancellationToken cancellationToken) + { + IEnumerable entries = Entries; + if (scope is not null) + { + entries = entries.Where(entry => entry.Scope == scope.Value); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + entries = entries.Where(entry => entry.Content.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult>(entries.Take(limit).ToArray()); + } + + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + Entries.RemoveAll(existing => existing.Id == entry.Id); + Entries.Add(entry); + return Task.FromResult(entry); + } + } } diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs new file mode 100644 index 0000000..1827ced --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs @@ -0,0 +1,128 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Services; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.UnitTests.MemorySkillsGit; + +/// +/// Covers workspace indexing, hybrid search, and durable memory recall. +/// +public sealed class WorkspaceKnowledgeServicesTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), $"sharpclaw-knowledge-{Guid.NewGuid():N}"); + private readonly string userRoot = Path.Combine(Path.GetTempPath(), $"sharpclaw-user-{Guid.NewGuid():N}"); + private readonly LocalFileSystem fileSystem = new(); + private readonly PathService pathService = new(); + + [Fact] + public async Task Index_search_and_memory_services_should_round_trip_expected_data() + { + Directory.CreateDirectory(Path.Combine(workspaceRoot, "src")); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "src", "WidgetService.cs"), + """ + namespace Sample.App; + + public sealed class WidgetService + { + public string BuildWidgetPrompt(string name) => $"Widget prompt for {name}"; + } + """); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "README.md"), + "The widget prompt pipeline provides semantic workspace context."); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "src", "Sample.App.csproj"), + """ + + + + + + """); + + var store = CreateStore(); + var indexService = new WorkspaceIndexService(fileSystem, pathService, store); + var searchService = new WorkspaceSearchService(store); + var memoryStore = new PersistentMemoryStore(store); + var recallService = new MemoryRecallService(memoryStore); + + var refresh = await indexService.RefreshAsync(workspaceRoot, CancellationToken.None); + var search = await searchService.SearchAsync( + workspaceRoot, + new WorkspaceSearchRequest("WidgetService", 10), + CancellationToken.None); + + refresh.IndexedFileCount.Should().BeGreaterThan(0); + search.Hits.Should().Contain(hit => hit.Kind == WorkspaceSearchHitKind.Symbol && hit.SymbolName == "WidgetService"); + + var now = DateTimeOffset.UtcNow; + await memoryStore.SaveAsync( + workspaceRoot, + new MemoryEntry( + Id: "project-memory-1", + Scope: MemoryScope.Project, + Content: "Widget prompts should stay concise and repo-specific.", + Source: "unit-test", + SourceSessionId: null, + SourceTurnId: null, + Tags: ["widgets"], + Confidence: 0.9d, + RelatedFilePath: "src/WidgetService.cs", + RelatedSymbolName: "WidgetService", + CreatedAtUtc: now, + UpdatedAtUtc: now), + CancellationToken.None); + await memoryStore.SaveAsync( + null, + new MemoryEntry( + Id: "user-memory-1", + Scope: MemoryScope.User, + Content: "Prefer explicit engineering language over vague summaries.", + Source: "unit-test", + SourceSessionId: null, + SourceTurnId: null, + Tags: ["style"], + Confidence: 0.8d, + RelatedFilePath: null, + RelatedSymbolName: null, + CreatedAtUtc: now, + UpdatedAtUtc: now), + CancellationToken.None); + + var recalled = await recallService.RecallAsync(workspaceRoot, "Write a concise widget prompt summary.", 5, CancellationToken.None); + recalled.Should().Contain(entry => entry.Id == "project-memory-1"); + recalled.Should().Contain(entry => entry.Id == "user-memory-1"); + } + + public void Dispose() + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + + if (Directory.Exists(userRoot)) + { + Directory.Delete(userRoot, recursive: true); + } + } + + private IWorkspaceKnowledgeStore CreateStore() + => new SqliteWorkspaceKnowledgeStore(fileSystem, pathService, new TestUserProfilePaths(userRoot, pathService)); + + private sealed class TestUserProfilePaths(string root, IPathService pathService) : IUserProfilePaths + { + public string GetUserCustomCommandsDirectory() + => pathService.Combine(root, "commands"); + + public string GetUserHomeDirectory() + => root; + + public string GetUserSharpClawRoot() + => root; + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs b/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs index 8fac07d..7f9c770 100644 --- a/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Permissions/PermissionPolicyEngineTests.cs @@ -355,7 +355,8 @@ public Task RequestApprovalAsync( ResolvedBy: "test", Reason: "approved", ResolvedAtUtc: DateTimeOffset.UtcNow, - ExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10))); + ExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10), + RememberForSession: request.CanRememberDecision)); } } } diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs new file mode 100644 index 0000000..14c9402 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Providers/ProviderCatalogServiceTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Providers; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Providers.Configuration; +using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.UnitTests.Providers; + +/// +/// Covers provider catalog discovery for local runtime profiles. +/// +public sealed class ProviderCatalogServiceTests +{ + [Fact] + public async Task ListAsync_should_surface_local_runtime_profiles_and_discovered_models() + { + await using var server = await LocalJsonServer.StartAsync(""" + {"data":[{"id":"qwen2.5-coder"},{"id":"nomic-embed-text"}]} + """); + + var openAiOptions = new OpenAiCompatibleProviderOptions + { + ProviderName = "openai-compatible", + DefaultModel = "gpt-4.1-mini", + DefaultEmbeddingModel = "text-embedding-3-small", + SupportsEmbeddings = true, + }; + openAiOptions.LocalRuntimes["ollama"] = new LocalRuntimeProfileOptions + { + Kind = LocalRuntimeKind.Ollama, + BaseUrl = $"{server.BaseUrl}v1/", + DefaultChatModel = "qwen2.5-coder", + DefaultEmbeddingModel = "nomic-embed-text", + AuthMode = ProviderAuthMode.Optional, + SupportsToolCalls = true, + SupportsEmbeddings = true, + }; + + var service = new ProviderCatalogService( + [new StubModelProvider("openai-compatible")], + new StubAuthFlowService(), + Options.Create(new ProviderCatalogOptions()), + Options.Create(new AnthropicProviderOptions()), + Options.Create(openAiOptions)); + + var entries = await service.ListAsync(CancellationToken.None); + + entries.Should().ContainSingle(); + var entry = entries[0]; + entry.ProviderName.Should().Be("openai-compatible"); + entry.SupportsEmbeddings.Should().BeTrue(); + entry.AvailableModels.Should().Contain(model => model.Id == "qwen2.5-coder" && model.SupportsTools); + entry.AvailableModels.Should().Contain(model => model.Id == "nomic-embed-text" && model.SupportsEmbeddings); + entry.LocalRuntimeProfiles.Should().ContainSingle(profile => + profile.Name == "ollama" + && profile.AuthMode == ProviderAuthMode.Optional + && profile.IsHealthy + && profile.AvailableModels.Length == 2); + } + + private sealed class StubAuthFlowService : IAuthFlowService + { + public Task GetStatusAsync(string providerName, CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus(null, false, providerName, null, null, [])); + } + + private sealed class StubModelProvider(string providerName) : IModelProvider + { + public string ProviderName => providerName; + + public Task GetAuthStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(new AuthStatus(null, false, providerName, null, null, [])); + + public Task StartStreamAsync(ProviderRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private sealed class LocalJsonServer(TcpListener listener, Task serverTask) : IAsyncDisposable + { + public string BaseUrl => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/"; + + public static Task StartAsync(string jsonPayload) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + var payloadBytes = Encoding.UTF8.GetBytes(jsonPayload); + var serverTask = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + + while (!string.IsNullOrEmpty(await reader.ReadLineAsync())) + { + } + + var headers = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + $"Content-Length: {payloadBytes.Length}\r\n" + + "Connection: close\r\n\r\n"); + await stream.WriteAsync(headers); + await stream.WriteAsync(payloadBytes); + await stream.FlushAsync(); + }); + + return Task.FromResult(new LocalJsonServer(listener, serverTask)); + } + + public async ValueTask DisposeAsync() + { + listener.Stop(); + try + { + await serverTask.ConfigureAwait(false); + } + catch (SocketException) + { + } + catch (ObjectDisposedException) + { + } + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs b/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs index 3b45e32..d158b5a 100644 --- a/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Providers/ProviderConfigurationBindingTests.cs @@ -5,6 +5,7 @@ using SharpClaw.Code.Infrastructure; using SharpClaw.Code.Providers; using SharpClaw.Code.Providers.Configuration; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.UnitTests.Providers; @@ -28,7 +29,17 @@ public void AddSharpClawProviders_should_bind_options_from_configuration() ["SharpClaw:Providers:Anthropic:ApiKey"] = "anthropic-key", ["SharpClaw:Providers:Anthropic:BaseUrl"] = "https://anthropic.example.com", ["SharpClaw:Providers:OpenAiCompatible:ApiKey"] = "openai-key", - ["SharpClaw:Providers:OpenAiCompatible:BaseUrl"] = "https://openai.example.com/v1" + ["SharpClaw:Providers:OpenAiCompatible:BaseUrl"] = "https://openai.example.com/v1", + ["SharpClaw:Providers:OpenAiCompatible:AuthMode"] = "Optional", + ["SharpClaw:Providers:OpenAiCompatible:DefaultEmbeddingModel"] = "text-embedding-3-small", + ["SharpClaw:Providers:OpenAiCompatible:SupportsEmbeddings"] = "true", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:Kind"] = "Ollama", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:BaseUrl"] = "http://127.0.0.1:11434/v1/", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:DefaultChatModel"] = "qwen2.5-coder", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:DefaultEmbeddingModel"] = "nomic-embed-text", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:AuthMode"] = "Optional", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:SupportsToolCalls"] = "true", + ["SharpClaw:Providers:OpenAiCompatible:LocalRuntimes:ollama:SupportsEmbeddings"] = "true" }) .Build(); @@ -47,5 +58,12 @@ public void AddSharpClawProviders_should_bind_options_from_configuration() anthropic.BaseUrl.Should().Be("https://anthropic.example.com"); openAi.ApiKey.Should().Be("openai-key"); openAi.BaseUrl.Should().Be("https://openai.example.com/v1"); + openAi.AuthMode.Should().Be(ProviderAuthMode.Optional); + openAi.DefaultEmbeddingModel.Should().Be("text-embedding-3-small"); + openAi.SupportsEmbeddings.Should().BeTrue(); + openAi.LocalRuntimes.Should().ContainKey("ollama"); + openAi.LocalRuntimes["ollama"].Kind.Should().Be(LocalRuntimeKind.Ollama); + openAi.LocalRuntimes["ollama"].DefaultChatModel.Should().Be("qwen2.5-coder"); + openAi.LocalRuntimes["ollama"].DefaultEmbeddingModel.Should().Be("nomic-embed-text"); } } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs new file mode 100644 index 0000000..21f4b6a --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/LocalRuntimeCatalogCheckTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Operational; +using SharpClaw.Code.Runtime.Diagnostics; +using SharpClaw.Code.Runtime.Diagnostics.Checks; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Covers status/doctor reporting for configured local runtime profiles. +/// +public sealed class LocalRuntimeCatalogCheckTests +{ + [Fact] + public async Task ExecuteAsync_should_report_profile_health_and_model_counts() + { + var check = new LocalRuntimeCatalogCheck(new StubProviderCatalogService()); + + var result = await check.ExecuteAsync( + new OperationalDiagnosticsContext("/workspace", null, PermissionMode.WorkspaceWrite), + CancellationToken.None); + + result.Status.Should().Be(OperationalCheckStatus.Warn); + result.Detail.Should().Contain("ollama (Ollama): unhealthy, 1 model(s)"); + result.Detail.Should().Contain("embedding default nomic-embed-text"); + } + + private sealed class StubProviderCatalogService : IProviderCatalogService + { + public Task> ListAsync(CancellationToken cancellationToken) + => Task.FromResult>( + [ + new ProviderModelCatalogEntry( + "openai-compatible", + "gpt-4.1-mini", + [], + new AuthStatus(null, false, "openai-compatible", null, null, []), + AvailableModels: + [ + new ProviderDiscoveredModel("qwen2.5-coder", "qwen2.5-coder", true, false) + ], + LocalRuntimeProfiles: + [ + new LocalRuntimeProfileSummary( + "ollama", + LocalRuntimeKind.Ollama, + "http://127.0.0.1:11434/v1/", + "qwen2.5-coder", + "nomic-embed-text", + ProviderAuthMode.Optional, + false, + "connection refused", + [ + new ProviderDiscoveredModel("qwen2.5-coder", "qwen2.5-coder", true, false) + ]) + ]) + ]); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs index 058c98b..5a360fe 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Options; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; @@ -116,7 +117,8 @@ await eventStore.AppendAsync( hooks); var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, clock); _ = await todoService.AddAsync(workspaceRoot, TodoScope.Session, "Follow up on diagnostics UX", session.Id, "primary-coding-agent", CancellationToken.None); - var compactionService = new ConversationCompactionService(sessionStore, eventStore, todoService, clock); + var memoryStore = new RecordingPersistentMemoryStore(); + var compactionService = new ConversationCompactionService(sessionStore, eventStore, todoService, memoryStore, clock); var share = await shareService.CreateShareAsync(workspaceRoot, session.Id, CancellationToken.None); var sharedSession = await sessionStore.GetByIdAsync(workspaceRoot, session.Id, CancellationToken.None); @@ -136,6 +138,7 @@ await eventStore.AppendAsync( compacted.Summary.Should().Contain("Recent requests:"); compacted.Summary.Should().Contain("Active tasks:"); compacted.Session.Title.Should().Contain("Add diagnostics support"); + memoryStore.Saved.Should().ContainSingle(entry => entry.Source == "session-compaction" && entry.SourceSessionId == session.Id); removed.Should().BeTrue(); unsharedSession!.Metadata.Should().NotContainKey(SharpClawWorkflowMetadataKeys.ShareId); hooks.Invocations.Should().Contain(invocation => invocation.Trigger == HookTriggerKind.ShareCreated); @@ -189,4 +192,21 @@ public Task> ListAsync(string workspaceRoot, Can public Task TestAsync(string workspaceRoot, string hookName, string payloadJson, CancellationToken cancellationToken) => Task.FromResult(new HookTestResult(hookName, HookTriggerKind.ShareCreated, true, "ok", DateTimeOffset.UtcNow)); } + + private sealed class RecordingPersistentMemoryStore : IPersistentMemoryStore + { + public List Saved { get; } = []; + + public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, CancellationToken cancellationToken) + { + Saved.Add(entry); + return Task.FromResult(entry); + } + + public Task DeleteAsync(string? workspaceRoot, MemoryScope scope, string id, CancellationToken cancellationToken) + => Task.FromResult(false); + + public Task> ListAsync(string? workspaceRoot, MemoryScope? scope, string? query, int limit, CancellationToken cancellationToken) + => Task.FromResult>(Saved); + } } From b692cfbb261d5631a7bcc58e66ace289f2311fb4 Mon Sep 17 00:00:00 2001 From: telli Date: Thu, 16 Apr 2026 13:37:38 -0700 Subject: [PATCH 2/8] feat: add embedded runtime, tenant storage, admin api, and tool packages --- SharpClawCode.sln | 15 ++ .../IRuntimeStoragePathResolver.cs | 67 +++++ ...frastructureServiceCollectionExtensions.cs | 3 + .../Services/RuntimeHostContextAccessor.cs | 33 +++ .../Services/RuntimeStoragePathResolver.cs | 134 ++++++++++ .../Services/SqliteWorkspaceKnowledgeStore.cs | 12 +- .../Services/PluginManager.cs | 4 +- .../IRuntimeHostContextAccessor.cs | 19 ++ .../Commands/RunPromptRequest.cs | 4 +- .../Models/OpenCodeParityModels.cs | 4 +- .../Models/Phase2Models.cs | 122 +++++++++ .../Serialization/ProtocolJsonContext.cs | 11 + .../Abstractions/IRuntimeCommandService.cs | 4 +- .../RuntimeServiceCollectionExtensions.cs | 8 +- .../Export/PortableSessionBundleService.cs | 11 +- .../Orchestration/ConversationRuntime.cs | 36 ++- .../Server/WorkspaceHttpServer.cs | 231 +++++++++++++++++- .../Workflow/ShareSessionService.cs | 9 +- .../Workflow/TodoService.cs | 23 +- .../SharpClaw.Code.Sessions.csproj | 1 + .../Storage/FileCheckpointStore.cs | 6 +- .../Storage/FileMutationSetStore.cs | 8 +- .../Storage/FileSessionStore.cs | 16 +- .../FileWorkspaceSessionAttachmentStore.cs | 8 +- .../Storage/HostAwareEventStore.cs | 29 +++ .../Storage/HostAwareSessionStore.cs | 36 +++ .../Storage/NdjsonEventStore.cs | 6 +- .../Storage/SqliteEventStore.cs | 69 ++++++ .../Storage/SqliteSessionStore.cs | 101 ++++++++ .../Storage/SqliteSessionStoreDatabase.cs | 55 +++++ .../Abstractions/IRuntimeEventSink.cs | 14 ++ .../Abstractions/IRuntimeEventStream.cs | 19 ++ .../RuntimeEventPublishOptions.cs | 4 +- .../Services/InProcessRuntimeEventStream.cs | 71 ++++++ .../Services/RuntimeEventPublisher.cs | 26 +- .../Services/WebhookRuntimeEventSink.cs | 49 ++++ .../TelemetryOptions.cs | 5 + .../TelemetryServiceCollectionExtensions.cs | 17 +- .../Abstractions/IToolPackageService.cs | 22 ++ .../Services/ToolPackageService.cs | 161 ++++++++++++ .../ToolsServiceCollectionExtensions.cs | 2 + src/SharpClaw.Code/SharpClaw.Code.csproj | 21 ++ src/SharpClaw.Code/SharpClawRuntimeHost.cs | 147 +++++++++++ .../SharpClawRuntimeHostBuilder.cs | 83 +++++++ .../Runtime/EmbeddedRuntimeHostTests.cs | 76 ++++++ .../Runtime/PromptContextAssemblyTests.cs | 19 +- .../Runtime/WorkspaceHttpServerAdminTests.cs | 151 ++++++++++++ .../SharpClaw.Code.IntegrationTests.csproj | 3 +- .../McpPlugins/McpAndPluginLifecycleTests.cs | 2 + .../WorkspaceKnowledgeServicesTests.cs | 15 +- .../ShareAndCompactionServicesTests.cs | 9 +- .../WorkspaceInsightsAndTodoServiceTests.cs | 10 +- .../Sessions/SessionStorageTests.cs | 28 ++- .../Support/TestRuntimeStorageResolver.cs | 28 +++ .../Tools/ToolPackageServiceTests.cs | 52 ++++ 55 files changed, 2013 insertions(+), 106 deletions(-) create mode 100644 src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs create mode 100644 src/SharpClaw.Code.Infrastructure/Services/RuntimeHostContextAccessor.cs create mode 100644 src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs create mode 100644 src/SharpClaw.Code.Protocol/Abstractions/IRuntimeHostContextAccessor.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/Phase2Models.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/HostAwareEventStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/HostAwareSessionStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/SqliteEventStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs create mode 100644 src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs create mode 100644 src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventSink.cs create mode 100644 src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventStream.cs create mode 100644 src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs create mode 100644 src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs create mode 100644 src/SharpClaw.Code.Tools/Abstractions/IToolPackageService.cs create mode 100644 src/SharpClaw.Code.Tools/Services/ToolPackageService.cs create mode 100644 src/SharpClaw.Code/SharpClaw.Code.csproj create mode 100644 src/SharpClaw.Code/SharpClawRuntimeHost.cs create mode 100644 src/SharpClaw.Code/SharpClawRuntimeHostBuilder.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Runtime/EmbeddedRuntimeHostTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs diff --git a/SharpClawCode.sln b/SharpClawCode.sln index 48e8b64..1af073c 100644 --- a/SharpClawCode.sln +++ b/SharpClawCode.sln @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Acp", "src\S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Mcp.FixtureServer", "tests\SharpClaw.Code.Mcp.FixtureServer\SharpClaw.Code.Mcp.FixtureServer.csproj", "{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code", "src\SharpClaw.Code\SharpClaw.Code.csproj", "{8552440E-B169-4CD9-9B52-4BFFDDADF053}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -339,6 +341,18 @@ Global {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x64.Build.0 = Release|Any CPU {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x86.ActiveCfg = Release|Any CPU {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x86.Build.0 = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x64.ActiveCfg = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x64.Build.0 = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x86.ActiveCfg = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x86.Build.0 = Debug|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|Any CPU.Build.0 = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.ActiveCfg = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.Build.0 = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.ActiveCfg = Release|Any CPU + {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -367,5 +381,6 @@ Global {5F0ED186-7920-4A49-B0A3-75F84B4215B3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {0060F8FF-0714-4C01-936F-719D7E5F124D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8552440E-B169-4CD9-9B52-4BFFDDADF053} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs b/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs new file mode 100644 index 0000000..6cab425 --- /dev/null +++ b/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs @@ -0,0 +1,67 @@ +namespace SharpClaw.Code.Infrastructure.Abstractions; + +/// +/// Resolves tenant-aware durable storage paths for SharpClaw runtime state. +/// +public interface IRuntimeStoragePathResolver +{ + /// Gets the tenant-aware SharpClaw storage root for a workspace. + string GetSharpClawRoot(string workspacePath); + + /// Gets the root directory containing persisted sessions. + string GetSessionsRoot(string workspacePath); + + /// Gets the root directory for a specific session. + string GetSessionRoot(string workspacePath, string sessionId); + + /// Gets the session snapshot JSON path. + string GetSessionSnapshotPath(string workspacePath, string sessionId); + + /// Gets the per-session turn lock path. + string GetSessionTurnLockPath(string workspacePath, string sessionId); + + /// Gets the append-only events log path. + string GetEventsPath(string workspacePath, string sessionId); + + /// Gets the checkpoints directory path. + string GetCheckpointsRoot(string workspacePath, string sessionId); + + /// Gets one checkpoint JSON path. + string GetCheckpointPath(string workspacePath, string sessionId, string checkpointId); + + /// Gets the mutations directory path. + string GetMutationsRoot(string workspacePath, string sessionId); + + /// Gets one mutation-set JSON path. + string GetMutationSetPath(string workspacePath, string sessionId, string mutationSetId); + + /// Gets the workspace attachment file path. + string GetWorkspaceActiveSessionPath(string workspacePath); + + /// Gets the share snapshot directory path. + string GetSharesRoot(string workspacePath); + + /// Gets one share snapshot JSON path. + string GetShareSnapshotPath(string workspacePath, string shareId); + + /// Gets the workspace todo snapshot path. + string GetWorkspaceTodosPath(string workspacePath); + + /// Gets the workspace todo lock path. + string GetWorkspaceTodosLockPath(string workspacePath); + + /// Gets the workspace knowledge directory path. + string GetWorkspaceKnowledgeRoot(string workspacePath); + + /// Gets the workspace exports directory path. + string GetExportsRoot(string workspacePath); + + /// Gets the SQLite database path used by alternate session and event stores. + string GetSessionStoreDatabasePath(string workspacePath); + + /// Gets the tool package catalog directory path. + string GetToolPackagesRoot(string workspacePath); + + /// Gets the user-level SharpClaw root with any active tenant partition applied. + string GetUserSharpClawRoot(); +} diff --git a/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 0fbd757..32864ed 100644 --- a/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Protocol.Abstractions; namespace SharpClaw.Code.Infrastructure; @@ -22,6 +23,8 @@ public static IServiceCollection AddSharpClawInfrastructure(this IServiceCollect services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } diff --git a/src/SharpClaw.Code.Infrastructure/Services/RuntimeHostContextAccessor.cs b/src/SharpClaw.Code.Infrastructure/Services/RuntimeHostContextAccessor.cs new file mode 100644 index 0000000..0ec3852 --- /dev/null +++ b/src/SharpClaw.Code.Infrastructure/Services/RuntimeHostContextAccessor.cs @@ -0,0 +1,33 @@ +using System.Threading; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Infrastructure.Services; + +/// +public sealed class RuntimeHostContextAccessor : IRuntimeHostContextAccessor +{ + private readonly AsyncLocal current = new(); + + /// + public RuntimeHostContext? Current => current.Value?.Context; + + /// + public IDisposable BeginScope(RuntimeHostContext? context) + { + var previous = current.Value; + current.Value = new Holder(context); + return new Scope(current, previous); + } + + private sealed record Holder(RuntimeHostContext? Context); + + private sealed class Scope(AsyncLocal current, Holder? previous) : IDisposable + { + public void Dispose() + { + current.Value = previous; + } + } +} diff --git a/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs b/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs new file mode 100644 index 0000000..e8d0a25 --- /dev/null +++ b/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs @@ -0,0 +1,134 @@ +using System.Security.Cryptography; +using System.Text; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; + +namespace SharpClaw.Code.Infrastructure.Services; + +/// +public sealed class RuntimeStoragePathResolver( + IPathService pathService, + IUserProfilePaths userProfilePaths, + IRuntimeHostContextAccessor hostContextAccessor) : IRuntimeStoragePathResolver +{ + /// + public string GetSharpClawRoot(string workspacePath) + { + var workspace = pathService.GetFullPath(workspacePath); + var hostContext = hostContextAccessor.Current; + var baseRoot = string.IsNullOrWhiteSpace(hostContext?.StorageRoot) + ? workspace + : pathService.Combine( + pathService.GetFullPath(hostContext.StorageRoot!), + "workspaces", + BuildWorkspaceKey(workspace)); + + var sharpClawRoot = pathService.Combine(baseRoot, ".sharpclaw"); + return string.IsNullOrWhiteSpace(hostContext?.TenantId) + ? sharpClawRoot + : pathService.Combine(sharpClawRoot, "tenants", SanitizeSegment(hostContext!.TenantId!)); + } + + /// + public string GetSessionsRoot(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "sessions"); + + /// + public string GetSessionRoot(string workspacePath, string sessionId) + => pathService.Combine(GetSessionsRoot(workspacePath), sessionId); + + /// + public string GetSessionSnapshotPath(string workspacePath, string sessionId) + => pathService.Combine(GetSessionRoot(workspacePath, sessionId), "session.json"); + + /// + public string GetSessionTurnLockPath(string workspacePath, string sessionId) + => pathService.Combine(GetSessionRoot(workspacePath, sessionId), ".turn.lock"); + + /// + public string GetEventsPath(string workspacePath, string sessionId) + => pathService.Combine(GetSessionRoot(workspacePath, sessionId), "events.ndjson"); + + /// + public string GetCheckpointsRoot(string workspacePath, string sessionId) + => pathService.Combine(GetSessionRoot(workspacePath, sessionId), "checkpoints"); + + /// + public string GetCheckpointPath(string workspacePath, string sessionId, string checkpointId) + => pathService.Combine(GetCheckpointsRoot(workspacePath, sessionId), $"{checkpointId}.json"); + + /// + public string GetMutationsRoot(string workspacePath, string sessionId) + => pathService.Combine(GetSessionRoot(workspacePath, sessionId), "mutations"); + + /// + public string GetMutationSetPath(string workspacePath, string sessionId, string mutationSetId) + => pathService.Combine(GetMutationsRoot(workspacePath, sessionId), $"{mutationSetId}.json"); + + /// + public string GetWorkspaceActiveSessionPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "active-session.json"); + + /// + public string GetSharesRoot(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "shares"); + + /// + public string GetShareSnapshotPath(string workspacePath, string shareId) + => pathService.Combine(GetSharesRoot(workspacePath), $"{shareId}.json"); + + /// + public string GetWorkspaceTodosPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "tasks.json"); + + /// + public string GetWorkspaceTodosLockPath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), ".tasks.lock"); + + /// + public string GetWorkspaceKnowledgeRoot(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "knowledge"); + + /// + public string GetExportsRoot(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "exports"); + + /// + public string GetSessionStoreDatabasePath(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "session-store.db"); + + /// + public string GetToolPackagesRoot(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "tool-packages"); + + /// + public string GetUserSharpClawRoot() + { + var root = userProfilePaths.GetUserSharpClawRoot(); + var hostContext = hostContextAccessor.Current; + return string.IsNullOrWhiteSpace(hostContext?.TenantId) + ? root + : pathService.Combine(root, "tenants", SanitizeSegment(hostContext!.TenantId!)); + } + + private string BuildWorkspaceKey(string workspacePath) + { + var normalized = pathService.GetFullPath(workspacePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = pathService.GetFileName(normalized); + var slug = string.IsNullOrWhiteSpace(name) ? "workspace" : SanitizeSegment(name); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant()[..12]; + return $"{slug}-{hash}"; + } + + private static string SanitizeSegment(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-'); + } + + var result = builder.ToString().Trim('-'); + return string.IsNullOrWhiteSpace(result) ? "default" : result; + } +} diff --git a/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs b/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs index dbaec24..41e319a 100644 --- a/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs +++ b/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs @@ -14,7 +14,7 @@ namespace SharpClaw.Code.Memory.Services; public sealed class SqliteWorkspaceKnowledgeStore( IFileSystem fileSystem, IPathService pathService, - IUserProfilePaths userProfilePaths) : IWorkspaceKnowledgeStore + IRuntimeStoragePathResolver storagePathResolver) : IWorkspaceKnowledgeStore { private const string WorkspaceKnowledgeDirectoryName = "knowledge"; private const string WorkspaceKnowledgeFileName = "knowledge.db"; @@ -636,12 +636,16 @@ private static async Task CountDistinctAsync( private string GetWorkspaceDatabasePath(string workspaceRoot) { - var normalized = pathService.GetFullPath(workspaceRoot); - return pathService.Combine(normalized, ".sharpclaw", WorkspaceKnowledgeDirectoryName, WorkspaceKnowledgeFileName); + return pathService.Combine( + storagePathResolver.GetWorkspaceKnowledgeRoot(workspaceRoot), + WorkspaceKnowledgeFileName); } private string GetUserMemoryDatabasePath() - => pathService.Combine(userProfilePaths.GetUserSharpClawRoot(), WorkspaceKnowledgeDirectoryName, UserMemoryFileName); + => pathService.Combine( + storagePathResolver.GetUserSharpClawRoot(), + WorkspaceKnowledgeDirectoryName, + UserMemoryFileName); private static string NormalizeFtsQuery(string query) { diff --git a/src/SharpClaw.Code.Plugins/Services/PluginManager.cs b/src/SharpClaw.Code.Plugins/Services/PluginManager.cs index d38b523..f171574 100644 --- a/src/SharpClaw.Code.Plugins/Services/PluginManager.cs +++ b/src/SharpClaw.Code.Plugins/Services/PluginManager.cs @@ -22,6 +22,7 @@ public sealed class PluginManager( PluginManifestValidator manifestValidator, IFileSystem fileSystem, IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver, ISystemClock systemClock, IRuntimeEventPublisher? runtimeEventPublisher = null, ILogger? logger = null) : IPluginManager @@ -250,8 +251,7 @@ private Task WritePluginStateAsync(string pluginDirectory, LoadedPlugin plugin, private string GetPluginsRoot(string workspaceRoot) => pathService.Combine( - pathService.GetFullPath(workspaceRoot), - PluginLocalStore.SharpClawRelativeDirectoryName, + storagePathResolver.GetSharpClawRoot(workspaceRoot), PluginLocalStore.PluginsRelativeDirectoryName); private string GetPluginDirectory(string workspaceRoot, string pluginId) diff --git a/src/SharpClaw.Code.Protocol/Abstractions/IRuntimeHostContextAccessor.cs b/src/SharpClaw.Code.Protocol/Abstractions/IRuntimeHostContextAccessor.cs new file mode 100644 index 0000000..321e0cb --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Abstractions/IRuntimeHostContextAccessor.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Protocol.Abstractions; + +/// +/// Tracks the active runtime host context for the current async flow. +/// +public interface IRuntimeHostContextAccessor +{ + /// + /// Gets the host context for the active async flow, when one is present. + /// + RuntimeHostContext? Current { get; } + + /// + /// Begins a new host-context scope for the current async flow. + /// + IDisposable BeginScope(RuntimeHostContext? context); +} diff --git a/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs b/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs index 7d5d546..c250875 100644 --- a/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs +++ b/src/SharpClaw.Code.Protocol/Commands/RunPromptRequest.cs @@ -16,6 +16,7 @@ namespace SharpClaw.Code.Protocol.Commands; /// When non-null, selects the registered agent id for the turn. /// When non-null, supplies the bounded contract for sub-agent-worker runs. /// Whether the caller can participate in approval prompts. +/// Optional embedded host and tenant context. public sealed record RunPromptRequest( string Prompt, string? SessionId, @@ -26,4 +27,5 @@ public sealed record RunPromptRequest( PrimaryMode? PrimaryMode = null, string? AgentId = null, DelegatedTaskContract? DelegatedTask = null, - bool IsInteractive = true); + bool IsInteractive = true, + RuntimeHostContext? HostContext = null); diff --git a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs index eb25f04..95eaf71 100644 --- a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs @@ -304,6 +304,7 @@ public sealed record WorkspaceDiagnosticsSnapshot( /// Optional output format override. /// Optional primary mode override. /// Optional agent override. +/// Optional tenant override for embedded hosts. public sealed record ServerPromptRequest( string Prompt, string? SessionId, @@ -311,7 +312,8 @@ public sealed record ServerPromptRequest( PermissionMode? PermissionMode, OutputFormat? OutputFormat, PrimaryMode? PrimaryMode, - string? AgentId); + string? AgentId, + string? TenantId); /// /// Metadata for a shared session snapshot. diff --git a/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs b/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs new file mode 100644 index 0000000..c25b59c --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs @@ -0,0 +1,122 @@ +using SharpClaw.Code.Protocol.Events; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Selects the durable session/event storage backend for an embedded host. +/// +public enum SessionStoreKind +{ + /// + /// Persist session artifacts as files under workspace or configured storage roots. + /// + FileSystem, + + /// + /// Persist core session artifacts in SQLite. + /// + Sqlite, +} + +/// +/// Describes the current embedded host and tenant boundary for a runtime invocation. +/// +/// Stable host identifier for emitted events and diagnostics. +/// Optional tenant identifier. +/// Optional external storage root for durable state. +/// Selected session store backend. +/// Whether the runtime is being used as an embedded SDK host. +public sealed record RuntimeHostContext( + string HostId, + string? TenantId = null, + string? StorageRoot = null, + SessionStoreKind SessionStoreKind = SessionStoreKind.FileSystem, + bool IsEmbeddedHost = false); + +/// +/// Wraps a runtime event with host and routing metadata for external streaming. +/// +/// Stable event type name. +/// Event timestamp. +/// The underlying runtime event payload. +/// Normalized workspace path when known. +/// Session identifier when known. +/// Tenant identifier when present. +/// Embedded host identifier when present. +public sealed record RuntimeEventEnvelope( + string EventType, + DateTimeOffset OccurredAtUtc, + RuntimeEvent Event, + string? WorkspacePath = null, + string? SessionId = null, + string? TenantId = null, + string? HostId = null); + +/// +/// Describes the packaged distribution metadata for a custom tool bundle. +/// +/// Stable package identifier. +/// Package version. +/// Distribution kind, such as nuget or local. +/// Entry assembly or process path. +/// Target framework moniker. +/// Optional discovery tags. +public sealed record ToolPackageReference( + string PackageId, + string Version, + string PackageType, + string EntryAssembly, + string? TargetFramework = null, + string[]? Tags = null); + +/// +/// Declares one tool surfaced from a packaged tool bundle. +/// +/// Stable tool name. +/// Human-readable description. +/// Optional JSON schema or example payload. +/// Whether the tool requires approval by default. +/// Whether the tool mutates workspace or environment state. +/// Optional discovery tags. +public sealed record PackagedToolDescriptor( + string Name, + string Description, + string? InputSchemaJson, + bool RequiresApproval = false, + bool IsDestructive = false, + string[]? Tags = null); + +/// +/// Manifest describing a distributable custom tool package. +/// +/// Package distribution metadata. +/// Optional publisher identifier. +/// Optional package description. +/// Tools provided by the package. +public sealed record ToolPackageManifest( + ToolPackageReference Package, + string? PublisherId, + string? Description, + PackagedToolDescriptor[] Tools); + +/// +/// Represents one installed tool package in the local workspace catalog. +/// +/// The installed manifest. +/// Installation time. +/// Source used to install the package. +public sealed record InstalledToolPackage( + ToolPackageManifest Manifest, + DateTimeOffset InstalledAtUtc, + string InstallSource); + +/// +/// Request payload used to install a packaged tool manifest into a workspace catalog. +/// +/// The manifest to install. +/// The source identifier or path for the install action. +/// Whether the underlying plugin should be enabled immediately. +public sealed record ToolPackageInstallRequest( + ToolPackageManifest Manifest, + string InstallSource, + bool EnableAfterInstall = true); diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index d04b83a..f286520 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -52,6 +52,17 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(MemorySaveRequest))] [JsonSerializable(typeof(MemoryListRequest))] +[JsonSerializable(typeof(SessionStoreKind))] +[JsonSerializable(typeof(RuntimeHostContext))] +[JsonSerializable(typeof(RuntimeEventEnvelope))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ToolPackageReference))] +[JsonSerializable(typeof(PackagedToolDescriptor))] +[JsonSerializable(typeof(PackagedToolDescriptor[]))] +[JsonSerializable(typeof(ToolPackageManifest))] +[JsonSerializable(typeof(ToolPackageInstallRequest))] +[JsonSerializable(typeof(InstalledToolPackage))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(WorkspaceSearchHitKind))] [JsonSerializable(typeof(WorkspaceSearchHit))] [JsonSerializable(typeof(List))] diff --git a/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs b/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs index 0362b35..5df562b 100644 --- a/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs +++ b/src/SharpClaw.Code.Runtime/Abstractions/IRuntimeCommandService.cs @@ -154,6 +154,7 @@ Task ImportPortableSessionBundleAsync( /// Optional explicit session id (e.g. from --session); when null, attachment/latest resolution applies. /// Optional effective agent id. /// Whether the caller can participate in approval prompts. +/// Optional embedded host and tenant context. public sealed record RuntimeCommandContext( string WorkingDirectory, string? Model, @@ -162,4 +163,5 @@ public sealed record RuntimeCommandContext( PrimaryMode? PrimaryMode = null, string? SessionId = null, string? AgentId = null, - bool IsInteractive = true); + bool IsInteractive = true, + RuntimeHostContext? HostContext = null); diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index 5bddf1e..c4773ac 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -94,8 +94,12 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSharpClawMemory(); services.AddSharpClawSkills(); services.AddSharpClawGit(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Runtime/Export/PortableSessionBundleService.cs b/src/SharpClaw.Code.Runtime/Export/PortableSessionBundleService.cs index d365e7f..d63fcb5 100644 --- a/src/SharpClaw.Code.Runtime/Export/PortableSessionBundleService.cs +++ b/src/SharpClaw.Code.Runtime/Export/PortableSessionBundleService.cs @@ -13,7 +13,8 @@ namespace SharpClaw.Code.Runtime.Export; public sealed class PortableSessionBundleService( ISessionStore sessionStore, IFileSystem fileSystem, - IPathService pathService) : IPortableSessionBundleService + IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver) : IPortableSessionBundleService { /// public async Task CreateBundleZipAsync( @@ -31,7 +32,7 @@ public async Task CreateBundleZipAsync( throw new InvalidOperationException("No session found to bundle."); } - var sessionRoot = SessionStorageLayout.GetSessionRoot(pathService, workspace, session.Id); + var sessionRoot = storagePathResolver.GetSessionRoot(workspace, session.Id); if (!fileSystem.DirectoryExists(sessionRoot)) { throw new InvalidOperationException($"Session directory for '{session.Id}' was not found."); @@ -62,7 +63,7 @@ await fileSystem.WriteAllTextAsync( cancellationToken) .ConfigureAwait(false); - var bundleDir = pathService.Combine(workspace, ".sharpclaw", "exports"); + var bundleDir = storagePathResolver.GetExportsRoot(workspace); fileSystem.CreateDirectory(bundleDir); var zipPath = string.IsNullOrWhiteSpace(outputZipPath) ? pathService.Combine(bundleDir, $"{session.Id}-{DateTimeOffset.UtcNow:yyyyMMddTHHmmss}.sharpclaw-bundle.zip") @@ -139,7 +140,7 @@ public async Task ImportBundleZipAsync( Path.GetDirectoryName(snapshotFull) ?? throw new InvalidOperationException("Could not resolve payload directory from manifest paths.")); - var targetRoot = SessionStorageLayout.GetSessionRoot(pathService, workspace, manifest.SessionId); + var targetRoot = storagePathResolver.GetSessionRoot(workspace, manifest.SessionId); if (fileSystem.DirectoryExists(targetRoot)) { if (!replaceExisting) @@ -151,7 +152,7 @@ public async Task ImportBundleZipAsync( fileSystem.DeleteDirectoryRecursive(targetRoot); } - var sessionsRoot = SessionStorageLayout.GetSessionsRoot(pathService, workspace); + var sessionsRoot = storagePathResolver.GetSessionsRoot(workspace); fileSystem.CreateDirectory(sessionsRoot); await CopyTreeAsync(payloadDir, targetRoot, cancellationToken).ConfigureAwait(false); diff --git a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs index b4e485f..477fd73 100644 --- a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs +++ b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Providers.Models; +using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Events; @@ -39,6 +40,8 @@ public sealed class ConversationRuntime( ISystemClock systemClock, IFileSystem fileSystem, IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver, + IRuntimeHostContextAccessor hostContextAccessor, IOperationalDiagnosticsCoordinator operationalDiagnostics, ICustomCommandDiscoveryService customCommandDiscovery, ISessionExportService sessionExportService, @@ -68,7 +71,7 @@ private static SemaphoreSlim GetSessionMutex(string workspacePath, string sessio public async Task CreateSessionAsync(string workspacePath, PermissionMode permissionMode, OutputFormat outputFormat, CancellationToken cancellationToken) { var normalizedWorkspacePath = NormalizeWorkspacePath(workspacePath); - fileSystem.CreateDirectory(pathService.Combine(normalizedWorkspacePath, ".sharpclaw")); + fileSystem.CreateDirectory(storagePathResolver.GetSharpClawRoot(normalizedWorkspacePath)); var sessionId = CreateIdentifier("session"); var session = new ConversationSession( @@ -104,6 +107,7 @@ public async Task CreateSessionAsync(string workspacePath, public async Task RunPromptAsync(RunPromptRequest request, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(request.Prompt); + using var hostScope = hostContextAccessor.BeginScope(request.HostContext); var workspacePath = NormalizeWorkspacePath(request.WorkingDirectory); request = EnrichRequestWithEditorIngress(workspacePath, request); @@ -129,7 +133,7 @@ public async Task RunPromptAsync(RunPromptRequest request, await sessionMutex.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var turnLockPath = SessionStorageLayout.GetSessionTurnLockPath(pathService, workspacePath, session.Id); + var turnLockPath = storagePathResolver.GetSessionTurnLockPath(workspacePath, session.Id); await using var crossProcessTurnLock = await fileSystem .AcquireExclusiveFileLockAsync(turnLockPath, cancellationToken) .ConfigureAwait(false); @@ -467,7 +471,8 @@ public Task ExecutePromptAsync(string prompt, RuntimeComman .ToDictionary(pair => pair.Key, pair => pair.Value!), PrimaryMode: context.PrimaryMode, AgentId: context.AgentId, - IsInteractive: context.IsInteractive), + IsInteractive: context.IsInteractive, + HostContext: context.HostContext), cancellationToken); /// @@ -478,6 +483,7 @@ public async Task ExecuteCustomCommandAsync( CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(commandName); + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); var workspace = NormalizeWorkspacePath(context.WorkingDirectory); var definition = await customCommandDiscovery .FindAsync(workspace, commandName, cancellationToken) @@ -512,13 +518,15 @@ public async Task ExecuteCustomCommandAsync( Metadata: metadata, PrimaryMode: primary, AgentId: definition.AgentId, - IsInteractive: context.IsInteractive), + IsInteractive: context.IsInteractive, + HostContext: context.HostContext), cancellationToken).ConfigureAwait(false); } /// public async Task GetStatusAsync(RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); var input = new OperationalDiagnosticsInput( context.WorkingDirectory, context.Model, @@ -542,6 +550,7 @@ public async Task GetStatusAsync(RuntimeCommandContext context, C /// public async Task RunDoctorAsync(RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); var input = new OperationalDiagnosticsInput( context.WorkingDirectory, context.Model, @@ -564,6 +573,7 @@ public async Task RunDoctorAsync(RuntimeCommandContext context, C /// public async Task InspectSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); var input = new OperationalDiagnosticsInput( context.WorkingDirectory, context.Model, @@ -601,6 +611,7 @@ public async Task ForkSessionAsync( RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var child = await ForkSessionAsync(NormalizeWorkspacePath(context.WorkingDirectory), sourceSessionId, cancellationToken) @@ -627,6 +638,7 @@ public async Task ExportSessionAsync( RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -634,7 +646,7 @@ public async Task ExportSessionAsync( .BuildDocumentAsync(workspace, sessionId, format, cancellationToken) .ConfigureAwait(false); - var exportsDir = pathService.Combine(workspace, ".sharpclaw", "exports"); + var exportsDir = storagePathResolver.GetExportsRoot(workspace); fileSystem.CreateDirectory(exportsDir); var fileName = $"{document.Session.Id}-{document.ExportedAtUtc:yyyyMMddTHHmmss}.{ext}"; @@ -666,6 +678,7 @@ public async Task ExportSessionAsync( /// public async Task UndoAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -694,6 +707,7 @@ public async Task UndoAsync(string? sessionId, RuntimeCommandCont /// public async Task RedoAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -726,6 +740,7 @@ public async Task ExportPortableSessionBundleAsync( RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -755,6 +770,7 @@ public async Task ImportPortableSessionBundleAsync( RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -782,6 +798,7 @@ public async Task ImportPortableSessionBundleAsync( /// public async Task ListSessionsAsync(RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -798,6 +815,7 @@ public async Task ListSessionsAsync(RuntimeCommandContext context /// public async Task AttachSessionAsync(string sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -820,6 +838,7 @@ public async Task AttachSessionAsync(string sessionId, RuntimeCom /// public async Task DetachSessionAsync(RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -835,6 +854,7 @@ public async Task DetachSessionAsync(RuntimeCommandContext contex /// public async Task ShareSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -862,6 +882,7 @@ public async Task ShareSessionAsync(string? sessionId, RuntimeCom /// public async Task UnshareSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -893,6 +914,7 @@ public async Task UnshareSessionAsync(string? sessionId, RuntimeC /// public async Task CompactSessionAsync(string? sessionId, RuntimeCommandContext context, CancellationToken cancellationToken) { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); try { var workspace = NormalizeWorkspacePath(context.WorkingDirectory); @@ -932,7 +954,7 @@ public async Task ForkSessionAsync(string workspacePath, st var events = await eventStore.ReadAllAsync(normalized, parent.Id, cancellationToken).ConfigureAwait(false); var summary = BuildForkHistorySummary(events); - fileSystem.CreateDirectory(pathService.Combine(normalized, ".sharpclaw")); + fileSystem.CreateDirectory(storagePathResolver.GetSharpClawRoot(normalized)); var childId = CreateIdentifier("session"); var md = CloneMetadata(parent.Metadata); @@ -1430,7 +1452,7 @@ private async Task ExecuteWithSessionLockAsync( await sessionMutex.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var turnLockPath = SessionStorageLayout.GetSessionTurnLockPath(pathService, workspacePath, sessionId); + var turnLockPath = storagePathResolver.GetSessionTurnLockPath(workspacePath, sessionId); await using var crossProcessTurnLock = await fileSystem .AcquireExclusiveFileLockAsync(turnLockPath, cancellationToken) .ConfigureAwait(false); diff --git a/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs b/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs index c91eff4..cea8597 100644 --- a/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs +++ b/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs @@ -2,12 +2,18 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; using Microsoft.Extensions.Logging; +using SharpClaw.Code.Providers.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Abstractions; namespace SharpClaw.Code.Runtime.Server; @@ -19,6 +25,13 @@ public sealed class WorkspaceHttpServer( IShareSessionService shareSessionService, ISharpClawConfigService sharpClawConfigService, IHookDispatcher hookDispatcher, + IProviderCatalogService providerCatalogService, + IWorkspaceIndexService workspaceIndexService, + IWorkspaceSearchService workspaceSearchService, + IPersistentMemoryStore persistentMemoryStore, + IRuntimeEventStream runtimeEventStream, + IToolPackageService toolPackageService, + IRuntimeHostContextAccessor hostContextAccessor, ILogger logger) : IWorkspaceHttpServer { /// @@ -82,46 +95,93 @@ private async Task HandleRequestAsync( var requestSucceeded = false; var statusCode = 500; var path = request.Url?.AbsolutePath ?? "/"; + var requestHostContext = ResolveRequestHostContext(request, defaultContext.HostContext, tenantOverride: null); + using var hostScope = hostContextAccessor.BeginScope(requestHostContext); + var requestContext = defaultContext with { HostContext = requestHostContext }; try { if (request.HttpMethod == "GET" && path == "/v1/status") { - var result = await runtimeCommandService.GetStatusAsync(defaultContext, cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.GetStatusAsync(requestContext, cancellationToken).ConfigureAwait(false); statusCode = await WriteCommandResultAsync(response, result, cancellationToken).ConfigureAwait(false); } else if (request.HttpMethod == "GET" && path == "/v1/doctor") { - var result = await runtimeCommandService.RunDoctorAsync(defaultContext, cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.RunDoctorAsync(requestContext, cancellationToken).ConfigureAwait(false); statusCode = await WriteCommandResultAsync(response, result, cancellationToken).ConfigureAwait(false); } else if (request.HttpMethod == "GET" && path == "/v1/sessions") { - var result = await runtimeCommandService.ListSessionsAsync(defaultContext, cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.ListSessionsAsync(requestContext, cancellationToken).ConfigureAwait(false); statusCode = await WriteCommandResultAsync(response, result, cancellationToken).ConfigureAwait(false); } else if (request.HttpMethod == "GET" && path.StartsWith("/v1/sessions/", StringComparison.Ordinal)) { var sessionId = Uri.UnescapeDataString(path["/v1/sessions/".Length..]); - var result = await runtimeCommandService.InspectSessionAsync(sessionId, defaultContext, cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.InspectSessionAsync(sessionId, requestContext, cancellationToken).ConfigureAwait(false); statusCode = await WriteCommandResultAsync(response, result, cancellationToken).ConfigureAwait(false); } else if (request.HttpMethod == "POST" && path == "/v1/prompt") { - statusCode = await HandlePromptAsync(request, response, workspaceRoot, defaultContext, cancellationToken).ConfigureAwait(false); + statusCode = await HandlePromptAsync(request, response, workspaceRoot, requestContext, cancellationToken).ConfigureAwait(false); } else if (request.HttpMethod == "POST" && path.StartsWith("/v1/share/", StringComparison.Ordinal)) { var sessionId = Uri.UnescapeDataString(path["/v1/share/".Length..]); - var result = await runtimeCommandService.ShareSessionAsync(sessionId, defaultContext, cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.ShareSessionAsync(sessionId, requestContext, cancellationToken).ConfigureAwait(false); statusCode = await WriteCommandResultAsync(response, result, cancellationToken).ConfigureAwait(false); } else if (request.HttpMethod == "DELETE" && path.StartsWith("/v1/share/", StringComparison.Ordinal)) { var sessionId = Uri.UnescapeDataString(path["/v1/share/".Length..]); - var result = await runtimeCommandService.UnshareSessionAsync(sessionId, defaultContext, cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UnshareSessionAsync(sessionId, requestContext, cancellationToken).ConfigureAwait(false); statusCode = await WriteCommandResultAsync(response, result, cancellationToken).ConfigureAwait(false); } + else if (request.HttpMethod == "GET" && path == "/v1/admin/providers") + { + statusCode = 200; + var catalog = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, catalog, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "GET" && path == "/v1/admin/index/status") + { + statusCode = 200; + var status = await workspaceIndexService.GetStatusAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, status, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "POST" && path == "/v1/admin/index/refresh") + { + statusCode = 200; + var refresh = await workspaceIndexService.RefreshAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, refresh, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "POST" && path == "/v1/admin/search") + { + statusCode = await HandleWorkspaceSearchAsync(request, response, workspaceRoot, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "GET" && path == "/v1/admin/memory") + { + statusCode = await HandleMemoryListAsync(request, response, workspaceRoot, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "GET" && path == "/v1/admin/events/recent") + { + statusCode = await HandleRecentEventsAsync(response, workspaceRoot, requestHostContext, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "GET" && path == "/v1/admin/events/stream") + { + statusCode = await HandleEventStreamAsync(response, workspaceRoot, requestHostContext, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "GET" && path == "/v1/admin/tool-packages") + { + statusCode = 200; + var packages = await toolPackageService.ListInstalledAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, packages, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "POST" && path == "/v1/admin/tool-packages/install") + { + statusCode = await HandleToolPackageInstallAsync(request, response, workspaceRoot, cancellationToken).ConfigureAwait(false); + } else if (request.HttpMethod == "GET" && path.StartsWith("/s/", StringComparison.Ordinal)) { var shareId = Uri.UnescapeDataString(path["/s/".Length..]); @@ -181,7 +241,8 @@ private async Task HandlePromptAsync( PrimaryMode: payload.PrimaryMode ?? defaultContext.PrimaryMode, SessionId: payload.SessionId ?? defaultContext.SessionId, AgentId: payload.AgentId ?? defaultContext.AgentId, - IsInteractive: false); + IsInteractive: false, + HostContext: ResolveRequestHostContext(request, defaultContext.HostContext, payload.TenantId)); var result = await runtimeCommandService.ExecutePromptAsync(payload.Prompt, commandContext, cancellationToken).ConfigureAwait(false); var accept = request.Headers["Accept"]; @@ -199,7 +260,7 @@ private async Task HandlePromptAsync( await WriteSseAsync( writer, "runtime-event", - JsonSerializer.Serialize(runtimeEvent, runtimeEvent.GetType(), ProtocolJsonContext.Default)) + JsonSerializer.Serialize(runtimeEvent, runtimeEvent.GetType(), ServerJsonOptions)) .ConfigureAwait(false); } @@ -216,6 +277,93 @@ await WriteSseAsync( return 200; } + private async Task HandleWorkspaceSearchAsync( + HttpListenerRequest request, + HttpListenerResponse response, + string workspaceRoot, + CancellationToken cancellationToken) + { + await using var body = request.InputStream; + var payload = await JsonSerializer.DeserializeAsync(body, ProtocolJsonContext.Default.WorkspaceSearchRequest, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Request body is required."); + var result = await workspaceSearchService.SearchAsync(workspaceRoot, payload, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, result, cancellationToken).ConfigureAwait(false); + return 200; + } + + private async Task HandleMemoryListAsync( + HttpListenerRequest request, + HttpListenerResponse response, + string workspaceRoot, + CancellationToken cancellationToken) + { + var scope = TryParseEnum(request.QueryString["scope"]); + var query = request.QueryString["query"] ?? request.QueryString["q"]; + var limit = ParseInt(request.QueryString["limit"], 50, 1, 500); + var entries = await persistentMemoryStore + .ListAsync(workspaceRoot, scope, query, limit, cancellationToken) + .ConfigureAwait(false); + await WriteJsonAsync(response, 200, entries, cancellationToken).ConfigureAwait(false); + return 200; + } + + private async Task HandleRecentEventsAsync( + HttpListenerResponse response, + string workspaceRoot, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken) + { + var events = FilterEnvelopes(runtimeEventStream.GetRecentEnvelopesSnapshot(), workspaceRoot, hostContext); + await WriteJsonAsync(response, 200, events, cancellationToken).ConfigureAwait(false); + return 200; + } + + private async Task HandleEventStreamAsync( + HttpListenerResponse response, + string workspaceRoot, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken) + { + response.StatusCode = 200; + response.ContentType = "text/event-stream"; + response.ContentEncoding = Encoding.UTF8; + await using var writer = new StreamWriter(response.OutputStream, new UTF8Encoding(false), leaveOpen: true); + await writer.WriteLineAsync("retry: 1000").ConfigureAwait(false); + await writer.WriteLineAsync().ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + + await foreach (var envelope in runtimeEventStream.StreamAsync(cancellationToken).ConfigureAwait(false)) + { + if (!ShouldIncludeEnvelope(envelope, workspaceRoot, hostContext)) + { + continue; + } + + await WriteSseAsync( + writer, + "runtime-envelope", + JsonSerializer.Serialize(envelope, ProtocolJsonContext.Default.RuntimeEventEnvelope)) + .ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + return 200; + } + + private async Task HandleToolPackageInstallAsync( + HttpListenerRequest request, + HttpListenerResponse response, + string workspaceRoot, + CancellationToken cancellationToken) + { + await using var body = request.InputStream; + var payload = await JsonSerializer.DeserializeAsync(body, ProtocolJsonContext.Default.ToolPackageInstallRequest, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Request body is required."); + var result = await toolPackageService.InstallAsync(workspaceRoot, payload, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, result, cancellationToken).ConfigureAwait(false); + return 200; + } + private static async Task WriteSseAsync(StreamWriter writer, string eventName, string payload) { await writer.WriteLineAsync($"event: {eventName}").ConfigureAwait(false); @@ -296,6 +444,71 @@ private static JsonSerializerOptions CreateServerJsonOptions() return options; } + private static RuntimeHostContext? ResolveRequestHostContext( + HttpListenerRequest request, + RuntimeHostContext? fallback, + string? tenantOverride) + { + var hostId = HeaderOrDefault(request, "X-SharpClaw-Host-Id", fallback?.HostId); + var tenantId = string.IsNullOrWhiteSpace(tenantOverride) + ? HeaderOrDefault(request, "X-SharpClaw-Tenant-Id", fallback?.TenantId) + : tenantOverride; + var storageRoot = HeaderOrDefault(request, "X-SharpClaw-Storage-Root", fallback?.StorageRoot); + var sessionStoreKind = TryParseEnum(request.Headers["X-SharpClaw-Session-Store"]) ?? fallback?.SessionStoreKind ?? SessionStoreKind.FileSystem; + var isEmbeddedHost = fallback?.IsEmbeddedHost ?? false; + + if (string.IsNullOrWhiteSpace(hostId) + && string.IsNullOrWhiteSpace(tenantId) + && string.IsNullOrWhiteSpace(storageRoot) + && fallback is null) + { + return null; + } + + return new RuntimeHostContext( + HostId: string.IsNullOrWhiteSpace(hostId) ? "workspace-http-server" : hostId!, + TenantId: string.IsNullOrWhiteSpace(tenantId) ? null : tenantId, + StorageRoot: string.IsNullOrWhiteSpace(storageRoot) ? null : storageRoot, + SessionStoreKind: sessionStoreKind, + IsEmbeddedHost: isEmbeddedHost || !string.IsNullOrWhiteSpace(storageRoot) || !string.IsNullOrWhiteSpace(tenantId)); + } + + private static IReadOnlyList FilterEnvelopes( + IEnumerable source, + string workspaceRoot, + RuntimeHostContext? hostContext) + => source.Where(envelope => ShouldIncludeEnvelope(envelope, workspaceRoot, hostContext)).ToArray(); + + private static bool ShouldIncludeEnvelope( + RuntimeEventEnvelope envelope, + string workspaceRoot, + RuntimeHostContext? hostContext) + { + var workspaceMatches = string.IsNullOrWhiteSpace(envelope.WorkspacePath) + || string.Equals(envelope.WorkspacePath, workspaceRoot, StringComparison.Ordinal); + if (!workspaceMatches) + { + return false; + } + + if (string.IsNullOrWhiteSpace(hostContext?.TenantId)) + { + return true; + } + + return string.Equals(envelope.TenantId, hostContext.TenantId, StringComparison.Ordinal); + } + + private static string? HeaderOrDefault(HttpListenerRequest request, string headerName, string? fallback) + => string.IsNullOrWhiteSpace(request.Headers[headerName]) ? fallback : request.Headers[headerName]; + + private static TEnum? TryParseEnum(string? value) + where TEnum : struct, Enum + => Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : null; + + private static int ParseInt(string? value, int fallback, int min, int max) + => int.TryParse(value, out var parsed) ? Math.Clamp(parsed, min, max) : fallback; + private sealed record ServerCommandEnvelope( bool Succeeded, int ExitCode, diff --git a/src/SharpClaw.Code.Runtime/Workflow/ShareSessionService.cs b/src/SharpClaw.Code.Runtime/Workflow/ShareSessionService.cs index 19dccd5..e85bd5d 100644 --- a/src/SharpClaw.Code.Runtime/Workflow/ShareSessionService.cs +++ b/src/SharpClaw.Code.Runtime/Workflow/ShareSessionService.cs @@ -18,6 +18,7 @@ namespace SharpClaw.Code.Runtime.Workflow; public sealed class ShareSessionService( IFileSystem fileSystem, IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver, ISystemClock systemClock, ISessionStore sessionStore, IEventStore eventStore, @@ -57,8 +58,8 @@ public async Task CreateShareAsync(string workspaceRoot, str var events = await eventStore.ReadAllAsync(normalizedWorkspace, sessionId, cancellationToken).ConfigureAwait(false); var snapshot = new SharedSessionSnapshot(record, SanitizeSession(session, record), events.ToArray()); - var snapshotPath = SessionStorageLayout.GetShareSnapshotPath(pathService, normalizedWorkspace, shareId); - fileSystem.CreateDirectory(SessionStorageLayout.GetSharesRoot(pathService, normalizedWorkspace)); + var snapshotPath = storagePathResolver.GetShareSnapshotPath(normalizedWorkspace, shareId); + fileSystem.CreateDirectory(storagePathResolver.GetSharesRoot(normalizedWorkspace)); await fileSystem .WriteAllTextAsync(snapshotPath, JsonSerializer.Serialize(snapshot, ProtocolJsonContext.Default.SharedSessionSnapshot), cancellationToken) .ConfigureAwait(false); @@ -108,7 +109,7 @@ public async Task RemoveShareAsync(string workspaceRoot, string sessionId, return false; } - fileSystem.TryDeleteFile(SessionStorageLayout.GetShareSnapshotPath(pathService, normalizedWorkspace, shareId)); + fileSystem.TryDeleteFile(storagePathResolver.GetShareSnapshotPath(normalizedWorkspace, shareId)); var metadata = new Dictionary(session.Metadata, StringComparer.Ordinal); metadata.Remove(SharpClawWorkflowMetadataKeys.ShareId); metadata.Remove(SharpClawWorkflowMetadataKeys.ShareUrl); @@ -145,7 +146,7 @@ await hookDispatcher public async Task GetSharedSnapshotAsync(string workspaceRoot, string shareId, CancellationToken cancellationToken) { var content = await fileSystem - .ReadAllTextIfExistsAsync(SessionStorageLayout.GetShareSnapshotPath(pathService, pathService.GetFullPath(workspaceRoot), shareId), cancellationToken) + .ReadAllTextIfExistsAsync(storagePathResolver.GetShareSnapshotPath(pathService.GetFullPath(workspaceRoot), shareId), cancellationToken) .ConfigureAwait(false); return string.IsNullOrWhiteSpace(content) ? null diff --git a/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs b/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs index dcffafe..c1a4655 100644 --- a/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs +++ b/src/SharpClaw.Code.Runtime/Workflow/TodoService.cs @@ -17,6 +17,7 @@ public sealed class TodoService( IEventStore eventStore, IFileSystem fileSystem, IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver, ISystemClock systemClock) : ITodoService { /// @@ -106,7 +107,7 @@ private async Task ReadSessionTodosAsync(string workspaceRoot, strin private async Task ReadWorkspaceTodosAsync(string workspaceRoot, CancellationToken cancellationToken) { - var path = SessionStorageLayout.GetWorkspaceTodosPath(pathService, workspaceRoot); + var path = storagePathResolver.GetWorkspaceTodosPath(workspaceRoot); var content = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(content)) { @@ -137,7 +138,7 @@ private async Task AddSessionTodoAsync( resolvedSessionId); await using var gate = await fileSystem - .AcquireExclusiveFileLockAsync(SessionStorageLayout.GetSessionTurnLockPath(pathService, normalizedWorkspaceRoot, resolvedSessionId), cancellationToken) + .AcquireExclusiveFileLockAsync(storagePathResolver.GetSessionTurnLockPath(normalizedWorkspaceRoot, resolvedSessionId), cancellationToken) .ConfigureAwait(false); var session = await sessionStore.GetByIdAsync(normalizedWorkspaceRoot, resolvedSessionId, cancellationToken).ConfigureAwait(false) @@ -161,7 +162,7 @@ private async Task UpdateSessionTodoAsync( var resolvedSessionId = RequireSessionId(sessionId); await using var gate = await fileSystem - .AcquireExclusiveFileLockAsync(SessionStorageLayout.GetSessionTurnLockPath(pathService, normalizedWorkspaceRoot, resolvedSessionId), cancellationToken) + .AcquireExclusiveFileLockAsync(storagePathResolver.GetSessionTurnLockPath(normalizedWorkspaceRoot, resolvedSessionId), cancellationToken) .ConfigureAwait(false); var session = await sessionStore.GetByIdAsync(normalizedWorkspaceRoot, resolvedSessionId, cancellationToken).ConfigureAwait(false) @@ -197,7 +198,7 @@ private async Task RemoveSessionTodoAsync( var resolvedSessionId = RequireSessionId(sessionId); await using var gate = await fileSystem - .AcquireExclusiveFileLockAsync(SessionStorageLayout.GetSessionTurnLockPath(pathService, normalizedWorkspaceRoot, resolvedSessionId), cancellationToken) + .AcquireExclusiveFileLockAsync(storagePathResolver.GetSessionTurnLockPath(normalizedWorkspaceRoot, resolvedSessionId), cancellationToken) .ConfigureAwait(false); var session = await sessionStore.GetByIdAsync(normalizedWorkspaceRoot, resolvedSessionId, cancellationToken).ConfigureAwait(false) @@ -254,9 +255,9 @@ private async Task AddWorkspaceTodoAsync( CancellationToken cancellationToken) { var normalizedWorkspaceRoot = pathService.GetFullPath(workspaceRoot); - fileSystem.CreateDirectory(SessionStorageLayout.GetSharpClawRoot(pathService, normalizedWorkspaceRoot)); + fileSystem.CreateDirectory(storagePathResolver.GetSharpClawRoot(normalizedWorkspaceRoot)); await using var gate = await fileSystem - .AcquireExclusiveFileLockAsync(SessionStorageLayout.GetWorkspaceTodosLockPath(pathService, normalizedWorkspaceRoot), cancellationToken) + .AcquireExclusiveFileLockAsync(storagePathResolver.GetWorkspaceTodosLockPath(normalizedWorkspaceRoot), cancellationToken) .ConfigureAwait(false); var todos = (await ReadWorkspaceTodosAsync(normalizedWorkspaceRoot, cancellationToken).ConfigureAwait(false)).ToList(); @@ -285,9 +286,9 @@ private async Task UpdateWorkspaceTodoAsync( CancellationToken cancellationToken) { var normalizedWorkspaceRoot = pathService.GetFullPath(workspaceRoot); - fileSystem.CreateDirectory(SessionStorageLayout.GetSharpClawRoot(pathService, normalizedWorkspaceRoot)); + fileSystem.CreateDirectory(storagePathResolver.GetSharpClawRoot(normalizedWorkspaceRoot)); await using var gate = await fileSystem - .AcquireExclusiveFileLockAsync(SessionStorageLayout.GetWorkspaceTodosLockPath(pathService, normalizedWorkspaceRoot), cancellationToken) + .AcquireExclusiveFileLockAsync(storagePathResolver.GetWorkspaceTodosLockPath(normalizedWorkspaceRoot), cancellationToken) .ConfigureAwait(false); var todos = (await ReadWorkspaceTodosAsync(normalizedWorkspaceRoot, cancellationToken).ConfigureAwait(false)).ToList(); @@ -315,9 +316,9 @@ private async Task UpdateWorkspaceTodoAsync( private async Task RemoveWorkspaceTodoAsync(string workspaceRoot, string todoId, CancellationToken cancellationToken) { var normalizedWorkspaceRoot = pathService.GetFullPath(workspaceRoot); - fileSystem.CreateDirectory(SessionStorageLayout.GetSharpClawRoot(pathService, normalizedWorkspaceRoot)); + fileSystem.CreateDirectory(storagePathResolver.GetSharpClawRoot(normalizedWorkspaceRoot)); await using var gate = await fileSystem - .AcquireExclusiveFileLockAsync(SessionStorageLayout.GetWorkspaceTodosLockPath(pathService, normalizedWorkspaceRoot), cancellationToken) + .AcquireExclusiveFileLockAsync(storagePathResolver.GetWorkspaceTodosLockPath(normalizedWorkspaceRoot), cancellationToken) .ConfigureAwait(false); var todos = (await ReadWorkspaceTodosAsync(normalizedWorkspaceRoot, cancellationToken).ConfigureAwait(false)).ToList(); @@ -333,7 +334,7 @@ private async Task RemoveWorkspaceTodoAsync(string workspaceRoot, string t private Task WriteWorkspaceTodosAsync(string workspaceRoot, IReadOnlyList todos, CancellationToken cancellationToken) => fileSystem.WriteAllTextAsync( - SessionStorageLayout.GetWorkspaceTodosPath(pathService, workspaceRoot), + storagePathResolver.GetWorkspaceTodosPath(workspaceRoot), JsonSerializer.Serialize(Sort(todos).ToList(), ProtocolJsonContext.Default.ListTodoItem), cancellationToken); diff --git a/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj b/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj index 0e7237c..5ff4949 100644 --- a/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj +++ b/src/SharpClaw.Code.Sessions/SharpClaw.Code.Sessions.csproj @@ -1,6 +1,7 @@ + diff --git a/src/SharpClaw.Code.Sessions/Storage/FileCheckpointStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileCheckpointStore.cs index 92d59c6..fd3bac7 100644 --- a/src/SharpClaw.Code.Sessions/Storage/FileCheckpointStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/FileCheckpointStore.cs @@ -11,20 +11,20 @@ namespace SharpClaw.Code.Sessions.Storage; /// /// Stores runtime checkpoints as readable JSON files under each session. /// -public sealed class FileCheckpointStore(IFileSystem fileSystem, IPathService pathService, ILogger? logger = null) : ICheckpointStore +public sealed class FileCheckpointStore(IFileSystem fileSystem, IRuntimeStoragePathResolver storagePathResolver, ILogger? logger = null) : ICheckpointStore { /// public Task SaveAsync(string workspacePath, RuntimeCheckpoint checkpoint, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(checkpoint, ProtocolJsonContext.Default.RuntimeCheckpoint); - var path = SessionStorageLayout.GetCheckpointPath(pathService, workspacePath, checkpoint.SessionId, checkpoint.Id); + var path = storagePathResolver.GetCheckpointPath(workspacePath, checkpoint.SessionId, checkpoint.Id); return fileSystem.WriteAllTextAsync(path, json, cancellationToken); } /// public async Task GetLatestAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) { - var checkpointsRoot = SessionStorageLayout.GetCheckpointsRoot(pathService, workspacePath, sessionId); + var checkpointsRoot = storagePathResolver.GetCheckpointsRoot(workspacePath, sessionId); if (!fileSystem.DirectoryExists(checkpointsRoot)) { return null; diff --git a/src/SharpClaw.Code.Sessions/Storage/FileMutationSetStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileMutationSetStore.cs index 456d0e8..eaf2a5a 100644 --- a/src/SharpClaw.Code.Sessions/Storage/FileMutationSetStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/FileMutationSetStore.cs @@ -9,14 +9,14 @@ namespace SharpClaw.Code.Sessions.Storage; /// /// File-backed under each session's mutations directory. /// -public sealed class FileMutationSetStore(IFileSystem fileSystem, IPathService pathService) : IMutationSetStore +public sealed class FileMutationSetStore(IFileSystem fileSystem, IRuntimeStoragePathResolver storagePathResolver) : IMutationSetStore { /// public Task SaveAsync(string workspacePath, MutationSetDocument document, CancellationToken cancellationToken) { - var dir = SessionStorageLayout.GetMutationsRoot(pathService, workspacePath, document.SessionId); + var dir = storagePathResolver.GetMutationsRoot(workspacePath, document.SessionId); fileSystem.CreateDirectory(dir); - var path = SessionStorageLayout.GetMutationSetPath(pathService, workspacePath, document.SessionId, document.Id); + var path = storagePathResolver.GetMutationSetPath(workspacePath, document.SessionId, document.Id); var json = JsonSerializer.Serialize(document, ProtocolJsonContext.Default.MutationSetDocument); return fileSystem.WriteAllTextAsync(path, json, cancellationToken); } @@ -24,7 +24,7 @@ public Task SaveAsync(string workspacePath, MutationSetDocument document, Cancel /// public async Task GetAsync(string workspacePath, string sessionId, string mutationSetId, CancellationToken cancellationToken) { - var path = SessionStorageLayout.GetMutationSetPath(pathService, workspacePath, sessionId, mutationSetId); + var path = storagePathResolver.GetMutationSetPath(workspacePath, sessionId, mutationSetId); var text = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); return string.IsNullOrWhiteSpace(text) ? null diff --git a/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs index 7c8794e..599d71f 100644 --- a/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/FileSessionStore.cs @@ -9,15 +9,15 @@ namespace SharpClaw.Code.Sessions.Storage; /// /// Stores session snapshots as readable JSON files under the workspace. /// -public sealed class FileSessionStore(IFileSystem fileSystem, IPathService pathService) : ISessionStore +public sealed class FileSessionStore(IFileSystem fileSystem, IRuntimeStoragePathResolver storagePathResolver) : ISessionStore { /// public Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) { - var sessionsRoot = SessionStorageLayout.GetSessionsRoot(pathService, workspacePath); + var sessionsRoot = storagePathResolver.GetSessionsRoot(workspacePath); fileSystem.CreateDirectory(sessionsRoot); - var path = SessionStorageLayout.GetSessionSnapshotPath(pathService, workspacePath, session.Id); + var path = storagePathResolver.GetSessionSnapshotPath(workspacePath, session.Id); var json = JsonSerializer.Serialize(session, ProtocolJsonContext.Default.ConversationSession); return fileSystem.WriteAllTextAsync(path, json, cancellationToken); } @@ -25,7 +25,7 @@ public Task SaveAsync(string workspacePath, ConversationSession session, Cancell /// public async Task GetByIdAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) { - var path = SessionStorageLayout.GetSessionSnapshotPath(pathService, workspacePath, sessionId); + var path = storagePathResolver.GetSessionSnapshotPath(workspacePath, sessionId); var content = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); return string.IsNullOrWhiteSpace(content) ? null @@ -35,7 +35,7 @@ public Task SaveAsync(string workspacePath, ConversationSession session, Cancell /// public async Task GetLatestAsync(string workspacePath, CancellationToken cancellationToken) { - var sessionsRoot = SessionStorageLayout.GetSessionsRoot(pathService, workspacePath); + var sessionsRoot = storagePathResolver.GetSessionsRoot(workspacePath); if (!fileSystem.DirectoryExists(sessionsRoot)) { return null; @@ -44,7 +44,7 @@ public Task SaveAsync(string workspacePath, ConversationSession session, Cancell ConversationSession? latest = null; foreach (var sessionDirectory in fileSystem.EnumerateDirectories(sessionsRoot)) { - var sessionId = pathService.GetFileName(sessionDirectory); + var sessionId = Path.GetFileName(sessionDirectory); if (string.IsNullOrWhiteSpace(sessionId)) { continue; @@ -67,7 +67,7 @@ public Task SaveAsync(string workspacePath, ConversationSession session, Cancell /// public async Task> ListAllAsync(string workspacePath, CancellationToken cancellationToken) { - var sessionsRoot = SessionStorageLayout.GetSessionsRoot(pathService, workspacePath); + var sessionsRoot = storagePathResolver.GetSessionsRoot(workspacePath); if (!fileSystem.DirectoryExists(sessionsRoot)) { return []; @@ -76,7 +76,7 @@ public async Task> ListAllAsync(string worksp var list = new List(); foreach (var sessionDirectory in fileSystem.EnumerateDirectories(sessionsRoot)) { - var sessionId = pathService.GetFileName(sessionDirectory); + var sessionId = Path.GetFileName(sessionDirectory); if (string.IsNullOrWhiteSpace(sessionId)) { continue; diff --git a/src/SharpClaw.Code.Sessions/Storage/FileWorkspaceSessionAttachmentStore.cs b/src/SharpClaw.Code.Sessions/Storage/FileWorkspaceSessionAttachmentStore.cs index 8fec4fe..1ef730c 100644 --- a/src/SharpClaw.Code.Sessions/Storage/FileWorkspaceSessionAttachmentStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/FileWorkspaceSessionAttachmentStore.cs @@ -7,7 +7,7 @@ namespace SharpClaw.Code.Sessions.Storage; /// /// Persists .sharpclaw/active-session.json with an attached session id. /// -public sealed class FileWorkspaceSessionAttachmentStore(IFileSystem fileSystem, IPathService pathService) +public sealed class FileWorkspaceSessionAttachmentStore(IFileSystem fileSystem, IRuntimeStoragePathResolver storagePathResolver) : IWorkspaceSessionAttachmentStore { private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -15,7 +15,7 @@ public sealed class FileWorkspaceSessionAttachmentStore(IFileSystem fileSystem, /// public async Task GetAttachedSessionIdAsync(string workspacePath, CancellationToken cancellationToken) { - var path = SessionStorageLayout.GetWorkspaceActiveSessionPath(pathService, workspacePath); + var path = storagePathResolver.GetWorkspaceActiveSessionPath(workspacePath); var json = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(json)) { @@ -36,9 +36,9 @@ public sealed class FileWorkspaceSessionAttachmentStore(IFileSystem fileSystem, /// public Task SetAttachedSessionIdAsync(string workspacePath, string? sessionId, CancellationToken cancellationToken) { - var root = SessionStorageLayout.GetSharpClawRoot(pathService, workspacePath); + var root = storagePathResolver.GetSharpClawRoot(workspacePath); fileSystem.CreateDirectory(root); - var path = SessionStorageLayout.GetWorkspaceActiveSessionPath(pathService, workspacePath); + var path = storagePathResolver.GetWorkspaceActiveSessionPath(workspacePath); if (string.IsNullOrWhiteSpace(sessionId)) { fileSystem.TryDeleteFile(path); diff --git a/src/SharpClaw.Code.Sessions/Storage/HostAwareEventStore.cs b/src/SharpClaw.Code.Sessions/Storage/HostAwareEventStore.cs new file mode 100644 index 0000000..59bee4e --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/HostAwareEventStore.cs @@ -0,0 +1,29 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Selects the effective event backend from the current runtime host context. +/// +public sealed class HostAwareEventStore( + NdjsonEventStore fileEventStore, + SqliteEventStore sqliteEventStore, + IRuntimeHostContextAccessor hostContextAccessor) : IEventStore +{ + /// + public Task AppendAsync(string workspacePath, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + => ResolveStore().AppendAsync(workspacePath, sessionId, runtimeEvent, cancellationToken); + + /// + public Task> ReadAllAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + => ResolveStore().ReadAllAsync(workspacePath, sessionId, cancellationToken); + + private IEventStore ResolveStore() + => hostContextAccessor.Current?.SessionStoreKind == SessionStoreKind.Sqlite + ? sqliteEventStore + : fileEventStore; +} diff --git a/src/SharpClaw.Code.Sessions/Storage/HostAwareSessionStore.cs b/src/SharpClaw.Code.Sessions/Storage/HostAwareSessionStore.cs new file mode 100644 index 0000000..dbc8cc2 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/HostAwareSessionStore.cs @@ -0,0 +1,36 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Selects the effective session backend from the current runtime host context. +/// +public sealed class HostAwareSessionStore( + FileSessionStore fileSessionStore, + SqliteSessionStore sqliteSessionStore, + IRuntimeHostContextAccessor hostContextAccessor) : ISessionStore +{ + /// + public Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) + => ResolveStore().SaveAsync(workspacePath, session, cancellationToken); + + /// + public Task GetByIdAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + => ResolveStore().GetByIdAsync(workspacePath, sessionId, cancellationToken); + + /// + public Task GetLatestAsync(string workspacePath, CancellationToken cancellationToken) + => ResolveStore().GetLatestAsync(workspacePath, cancellationToken); + + /// + public Task> ListAllAsync(string workspacePath, CancellationToken cancellationToken) + => ResolveStore().ListAllAsync(workspacePath, cancellationToken); + + private ISessionStore ResolveStore() + => hostContextAccessor.Current?.SessionStoreKind == SessionStoreKind.Sqlite + ? sqliteSessionStore + : fileSessionStore; +} diff --git a/src/SharpClaw.Code.Sessions/Storage/NdjsonEventStore.cs b/src/SharpClaw.Code.Sessions/Storage/NdjsonEventStore.cs index 7fb605d..c549a2f 100644 --- a/src/SharpClaw.Code.Sessions/Storage/NdjsonEventStore.cs +++ b/src/SharpClaw.Code.Sessions/Storage/NdjsonEventStore.cs @@ -11,20 +11,20 @@ namespace SharpClaw.Code.Sessions.Storage; /// /// Stores session runtime events in append-only NDJSON files. /// -public sealed class NdjsonEventStore(IFileSystem fileSystem, IPathService pathService, ILogger? logger = null) : IEventStore +public sealed class NdjsonEventStore(IFileSystem fileSystem, IRuntimeStoragePathResolver storagePathResolver, ILogger? logger = null) : IEventStore { /// public Task AppendAsync(string workspacePath, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(runtimeEvent, ProtocolJsonContext.Default.RuntimeEvent); - var path = SessionStorageLayout.GetEventsPath(pathService, workspacePath, sessionId); + var path = storagePathResolver.GetEventsPath(workspacePath, sessionId); return fileSystem.AppendLineAsync(path, json, cancellationToken); } /// public async Task> ReadAllAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) { - var path = SessionStorageLayout.GetEventsPath(pathService, workspacePath, sessionId); + var path = storagePathResolver.GetEventsPath(workspacePath, sessionId); var lines = await fileSystem.ReadAllLinesIfExistsAsync(path, cancellationToken).ConfigureAwait(false); var events = new List(lines.Length); diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteEventStore.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteEventStore.cs new file mode 100644 index 0000000..b8e6ba9 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteEventStore.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Stores append-only runtime events in the shared SQLite session catalog. +/// +public sealed class SqliteEventStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver) : IEventStore +{ + /// + public async Task AppendAsync(string workspacePath, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(runtimeEvent); + + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO runtime_events(session_id, occurred_at_utc, event_type, payload_json) + VALUES ($sessionId, $occurredAtUtc, $eventType, $payloadJson); + """; + command.Parameters.AddWithValue("$sessionId", sessionId); + command.Parameters.AddWithValue("$occurredAtUtc", runtimeEvent.OccurredAtUtc.ToString("O")); + command.Parameters.AddWithValue("$eventType", runtimeEvent.GetType().Name); + command.Parameters.AddWithValue("$payloadJson", JsonSerializer.Serialize(runtimeEvent, ProtocolJsonContext.Default.RuntimeEvent)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> ReadAllAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT payload_json + FROM runtime_events + WHERE session_id = $sessionId + ORDER BY sequence ASC; + """; + command.Parameters.AddWithValue("$sessionId", sessionId); + + var events = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (reader.IsDBNull(0)) + { + continue; + } + + var runtimeEvent = JsonSerializer.Deserialize(reader.GetString(0), ProtocolJsonContext.Default.RuntimeEvent); + if (runtimeEvent is not null) + { + events.Add(runtimeEvent); + } + } + + return events; + } +} diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs new file mode 100644 index 0000000..fe12f84 --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStore.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +/// +/// Stores core session snapshots in a SQLite catalog for embedded and hosted scenarios. +/// +public sealed class SqliteSessionStore( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver) : ISessionStore +{ + /// + public async Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO sessions(session_id, updated_at_utc, payload_json) + VALUES ($sessionId, $updatedAtUtc, $payloadJson) + ON CONFLICT(session_id) DO UPDATE SET + updated_at_utc = excluded.updated_at_utc, + payload_json = excluded.payload_json; + """; + command.Parameters.AddWithValue("$sessionId", session.Id); + command.Parameters.AddWithValue("$updatedAtUtc", session.UpdatedAtUtc.ToString("O")); + command.Parameters.AddWithValue("$payloadJson", JsonSerializer.Serialize(session, ProtocolJsonContext.Default.ConversationSession)); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetByIdAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT payload_json FROM sessions WHERE session_id = $sessionId LIMIT 1;"; + command.Parameters.AddWithValue("$sessionId", sessionId); + var payload = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; + return Deserialize(payload); + } + + /// + public async Task GetLatestAsync(string workspacePath, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT payload_json + FROM sessions + ORDER BY updated_at_utc DESC + LIMIT 1; + """; + var payload = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string; + return Deserialize(payload); + } + + /// + public async Task> ListAllAsync(string workspacePath, CancellationToken cancellationToken) + { + await using var connection = await SqliteSessionStoreDatabase + .OpenConnectionAsync(fileSystem, storagePathResolver, workspacePath, cancellationToken) + .ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT payload_json + FROM sessions + ORDER BY updated_at_utc DESC; + """; + + var sessions = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var payload = reader.IsDBNull(0) ? null : reader.GetString(0); + var session = Deserialize(payload); + if (session is not null) + { + sessions.Add(session); + } + } + + return sessions; + } + + private static ConversationSession? Deserialize(string? payload) + => string.IsNullOrWhiteSpace(payload) + ? null + : JsonSerializer.Deserialize(payload, ProtocolJsonContext.Default.ConversationSession); +} diff --git a/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs new file mode 100644 index 0000000..86e4c2b --- /dev/null +++ b/src/SharpClaw.Code.Sessions/Storage/SqliteSessionStoreDatabase.cs @@ -0,0 +1,55 @@ +using Microsoft.Data.Sqlite; +using SharpClaw.Code.Infrastructure.Abstractions; + +namespace SharpClaw.Code.Sessions.Storage; + +internal static class SqliteSessionStoreDatabase +{ + public static async Task OpenConnectionAsync( + IFileSystem fileSystem, + IRuntimeStoragePathResolver storagePathResolver, + string workspacePath, + CancellationToken cancellationToken) + { + var dbPath = storagePathResolver.GetSessionStoreDatabasePath(workspacePath); + var directory = Path.GetDirectoryName(dbPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + fileSystem.CreateDirectory(directory); + } + + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + }.ToString()); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + + private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + const string sql = """ + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + updated_at_utc TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_sessions_updated_at_utc ON sessions(updated_at_utc DESC); + + CREATE TABLE IF NOT EXISTS runtime_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + occurred_at_utc TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_runtime_events_session_sequence ON runtime_events(session_id, sequence); + """; + + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventSink.cs b/src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventSink.cs new file mode 100644 index 0000000..4b2bea1 --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventSink.cs @@ -0,0 +1,14 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Telemetry.Abstractions; + +/// +/// Receives normalized runtime event envelopes for external streaming or integration. +/// +public interface IRuntimeEventSink +{ + /// + /// Publishes one runtime event envelope to the sink. + /// + Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventStream.cs b/src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventStream.cs new file mode 100644 index 0000000..c69be9a --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Abstractions/IRuntimeEventStream.cs @@ -0,0 +1,19 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Telemetry.Abstractions; + +/// +/// Exposes an in-process stream of runtime event envelopes for embedded hosts and admin APIs. +/// +public interface IRuntimeEventStream +{ + /// + /// Returns recent event envelopes retained in memory. + /// + IReadOnlyList GetRecentEnvelopesSnapshot(); + + /// + /// Streams event envelopes as they are published. + /// + IAsyncEnumerable StreamAsync(CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Telemetry/RuntimeEventPublishOptions.cs b/src/SharpClaw.Code.Telemetry/RuntimeEventPublishOptions.cs index 42ae75f..f969639 100644 --- a/src/SharpClaw.Code.Telemetry/RuntimeEventPublishOptions.cs +++ b/src/SharpClaw.Code.Telemetry/RuntimeEventPublishOptions.cs @@ -7,11 +7,13 @@ namespace SharpClaw.Code.Telemetry; /// Session id when persisting; may be system for non-session emissions. /// When and paths are set, delegates durable append to the registered persistence bridge (session event store). /// When , persistence exceptions propagate after logging. +/// Optional embedded host and tenant context for streaming sinks. public sealed record RuntimeEventPublishOptions( string? WorkspacePath = null, string? SessionId = null, bool PersistToSessionStore = false, - bool ThrowIfPersistenceFails = false) + bool ThrowIfPersistenceFails = false, + SharpClaw.Code.Protocol.Models.RuntimeHostContext? HostContext = null) { /// /// Gets a value indicating whether the session persistence bridge should be invoked. diff --git a/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs b/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs new file mode 100644 index 0000000..9a910d1 --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry.Abstractions; + +namespace SharpClaw.Code.Telemetry.Services; + +/// +/// In-process fan-out stream for embedded hosts and admin APIs. +/// +public sealed class InProcessRuntimeEventStream(IOptions telemetryOptionsAccessor) : IRuntimeEventSink, IRuntimeEventStream +{ + private readonly ConcurrentQueue recent = new(); + private readonly ConcurrentDictionary> subscribers = new(); + private readonly int capacity = Math.Max(64, telemetryOptionsAccessor.Value.RuntimeEventRingBufferCapacity); + + /// + public Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + { + _ = cancellationToken; + recent.Enqueue(envelope); + while (recent.Count > capacity && recent.TryDequeue(out _)) + { + } + + foreach (var channel in subscribers.Values) + { + channel.Writer.TryWrite(envelope); + } + + return Task.CompletedTask; + } + + /// + public IReadOnlyList GetRecentEnvelopesSnapshot() + => recent.ToArray(); + + /// + public async IAsyncEnumerable StreamAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var id = Guid.NewGuid(); + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + subscribers[id] = channel; + + try + { + foreach (var envelope in recent) + { + yield return envelope; + } + + while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + while (channel.Reader.TryRead(out var envelope)) + { + yield return envelope; + } + } + } + finally + { + subscribers.TryRemove(id, out _); + channel.Writer.TryComplete(); + } + } +} diff --git a/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs b/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs index 588446c..d1a4a5a 100644 --- a/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs +++ b/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Telemetry.Abstractions; @@ -16,6 +17,8 @@ public sealed class RuntimeEventPublisher : IRuntimeEventPublisher private readonly IUsageTracker usageTracker; private readonly ILogger logger; private readonly IRuntimeEventPersistence? persistence; + private readonly IRuntimeHostContextAccessor? hostContextAccessor; + private readonly IRuntimeEventSink[] sinks; private readonly object bufferLock = new(); private readonly List buffer = []; @@ -30,12 +33,16 @@ public RuntimeEventPublisher( IOptions telemetryOptionsAccessor, IUsageTracker usageTracker, ILogger? logger = null, - IRuntimeEventPersistence? persistence = null) + IRuntimeEventPersistence? persistence = null, + IRuntimeHostContextAccessor? hostContextAccessor = null, + IEnumerable? sinks = null) { telemetryOptions = telemetryOptionsAccessor.Value; this.usageTracker = usageTracker; this.logger = logger ?? NullLogger.Instance; this.persistence = persistence; + this.hostContextAccessor = hostContextAccessor; + this.sinks = sinks?.ToArray() ?? []; } /// @@ -89,6 +96,23 @@ await persistence "Runtime event {EventId} requested session persistence but no IRuntimeEventPersistence is registered.", runtimeEvent.EventId); } + + if (sinks.Length > 0) + { + var hostContext = routing.HostContext ?? hostContextAccessor?.Current; + var envelope = new RuntimeEventEnvelope( + EventType: runtimeEvent.GetType().Name, + OccurredAtUtc: runtimeEvent.OccurredAtUtc, + Event: runtimeEvent, + WorkspacePath: routing.WorkspacePath, + SessionId: routing.SessionId ?? runtimeEvent.SessionId, + TenantId: hostContext?.TenantId, + HostId: hostContext?.HostId); + foreach (var sink in sinks) + { + await sink.PublishAsync(envelope, cancellationToken).ConfigureAwait(false); + } + } } /// diff --git a/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs b/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs new file mode 100644 index 0000000..ce565f9 --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; + +namespace SharpClaw.Code.Telemetry.Services; + +/// +/// Delivers runtime event envelopes to configured webhook endpoints. +/// +public sealed class WebhookRuntimeEventSink( + IOptions telemetryOptionsAccessor, + ILogger? logger = null) : IRuntimeEventSink +{ + private readonly TelemetryOptions telemetryOptions = telemetryOptionsAccessor.Value; + private readonly ILogger logger = logger ?? NullLogger.Instance; + private readonly HttpClient httpClient = new() + { + Timeout = TimeSpan.FromSeconds(5), + }; + + /// + public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + { + if (telemetryOptions.EventWebhookUrls.Count == 0) + { + return; + } + + var payload = JsonSerializer.Serialize(envelope, ProtocolJsonContext.Default.RuntimeEventEnvelope); + foreach (var url in telemetryOptions.EventWebhookUrls) + { + try + { + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var response = await httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to post runtime event {EventId} to webhook {WebhookUrl}.", envelope.Event.EventId, url); + } + } + } +} diff --git a/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs b/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs index 07077fc..b954c90 100644 --- a/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs +++ b/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs @@ -9,4 +9,9 @@ public sealed class TelemetryOptions /// Maximum number of instances retained in the ring buffer. /// public int RuntimeEventRingBufferCapacity { get; set; } = 10_000; + + /// + /// Optional webhook destinations that receive normalized runtime event envelopes. + /// + public List EventWebhookUrls { get; } = []; } diff --git a/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs b/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs index 31ac46b..7e6f102 100644 --- a/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Telemetry.Abstractions; using SharpClaw.Code.Telemetry.Export; using SharpClaw.Code.Telemetry.Services; @@ -48,11 +49,25 @@ private static IServiceCollection AddSharpClawTelemetryCore(IServiceCollection s services.TryAddSingleton, TelemetryOptionsValidator>(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + if (!services.Any(static descriptor => descriptor.ServiceType == typeof(IRuntimeEventSink) && descriptor.ImplementationFactory is not null)) + { + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + } + + if (!services.Any(static descriptor => descriptor.ServiceType == typeof(IRuntimeEventSink) && descriptor.ImplementationType == typeof(WebhookRuntimeEventSink))) + { + services.AddSingleton(); + } + services.TryAddSingleton(serviceProvider => new RuntimeEventPublisher( serviceProvider.GetRequiredService>(), serviceProvider.GetRequiredService(), serviceProvider.GetService>(), - serviceProvider.GetService())); + serviceProvider.GetService(), + serviceProvider.GetService(), + serviceProvider.GetServices())); return services; } } diff --git a/src/SharpClaw.Code.Tools/Abstractions/IToolPackageService.cs b/src/SharpClaw.Code.Tools/Abstractions/IToolPackageService.cs new file mode 100644 index 0000000..4ce0760 --- /dev/null +++ b/src/SharpClaw.Code.Tools/Abstractions/IToolPackageService.cs @@ -0,0 +1,22 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Tools.Abstractions; + +/// +/// Manages packaged third-party tool manifests installed for a workspace. +/// +public interface IToolPackageService +{ + /// + /// Lists installed tool packages for the workspace. + /// + Task> ListInstalledAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Installs a tool package manifest and optionally enables its plugin-backed tool surface. + /// + Task InstallAsync( + string workspaceRoot, + ToolPackageInstallRequest request, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs b/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs new file mode 100644 index 0000000..d9fdcdd --- /dev/null +++ b/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs @@ -0,0 +1,161 @@ +using System.Text; +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Plugins.Abstractions; +using SharpClaw.Code.Plugins.Models; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Tools.Abstractions; + +namespace SharpClaw.Code.Tools.Services; + +/// +/// Persists packaged-tool manifests and maps them onto the existing plugin execution pipeline. +/// +public sealed class ToolPackageService( + IFileSystem fileSystem, + IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver, + IPluginManager pluginManager) : IToolPackageService +{ + /// + public async Task> ListInstalledAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var packagesRoot = storagePathResolver.GetToolPackagesRoot(normalizedWorkspace); + if (!fileSystem.DirectoryExists(packagesRoot)) + { + return []; + } + + var packages = new List(); + foreach (var manifestPath in fileSystem.EnumerateFiles(packagesRoot, "*.json")) + { + var content = await fileSystem.ReadAllTextIfExistsAsync(manifestPath, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + var installed = JsonSerializer.Deserialize(content, ProtocolJsonContext.Default.InstalledToolPackage); + if (installed is not null) + { + packages.Add(installed); + } + } + + return packages + .OrderByDescending(static package => package.InstalledAtUtc) + .ThenBy(static package => package.Manifest.Package.PackageId, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + /// + public async Task InstallAsync( + string workspaceRoot, + ToolPackageInstallRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + Validate(request.Manifest); + + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var packagesRoot = storagePathResolver.GetToolPackagesRoot(normalizedWorkspace); + fileSystem.CreateDirectory(packagesRoot); + + var installed = new InstalledToolPackage( + Manifest: request.Manifest, + InstalledAtUtc: DateTimeOffset.UtcNow, + InstallSource: request.InstallSource.Trim()); + var path = pathService.Combine(packagesRoot, $"{SanitizeFileName(request.Manifest.Package.PackageId)}.json"); + await fileSystem + .WriteAllTextAsync( + path, + JsonSerializer.Serialize(installed, ProtocolJsonContext.Default.InstalledToolPackage), + cancellationToken) + .ConfigureAwait(false); + + var pluginManifest = ToPluginManifest(request.Manifest); + await pluginManager + .InstallAsync( + normalizedWorkspace, + new PluginInstallRequest( + pluginManifest, + JsonSerializer.Serialize(request.Manifest, ProtocolJsonContext.Default.ToolPackageManifest)), + cancellationToken) + .ConfigureAwait(false); + if (request.EnableAfterInstall) + { + await pluginManager.EnableAsync(normalizedWorkspace, pluginManifest.Id, cancellationToken).ConfigureAwait(false); + } + + return installed; + } + + private static PluginManifest ToPluginManifest(ToolPackageManifest manifest) + => new( + Id: manifest.Package.PackageId, + Name: manifest.Package.PackageId, + Version: manifest.Package.Version, + Description: manifest.Description, + EntryPoint: manifest.Package.EntryAssembly, + Arguments: [], + Capabilities: + [ + "tool-package", + manifest.Package.PackageType, + ], + Tools: manifest.Tools.Select(tool => new PluginToolDescriptor( + Name: tool.Name, + Description: tool.Description, + InputDescription: string.IsNullOrWhiteSpace(tool.InputSchemaJson) ? "{}" : tool.InputSchemaJson!, + Tags: tool.Tags ?? manifest.Package.Tags, + IsDestructive: tool.IsDestructive, + RequiresApproval: tool.RequiresApproval, + SourcePluginId: manifest.Package.PackageId, + InputTypeName: "json", + InputSchemaJson: tool.InputSchemaJson, + Trust: ResolveTrust(manifest.Package.PackageType))).ToArray(), + Trust: ResolveTrust(manifest.Package.PackageType), + PublisherId: manifest.PublisherId, + SignatureHint: $"{manifest.Package.PackageType}:{manifest.Package.PackageId}:{manifest.Package.Version}"); + + private static PluginTrustLevel ResolveTrust(string packageType) + => string.Equals(packageType, "local", StringComparison.OrdinalIgnoreCase) + ? PluginTrustLevel.WorkspaceTrusted + : PluginTrustLevel.Untrusted; + + private static void Validate(ToolPackageManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Package.PackageId); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Package.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Package.EntryAssembly); + if (manifest.Tools.Length == 0) + { + throw new InvalidOperationException("Tool packages must declare at least one tool."); + } + + var duplicate = manifest.Tools + .GroupBy(static tool => tool.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException($"Tool package '{manifest.Package.PackageId}' declares duplicate tool '{duplicate.Key}'."); + } + } + + private static string SanitizeFileName(string value) + { + var invalid = Path.GetInvalidFileNameChars().ToHashSet(); + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(invalid.Contains(character) ? '-' : character); + } + + var result = builder.ToString().Trim(); + return string.IsNullOrWhiteSpace(result) ? "tool-package" : result; + } +} diff --git a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs index 261d250..992c2cc 100644 --- a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using SharpClaw.Code.Tools.BuiltIn; using SharpClaw.Code.Tools.Execution; using SharpClaw.Code.Tools.Registry; +using SharpClaw.Code.Tools.Services; using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Abstractions; using SharpClaw.Code.Web; @@ -89,6 +90,7 @@ private static IServiceCollection AddSharpClawToolsCore(IServiceCollection servi serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), serviceProvider.GetService())); + services.AddSingleton(); return services; } } diff --git a/src/SharpClaw.Code/SharpClaw.Code.csproj b/src/SharpClaw.Code/SharpClaw.Code.csproj new file mode 100644 index 0000000..9eff916 --- /dev/null +++ b/src/SharpClaw.Code/SharpClaw.Code.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + Embeddable SharpClaw host SDK for runtime, ACP, and admin surfaces. + + + + + + + + + + + + + + diff --git a/src/SharpClaw.Code/SharpClawRuntimeHost.cs b/src/SharpClaw.Code/SharpClawRuntimeHost.cs new file mode 100644 index 0000000..22a7583 --- /dev/null +++ b/src/SharpClaw.Code/SharpClawRuntimeHost.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SharpClaw.Code.Acp; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code; + +/// +/// Wraps an embeddable SharpClaw runtime host and exposes typed runtime entry points. +/// +public sealed class SharpClawRuntimeHost : IAsyncDisposable +{ + private readonly IHost host; + private readonly IConversationRuntime conversationRuntime; + private readonly IRuntimeCommandService runtimeCommandService; + private readonly IWorkspaceHttpServer workspaceHttpServer; + private readonly AcpStdioHost acpStdioHost; + private readonly IRuntimeHostContextAccessor hostContextAccessor; + + internal SharpClawRuntimeHost(IHost host) + { + this.host = host; + conversationRuntime = host.Services.GetRequiredService(); + runtimeCommandService = host.Services.GetRequiredService(); + workspaceHttpServer = host.Services.GetRequiredService(); + acpStdioHost = host.Services.GetRequiredService(); + hostContextAccessor = host.Services.GetRequiredService(); + } + + /// + /// Gets the root service provider for advanced host integrations. + /// + public IServiceProvider Services => host.Services; + + /// + /// Starts hosted services registered with the embedded runtime. + /// + public Task StartAsync(CancellationToken cancellationToken = default) + => host.StartAsync(cancellationToken); + + /// + /// Stops hosted services registered with the embedded runtime. + /// + public Task StopAsync(CancellationToken cancellationToken = default) + => host.StopAsync(cancellationToken); + + /// + /// Creates a durable session under the supplied host context. + /// + public Task CreateSessionAsync( + string workspacePath, + PermissionMode permissionMode, + OutputFormat outputFormat, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken = default) + => ExecuteInHostContextAsync(hostContext, () => conversationRuntime.CreateSessionAsync(workspacePath, permissionMode, outputFormat, cancellationToken)); + + /// + /// Gets a session snapshot by identifier under the supplied host context. + /// + public Task GetSessionAsync( + string workspacePath, + string sessionId, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken = default) + => ExecuteInHostContextAsync(hostContext, () => conversationRuntime.GetSessionAsync(workspacePath, sessionId, cancellationToken)); + + /// + /// Gets the latest session for a workspace under the supplied host context. + /// + public Task GetLatestSessionAsync( + string workspacePath, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken = default) + => ExecuteInHostContextAsync(hostContext, () => conversationRuntime.GetLatestSessionAsync(workspacePath, cancellationToken)); + + /// + /// Forks a session under the supplied host context. + /// + public Task ForkSessionAsync( + string workspacePath, + string? sourceSessionId, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken = default) + => ExecuteInHostContextAsync(hostContext, () => conversationRuntime.ForkSessionAsync(workspacePath, sourceSessionId, cancellationToken)); + + /// + /// Executes a prompt through the runtime command service. + /// + public Task ExecutePromptAsync( + string prompt, + RuntimeCommandContext context, + CancellationToken cancellationToken = default) + => runtimeCommandService.ExecutePromptAsync(prompt, context, cancellationToken); + + /// + /// Retrieves the runtime status report. + /// + public Task GetStatusAsync(RuntimeCommandContext context, CancellationToken cancellationToken = default) + => runtimeCommandService.GetStatusAsync(context, cancellationToken); + + /// + /// Runs the runtime doctor checks. + /// + public Task RunDoctorAsync(RuntimeCommandContext context, CancellationToken cancellationToken = default) + => runtimeCommandService.RunDoctorAsync(context, cancellationToken); + + /// + /// Lists sessions for the current workspace context. + /// + public Task ListSessionsAsync(RuntimeCommandContext context, CancellationToken cancellationToken = default) + => runtimeCommandService.ListSessionsAsync(context, cancellationToken); + + /// + /// Starts the embedded HTTP server for the supplied runtime context. + /// + public Task RunHttpServerAsync( + string workspaceRoot, + string? hostName, + int? port, + RuntimeCommandContext context, + CancellationToken cancellationToken = default) + => workspaceHttpServer.RunAsync(workspaceRoot, hostName, port, context, cancellationToken); + + /// + /// Runs the ACP stdio loop on the supplied streams. + /// + public Task RunAcpAsync(TextReader stdin, TextWriter stdout, CancellationToken cancellationToken = default) + => acpStdioHost.RunAsync(stdin, stdout, cancellationToken); + + /// + public async ValueTask DisposeAsync() + { + await host.StopAsync().ConfigureAwait(false); + host.Dispose(); + } + + private async Task ExecuteInHostContextAsync(RuntimeHostContext? hostContext, Func> action) + { + using var scope = hostContextAccessor.BeginScope(hostContext); + return await action().ConfigureAwait(false); + } +} diff --git a/src/SharpClaw.Code/SharpClawRuntimeHostBuilder.cs b/src/SharpClaw.Code/SharpClawRuntimeHostBuilder.cs new file mode 100644 index 0000000..442d3c0 --- /dev/null +++ b/src/SharpClaw.Code/SharpClawRuntimeHostBuilder.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SharpClaw.Code.Acp; +using SharpClaw.Code.Runtime; + +namespace SharpClaw.Code; + +/// +/// Builds an embeddable SharpClaw host without pulling in the CLI command surface. +/// +public sealed class SharpClawRuntimeHostBuilder +{ + private readonly HostApplicationBuilder builder; + private bool runtimeRegistered; + private bool acpRegistered; + + /// + /// Initializes a new builder for an embeddable SharpClaw runtime host. + /// + public SharpClawRuntimeHostBuilder(string[]? args = null) + { + builder = Host.CreateApplicationBuilder(args ?? []); + } + + /// + /// Gets the underlying configuration root for additional host customization. + /// + public IConfigurationManager Configuration => builder.Configuration; + + /// + /// Gets the service collection for additional registrations. + /// + public IServiceCollection Services => builder.Services; + + /// + /// Configures the runtime services with configuration-backed providers. + /// + public SharpClawRuntimeHostBuilder AddRuntime() + { + if (!runtimeRegistered) + { + builder.Services.AddSharpClawRuntime(builder.Configuration); + runtimeRegistered = true; + } + + return this; + } + + /// + /// Adds ACP hosting support for editor subprocess integrations. + /// + public SharpClawRuntimeHostBuilder AddAcp() + { + if (!acpRegistered) + { + builder.Services.AddSharpClawAcp(); + acpRegistered = true; + } + + return this; + } + + /// + /// Applies additional host customization before build. + /// + public SharpClawRuntimeHostBuilder Configure(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + configure(builder); + return this; + } + + /// + /// Builds the embeddable host wrapper. + /// + public SharpClawRuntimeHost Build() + { + AddRuntime(); + AddAcp(); + return new SharpClawRuntimeHost(builder.Build()); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/EmbeddedRuntimeHostTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/EmbeddedRuntimeHostTests.cs new file mode 100644 index 0000000..1d99998 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/EmbeddedRuntimeHostTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.MockProvider; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Runtime; + +/// +/// Verifies the embeddable host SDK and tenant-aware SQLite session isolation. +/// +public sealed class EmbeddedRuntimeHostTests +{ + /// + /// Ensures embedded hosts can partition durable SQLite session state by tenant. + /// + [Fact] + public async Task Embedded_host_should_isolate_tenants_and_use_sqlite_session_store() + { + var workspaceRoot = CreateTemporaryDirectory("sharpclaw-embedded-workspace"); + var storageRoot = CreateTemporaryDirectory("sharpclaw-embedded-storage"); + + await using var host = new SharpClawRuntimeHostBuilder() + .Configure(builder => builder.Services.AddDeterministicMockModelProvider()) + .Build(); + await host.StartAsync(); + + var tenantA = new RuntimeHostContext("embedded-host", "tenant-a", storageRoot, SessionStoreKind.Sqlite, true); + var tenantB = new RuntimeHostContext("embedded-host", "tenant-b", storageRoot, SessionStoreKind.Sqlite, true); + var contextA = new SharpClaw.Code.Runtime.Abstractions.RuntimeCommandContext( + WorkingDirectory: workspaceRoot, + Model: "default", + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Json, + HostContext: tenantA); + var contextB = contextA with { HostContext = tenantB }; + + var first = await host.ExecutePromptAsync("inspect tenant A", contextA, CancellationToken.None); + var second = await host.ExecutePromptAsync("inspect tenant B", contextB, CancellationToken.None); + + first.Session.Id.Should().NotBe(second.Session.Id); + (await host.GetLatestSessionAsync(workspaceRoot, tenantA, CancellationToken.None)).Should().NotBeNull(); + (await host.GetLatestSessionAsync(workspaceRoot, tenantB, CancellationToken.None)).Should().NotBeNull(); + (await host.GetSessionAsync(workspaceRoot, first.Session.Id, tenantB, CancellationToken.None)).Should().BeNull(); + + var storagePathResolver = host.Services.GetRequiredService(); + var hostContextAccessor = host.Services.GetRequiredService(); + + string tenantADbPath; + using (hostContextAccessor.BeginScope(tenantA)) + { + tenantADbPath = storagePathResolver.GetSessionStoreDatabasePath(workspaceRoot); + } + + string tenantBDbPath; + using (hostContextAccessor.BeginScope(tenantB)) + { + tenantBDbPath = storagePathResolver.GetSessionStoreDatabasePath(workspaceRoot); + } + + File.Exists(tenantADbPath).Should().BeTrue(); + File.Exists(tenantBDbPath).Should().BeTrue(); + tenantADbPath.Should().NotBe(tenantBDbPath); + tenantADbPath.Should().Contain("tenant-a"); + tenantBDbPath.Should().Contain("tenant-b"); + } + + private static string CreateTemporaryDirectory(string prefix) + { + var path = Path.Combine(Path.GetTempPath(), prefix, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs index f05a0df..474fec1 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/PromptContextAssemblyTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.Git.Abstractions; using SharpClaw.Code.Git.Models; @@ -75,7 +76,8 @@ public async Task RunPrompt_should_include_memory_skills_and_git_context_in_prov public async Task RunPrompt_should_reuse_in_process_conversation_history_cache() { var workspacePath = CreateTemporaryWorkspace(); - var countingEventStore = new CountingEventStore(new NdjsonEventStore(new LocalFileSystem(), new PathService())); + var pathService = new PathService(); + var countingEventStore = new CountingEventStore(new NdjsonEventStore(new LocalFileSystem(), CreateStoragePathResolver(workspacePath, pathService))); var services = new ServiceCollection(); services.AddSharpClawRuntime(); services.AddSingleton(new StubProjectMemoryService()); @@ -129,6 +131,9 @@ private static string CreateTemporaryWorkspace() return workspacePath; } + private static IRuntimeStoragePathResolver CreateStoragePathResolver(string root, IPathService pathService) + => new RuntimeStoragePathResolver(pathService, new FixedUserProfilePaths(root, pathService), new RuntimeHostContextAccessor()); + private sealed class StubProjectMemoryService : IProjectMemoryService { public Task BuildContextAsync(string workspaceRoot, CancellationToken cancellationToken) @@ -140,6 +145,18 @@ public Task BuildContextAsync(string workspaceRoot, Cancel })); } + private sealed class FixedUserProfilePaths(string root, IPathService pathService) : IUserProfilePaths + { + public string GetUserCustomCommandsDirectory() + => pathService.Combine(root, "commands"); + + public string GetUserHomeDirectory() + => root; + + public string GetUserSharpClawRoot() + => root; + } + private sealed class StubSessionSummaryService : ISessionSummaryService { public Task BuildSummaryAsync(ConversationSession session, CancellationToken cancellationToken) diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs new file mode 100644 index 0000000..d78f698 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs @@ -0,0 +1,151 @@ +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.MockProvider; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Runtime; + +/// +/// Verifies the embedded admin HTTP surface for provider, index, package, and event inspection. +/// +public sealed class WorkspaceHttpServerAdminTests +{ + /// + /// Ensures the admin server exposes provider catalog, index, package, and recent-event payloads. + /// + [Fact] + public async Task Admin_endpoints_should_expose_provider_index_package_and_event_data() + { + var workspaceRoot = CreateTemporaryWorkspace(); + await File.WriteAllTextAsync(Path.Combine(workspaceRoot, "README.md"), "Workspace admin search content."); + + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddDeterministicMockModelProvider(); + using var serviceProvider = services.BuildServiceProvider(); + + var server = serviceProvider.GetRequiredService(); + var port = FindFreePort(); + using var serverCts = new CancellationTokenSource(); + var serverTask = server.RunAsync( + workspaceRoot, + "127.0.0.1", + port, + new SharpClaw.Code.Runtime.Abstractions.RuntimeCommandContext( + WorkingDirectory: workspaceRoot, + Model: "default", + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Json), + serverCts.Token); + + try + { + using var httpClient = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{port}/"), + Timeout = TimeSpan.FromSeconds(10), + }; + await WaitForServerAsync(httpClient, CancellationToken.None); + + var providersJson = await httpClient.GetStringAsync("v1/admin/providers"); + providersJson.Should().Contain("mock"); + + using var refreshResponse = await httpClient.PostAsync("v1/admin/index/refresh", new StringContent(string.Empty), CancellationToken.None); + refreshResponse.EnsureSuccessStatusCode(); + var refresh = JsonSerializer.Deserialize( + await refreshResponse.Content.ReadAsStringAsync(), + ProtocolJsonContext.Default.WorkspaceIndexRefreshResult); + refresh.Should().NotBeNull(); + refresh!.IndexedFileCount.Should().BeGreaterThan(0); + + var installRequest = new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.echo", "1.0.0", "local", "echo-tool"), + "acme", + "Echo tools", + [new PackagedToolDescriptor("echo_tool", "Echoes content", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false); + using var installResponse = await httpClient.PostAsync( + "v1/admin/tool-packages/install", + new StringContent(JsonSerializer.Serialize(installRequest, ProtocolJsonContext.Default.ToolPackageInstallRequest), Encoding.UTF8, "application/json"), + CancellationToken.None); + installResponse.EnsureSuccessStatusCode(); + var installed = JsonSerializer.Deserialize( + await installResponse.Content.ReadAsStringAsync(), + ProtocolJsonContext.Default.InstalledToolPackage); + installed.Should().NotBeNull(); + installed!.Manifest.Package.PackageId.Should().Be("acme.echo"); + + var packageListJson = await httpClient.GetStringAsync("v1/admin/tool-packages"); + packageListJson.Should().Contain("acme.echo"); + + using var promptResponse = await httpClient.PostAsync( + "v1/prompt", + new StringContent("""{"prompt":"run the admin server flow","model":"default"}""", Encoding.UTF8, "application/json"), + CancellationToken.None); + promptResponse.EnsureSuccessStatusCode(); + var promptResult = JsonSerializer.Deserialize( + await promptResponse.Content.ReadAsStringAsync(), + ProtocolJsonContext.Default.TurnExecutionResult); + promptResult.Should().NotBeNull(); + + var events = JsonSerializer.Deserialize( + await httpClient.GetStringAsync("v1/admin/events/recent"), + ProtocolJsonContext.Default.ListRuntimeEventEnvelope); + events.Should().NotBeNull(); + events!.Should().Contain(envelope => envelope.EventType == nameof(SharpClaw.Code.Protocol.Events.TurnCompletedEvent)); + } + finally + { + serverCts.Cancel(); + await serverTask; + } + } + + private static async Task WaitForServerAsync(HttpClient httpClient, CancellationToken cancellationToken) + { + var attempts = 0; + while (attempts++ < 20) + { + try + { + using var response = await httpClient.GetAsync("v1/status", cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + } + catch (HttpRequestException) + { + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException("Embedded workspace HTTP server did not become ready."); + } + + private static int FindFreePort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + + private static string CreateTemporaryWorkspace() + { + var path = Path.Combine(Path.GetTempPath(), "sharpclaw-admin-server", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj b/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj index 4249ae4..df45c4d 100644 --- a/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj +++ b/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj @@ -18,6 +18,7 @@ + @@ -26,4 +27,4 @@ - \ No newline at end of file + diff --git a/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs b/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs index 265f7cb..8f6b471 100644 --- a/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs +++ b/tests/SharpClaw.Code.UnitTests/McpPlugins/McpAndPluginLifecycleTests.cs @@ -11,6 +11,7 @@ using SharpClaw.Code.Plugins.Services; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.McpPlugins; @@ -107,6 +108,7 @@ private static PluginManager CreatePluginManager() new PluginManifestValidator(), new LocalFileSystem(), new PathService(), + TestRuntimeStorageResolver.Create(CreateTemporaryWorkspace()), new FixedClock()); /// diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs index 1827ced..b140a52 100644 --- a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs @@ -4,6 +4,7 @@ using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Memory.Services; using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.MemorySkillsGit; @@ -112,17 +113,5 @@ public void Dispose() } private IWorkspaceKnowledgeStore CreateStore() - => new SqliteWorkspaceKnowledgeStore(fileSystem, pathService, new TestUserProfilePaths(userRoot, pathService)); - - private sealed class TestUserProfilePaths(string root, IPathService pathService) : IUserProfilePaths - { - public string GetUserCustomCommandsDirectory() - => pathService.Combine(root, "commands"); - - public string GetUserHomeDirectory() - => root; - - public string GetUserSharpClawRoot() - => root; - } + => new SqliteWorkspaceKnowledgeStore(fileSystem, pathService, TestRuntimeStorageResolver.Create(userRoot, pathService)); } diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs index 5a360fe..e2efa4f 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ShareAndCompactionServicesTests.cs @@ -11,6 +11,7 @@ using SharpClaw.Code.Sessions.Storage; using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Runtime; @@ -26,8 +27,9 @@ public async Task Share_and_compaction_services_persist_expected_session_metadat Directory.CreateDirectory(workspaceRoot); var clock = new FixedClock(DateTimeOffset.Parse("2026-04-13T15:00:00Z")); - var sessionStore = new FileSessionStore(fileSystem, pathService); - var eventStore = new NdjsonEventStore(fileSystem, pathService); + var storagePathResolver = TestRuntimeStorageResolver.Create(workspaceRoot, pathService); + var sessionStore = new FileSessionStore(fileSystem, storagePathResolver); + var eventStore = new NdjsonEventStore(fileSystem, storagePathResolver); var session = new ConversationSession( Id: "session-1", Title: "Initial title", @@ -109,13 +111,14 @@ await eventStore.AppendAsync( var shareService = new ShareSessionService( fileSystem, pathService, + storagePathResolver, clock, sessionStore, eventStore, new FixedConfigService(workspaceRoot), publisher, hooks); - var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, clock); + var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, storagePathResolver, clock); _ = await todoService.AddAsync(workspaceRoot, TodoScope.Session, "Follow up on diagnostics UX", session.Id, "primary-coding-agent", CancellationToken.None); var memoryStore = new RecordingPersistentMemoryStore(); var compactionService = new ConversationCompactionService(sessionStore, eventStore, todoService, memoryStore, clock); diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs index d9727bb..127af37 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/WorkspaceInsightsAndTodoServiceTests.cs @@ -7,6 +7,7 @@ using SharpClaw.Code.Runtime.Workflow; using SharpClaw.Code.Sessions.Storage; using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Runtime; @@ -21,11 +22,12 @@ public async Task Todo_service_and_workspace_insights_should_persist_and_report_ { Directory.CreateDirectory(workspaceRoot); var clock = new FixedClock(DateTimeOffset.Parse("2026-04-13T18:00:00Z")); - var sessionStore = new FileSessionStore(fileSystem, pathService); - var eventStore = new NdjsonEventStore(fileSystem, pathService); - var attachmentStore = new FileWorkspaceSessionAttachmentStore(fileSystem, pathService); + var storagePathResolver = TestRuntimeStorageResolver.Create(workspaceRoot, pathService); + var sessionStore = new FileSessionStore(fileSystem, storagePathResolver); + var eventStore = new NdjsonEventStore(fileSystem, storagePathResolver); + var attachmentStore = new FileWorkspaceSessionAttachmentStore(fileSystem, storagePathResolver); var usageTracker = new UsageTracker(); - var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, clock); + var todoService = new TodoService(sessionStore, eventStore, fileSystem, pathService, storagePathResolver, clock); var insights = new WorkspaceInsightsService(sessionStore, eventStore, attachmentStore, usageTracker, pathService, todoService); var session = new ConversationSession( diff --git a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs index c71f6e8..478e202 100644 --- a/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Sessions/SessionStorageTests.cs @@ -4,6 +4,7 @@ using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Sessions.Storage; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Sessions; @@ -16,6 +17,9 @@ public sealed class SessionStorageTests : IDisposable private readonly LocalFileSystem _fileSystem = new(); private readonly PathService _pathService = new(); + private SharpClaw.Code.Infrastructure.Abstractions.IRuntimeStoragePathResolver CreateStoragePathResolver() + => TestRuntimeStorageResolver.Create(_tempDir, _pathService); + public void Dispose() { if (Directory.Exists(_tempDir)) @@ -44,7 +48,7 @@ private ConversationSession CreateSession(string id, DateTimeOffset updatedAt) = [Fact] public async Task FileSessionStore_save_and_get_roundtrip() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var session = CreateSession("s1", DateTimeOffset.UtcNow); await store.SaveAsync(_tempDir, session, CancellationToken.None); @@ -59,7 +63,7 @@ public async Task FileSessionStore_save_and_get_roundtrip() [Fact] public async Task FileSessionStore_get_returns_null_when_missing() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var loaded = await store.GetByIdAsync(_tempDir, "nonexistent", CancellationToken.None); @@ -69,7 +73,7 @@ public async Task FileSessionStore_get_returns_null_when_missing() [Fact] public async Task FileSessionStore_get_latest_returns_most_recently_updated() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var older = CreateSession("s-old", DateTimeOffset.UtcNow.AddMinutes(-10)); var newer = CreateSession("s-new", DateTimeOffset.UtcNow); @@ -85,7 +89,7 @@ public async Task FileSessionStore_get_latest_returns_most_recently_updated() [Fact] public async Task FileSessionStore_get_latest_returns_null_when_empty() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var latest = await store.GetLatestAsync(_tempDir, CancellationToken.None); @@ -95,7 +99,7 @@ public async Task FileSessionStore_get_latest_returns_null_when_empty() [Fact] public async Task FileSessionStore_list_all_returns_sessions_descending() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var now = DateTimeOffset.UtcNow; await store.SaveAsync(_tempDir, CreateSession("s1", now.AddMinutes(-5)), CancellationToken.None); await store.SaveAsync(_tempDir, CreateSession("s2", now), CancellationToken.None); @@ -111,7 +115,7 @@ public async Task FileSessionStore_list_all_returns_sessions_descending() [Fact] public async Task FileSessionStore_save_overwrites_existing() { - var store = new FileSessionStore(_fileSystem, _pathService); + var store = new FileSessionStore(_fileSystem, CreateStoragePathResolver()); var session = CreateSession("s1", DateTimeOffset.UtcNow); await store.SaveAsync(_tempDir, session, CancellationToken.None); @@ -127,7 +131,7 @@ public async Task FileSessionStore_save_overwrites_existing() [Fact] public async Task NdjsonEventStore_append_and_read_roundtrip() { - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); var evt = new UndoCompletedEvent( EventId: "e1", SessionId: "s1", @@ -150,7 +154,7 @@ public async Task NdjsonEventStore_append_and_read_roundtrip() [Fact] public async Task NdjsonEventStore_read_returns_empty_when_no_file() { - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); var events = await store.ReadAllAsync(_tempDir, "nonexistent", CancellationToken.None); @@ -160,7 +164,7 @@ public async Task NdjsonEventStore_read_returns_empty_when_no_file() [Fact] public async Task NdjsonEventStore_appends_multiple_events() { - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); var sessionsRoot = Path.Combine(_tempDir, ".sharpclaw", "sessions", "s1"); Directory.CreateDirectory(sessionsRoot); @@ -189,7 +193,7 @@ public async Task NdjsonEventStore_skips_malformed_lines() var eventsPath = Path.Combine(sessionsRoot, "events.ndjson"); // Write a valid event followed by garbage. - var store = new NdjsonEventStore(_fileSystem, _pathService); + var store = new NdjsonEventStore(_fileSystem, CreateStoragePathResolver()); await store.AppendAsync(_tempDir, "s1", new UndoCompletedEvent( EventId: "e1", SessionId: "s1", @@ -210,7 +214,7 @@ public async Task NdjsonEventStore_skips_malformed_lines() [Fact] public async Task FileCheckpointStore_save_and_get_latest_roundtrip() { - var store = new FileCheckpointStore(_fileSystem, _pathService); + var store = new FileCheckpointStore(_fileSystem, CreateStoragePathResolver()); var now = DateTimeOffset.UtcNow; var older = new RuntimeCheckpoint("cp-old", "s1", "t1", now.AddMinutes(-5), "checkpoint old", "state-old", null, null); var newer = new RuntimeCheckpoint("cp-new", "s1", "t2", now, "checkpoint new", "state-new", null, null); @@ -230,7 +234,7 @@ public async Task FileCheckpointStore_save_and_get_latest_roundtrip() [Fact] public async Task FileCheckpointStore_returns_null_when_no_checkpoints() { - var store = new FileCheckpointStore(_fileSystem, _pathService); + var store = new FileCheckpointStore(_fileSystem, CreateStoragePathResolver()); var latest = await store.GetLatestAsync(_tempDir, "nonexistent", CancellationToken.None); diff --git a/tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs b/tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs new file mode 100644 index 0000000..b6f7b8d --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Support/TestRuntimeStorageResolver.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; + +namespace SharpClaw.Code.UnitTests.Support; + +internal static class TestRuntimeStorageResolver +{ + public static IRuntimeStoragePathResolver Create(string userRoot, IPathService? pathService = null) + { + var effectivePathService = pathService ?? new PathService(); + return new RuntimeStoragePathResolver( + effectivePathService, + new FixedUserProfilePaths(userRoot, effectivePathService), + new RuntimeHostContextAccessor()); + } + + private sealed class FixedUserProfilePaths(string root, IPathService pathService) : IUserProfilePaths + { + public string GetUserCustomCommandsDirectory() + => pathService.Combine(root, "commands"); + + public string GetUserHomeDirectory() + => root; + + public string GetUserSharpClawRoot() + => root; + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs new file mode 100644 index 0000000..7ad4b6b --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Plugins.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools; +using SharpClaw.Code.Tools.Abstractions; + +namespace SharpClaw.Code.UnitTests.Tools; + +/// +/// Verifies packaged tool manifests install into the workspace catalog and map onto plugins. +/// +public sealed class ToolPackageServiceTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-tool-packages", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Tool_package_service_should_install_and_list_workspace_packages() + { + Directory.CreateDirectory(workspaceRoot); + var services = new ServiceCollection(); + services.AddSharpClawTools(); + using var serviceProvider = services.BuildServiceProvider(); + + var packageService = serviceProvider.GetRequiredService(); + var pluginManager = serviceProvider.GetRequiredService(); + var request = new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.widgets", "1.2.3", "local", "widget-tool"), + "acme", + "Widget helper tools", + [new PackagedToolDescriptor("widget_lookup", "Looks up widgets", """{"type":"object"}""", Tags: ["widgets"])]), + InstallSource: "unit-test", + EnableAfterInstall: false); + + var installed = await packageService.InstallAsync(workspaceRoot, request, CancellationToken.None); + var listed = await packageService.ListInstalledAsync(workspaceRoot, CancellationToken.None); + var plugins = await pluginManager.ListAsync(workspaceRoot, CancellationToken.None); + + installed.Manifest.Package.PackageId.Should().Be("acme.widgets"); + listed.Should().ContainSingle(package => package.Manifest.Package.PackageId == "acme.widgets"); + plugins.Should().ContainSingle(plugin => plugin.Descriptor.Id == "acme.widgets"); + } + + public void Dispose() + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + } +} From e1535ae98dff521a25244ea2907886bdacc26e15 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 20 Apr 2026 17:00:50 -0700 Subject: [PATCH 3/8] feat: complete phase 2 enterprise runtime --- Directory.Packages.props | 2 + .../plans/2026-04-10-phase1-gaps.md | 2 +- .../MinimalConsoleAgent.csproj | 2 +- examples/MinimalConsoleAgent/Program.cs | 42 ++- examples/WebApiAgent/Program.cs | 60 ++-- examples/WebApiAgent/WebApiAgent.csproj | 2 +- .../EmbeddedRuntimeLifecycleService.cs | 13 + examples/WorkerServiceHost/Program.cs | 10 + examples/WorkerServiceHost/PromptWorker.cs | 64 ++++ .../WorkerServiceHost.csproj | 18 + examples/WorkerServiceHost/appsettings.json | 25 ++ .../Internal/ProviderBackedAgentKernel.cs | 2 - .../IRuntimeStoragePathResolver.cs | 9 + .../Services/RuntimeStoragePathResolver.cs | 15 + .../Abstractions/IApprovalIdentityService.cs | 23 ++ .../IApprovalPrincipalAccessor.cs | 24 ++ .../Models/PermissionEvaluationContext.cs | 4 +- .../PermissionsServiceCollectionExtensions.cs | 2 + .../Services/ApprovalPrincipalAccessor.cs | 40 +++ .../Services/ApprovalService.cs | 17 +- .../AuthenticatedApprovalTransport.cs | 66 ++++ .../Services/PermissionPolicyEngine.cs | 11 +- .../Models/ApprovalDecision.cs | 3 +- .../Models/EnterpriseRuntimeModels.cs | 174 ++++++++++ .../Models/OpenCodeParityModels.cs | 4 +- .../Models/Phase2Models.cs | 8 +- .../Serialization/ProtocolJsonContext.cs | 13 + .../RuntimeServiceCollectionExtensions.cs | 7 + .../Diagnostics/Checks/ApprovalAuthCheck.cs | 32 ++ .../OperationalDiagnosticsCoordinator.cs | 2 +- .../Prompts/PromptReferenceResolver.cs | 7 +- .../ConfiguredApprovalIdentityService.cs | 249 ++++++++++++++ .../Server/SqliteUsageMeteringStore.cs | 263 +++++++++++++++ .../Server/WorkspaceHttpServer.cs | 137 +++++++- .../SharpClaw.Code.Runtime.csproj | 3 + .../Turns/DefaultTurnRunner.cs | 9 +- .../Abstractions/IUsageMeteringService.cs | 23 ++ .../Abstractions/IUsageMeteringStore.cs | 28 ++ .../Services/UsageMeteringService.cs | 222 ++++++++++++ .../Services/WebhookRuntimeEventSink.cs | 33 +- .../TelemetryOptions.cs | 10 + .../Execution/ToolExecutor.cs | 5 +- .../Services/ToolPackageService.cs | 224 ++++++++++++- .../ToolsServiceCollectionExtensions.cs | 4 +- src/SharpClaw.Code/SharpClawRuntimeHost.cs | 29 ++ .../Runtime/ApprovalAuthIntegrationTests.cs | 317 ++++++++++++++++++ .../Runtime/WorkspaceHttpServerAdminTests.cs | 48 ++- .../ConfiguredApprovalIdentityServiceTests.cs | 71 ++++ .../Telemetry/UsageMeteringServiceTests.cs | 175 ++++++++++ .../Tools/ToolPackageServiceTests.cs | 143 +++++++- 50 files changed, 2597 insertions(+), 99 deletions(-) create mode 100644 examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs create mode 100644 examples/WorkerServiceHost/Program.cs create mode 100644 examples/WorkerServiceHost/PromptWorker.cs create mode 100644 examples/WorkerServiceHost/WorkerServiceHost.csproj create mode 100644 examples/WorkerServiceHost/appsettings.json create mode 100644 src/SharpClaw.Code.Permissions/Abstractions/IApprovalIdentityService.cs create mode 100644 src/SharpClaw.Code.Permissions/Abstractions/IApprovalPrincipalAccessor.cs create mode 100644 src/SharpClaw.Code.Permissions/Services/ApprovalPrincipalAccessor.cs create mode 100644 src/SharpClaw.Code.Permissions/Services/AuthenticatedApprovalTransport.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/EnterpriseRuntimeModels.cs create mode 100644 src/SharpClaw.Code.Runtime/Diagnostics/Checks/ApprovalAuthCheck.cs create mode 100644 src/SharpClaw.Code.Runtime/Server/ConfiguredApprovalIdentityService.cs create mode 100644 src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs create mode 100644 src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringService.cs create mode 100644 src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringStore.cs create mode 100644 src/SharpClaw.Code.Telemetry/Services/UsageMeteringService.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e6fadd8..3027c60 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,8 @@ + + diff --git a/docs/superpowers/plans/2026-04-10-phase1-gaps.md b/docs/superpowers/plans/2026-04-10-phase1-gaps.md index c8b602f..e1d36c4 100644 --- a/docs/superpowers/plans/2026-04-10-phase1-gaps.md +++ b/docs/superpowers/plans/2026-04-10-phase1-gaps.md @@ -1,4 +1,4 @@ -# Phase 1 Gaps Implementation Plan +Phase 1 Gaps Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj index 09f2de4..85a15e8 100644 --- a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +++ b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj @@ -10,7 +10,7 @@ - + diff --git a/examples/MinimalConsoleAgent/Program.cs b/examples/MinimalConsoleAgent/Program.cs index 8d53747..377ea2f 100644 --- a/examples/MinimalConsoleAgent/Program.cs +++ b/examples/MinimalConsoleAgent/Program.cs @@ -1,9 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code; using SharpClaw.Code.Protocol.Enums; -using SharpClaw.Code.Runtime.Abstractions; -using SharpClaw.Code.Runtime.Composition; +using SharpClaw.Code.Protocol.Models; if (args.Length == 0) { @@ -13,32 +10,31 @@ var prompt = string.Join(' ', args); -var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddSharpClawRuntime(builder.Configuration); - -using var host = builder.Build(); -await host.StartAsync(); - -var runtime = host.Services.GetRequiredService(); +await using var runtimeHost = new SharpClawRuntimeHostBuilder(args).Build(); +await runtimeHost.StartAsync(); var workspacePath = Directory.GetCurrentDirectory(); -var session = await runtime.CreateSessionAsync( +var hostContext = new RuntimeHostContext( + HostId: "minimal-console-agent", + IsEmbeddedHost: true); +var session = await runtimeHost.CreateSessionAsync( workspacePath, PermissionMode.ReadOnly, OutputFormat.Text, + hostContext, CancellationToken.None); -var request = new RunPromptRequest( - Prompt: prompt, - SessionId: session.Id, - WorkingDirectory: workspacePath, - PermissionMode: PermissionMode.ReadOnly, - OutputFormat: OutputFormat.Text, - Metadata: null); - -var result = await runtime.RunPromptAsync(request, CancellationToken.None); +var result = await runtimeHost.ExecutePromptAsync( + prompt, + workspacePath, + model: "default", + permissionMode: PermissionMode.ReadOnly, + outputFormat: OutputFormat.Text, + sessionId: session.Id, + hostContext: hostContext, + cancellationToken: CancellationToken.None); Console.WriteLine(result.FinalOutput ?? "(no output)"); -await host.StopAsync(); +await runtimeHost.StopAsync(); return 0; diff --git a/examples/WebApiAgent/Program.cs b/examples/WebApiAgent/Program.cs index f83023a..9039004 100644 --- a/examples/WebApiAgent/Program.cs +++ b/examples/WebApiAgent/Program.cs @@ -1,46 +1,58 @@ -using SharpClaw.Code.Protocol.Commands; +using Microsoft.Extensions.Hosting; +using SharpClaw.Code; using SharpClaw.Code.Protocol.Enums; -using SharpClaw.Code.Runtime.Abstractions; -using SharpClaw.Code.Runtime.Composition; +using SharpClaw.Code.Protocol.Models; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSharpClawRuntime(builder.Configuration); +builder.Services.AddSingleton(_ => new SharpClawRuntimeHostBuilder(args).Build()); +builder.Services.AddHostedService(); var app = builder.Build(); -app.MapPost("/chat", async (ChatRequest body, IConversationRuntime runtime, CancellationToken ct) => +app.MapPost("/chat", async (ChatRequest body, SharpClawRuntimeHost runtimeHost, CancellationToken ct) => { var workspacePath = Directory.GetCurrentDirectory(); + var hostContext = new RuntimeHostContext( + HostId: "web-api-agent", + TenantId: body.TenantId, + IsEmbeddedHost: true); - string sessionId; - if (!string.IsNullOrWhiteSpace(body.SessionId)) + var sessionId = body.SessionId; + if (string.IsNullOrWhiteSpace(sessionId)) { - sessionId = body.SessionId; - } - else - { - var session = await runtime.CreateSessionAsync( + var session = await runtimeHost.CreateSessionAsync( workspacePath, PermissionMode.ReadOnly, OutputFormat.Text, + hostContext, ct); sessionId = session.Id; } - var request = new RunPromptRequest( - Prompt: body.Prompt, - SessionId: sessionId, - WorkingDirectory: workspacePath, - PermissionMode: PermissionMode.ReadOnly, - OutputFormat: OutputFormat.Text, - Metadata: null); - - var result = await runtime.RunPromptAsync(request, ct); - - return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId)); + var result = await runtimeHost.ExecutePromptAsync( + body.Prompt, + workspacePath, + model: "default", + permissionMode: PermissionMode.ReadOnly, + outputFormat: OutputFormat.Text, + sessionId: sessionId, + hostContext: hostContext, + cancellationToken: ct); + + return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId!)); }); app.Run(); -record ChatRequest(string Prompt, string? SessionId); +sealed class EmbeddedRuntimeLifecycleService(SharpClawRuntimeHost runtimeHost) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + => runtimeHost.StartAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) + => runtimeHost.StopAsync(cancellationToken); +} + +record ChatRequest(string Prompt, string? SessionId, string? TenantId = null); + record ChatResponse(string Output, string SessionId); diff --git a/examples/WebApiAgent/WebApiAgent.csproj b/examples/WebApiAgent/WebApiAgent.csproj index 243d3a3..369ea85 100644 --- a/examples/WebApiAgent/WebApiAgent.csproj +++ b/examples/WebApiAgent/WebApiAgent.csproj @@ -5,7 +5,7 @@ - + diff --git a/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs b/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs new file mode 100644 index 0000000..07326ca --- /dev/null +++ b/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; +using SharpClaw.Code; + +namespace WorkerServiceHost; + +sealed class EmbeddedRuntimeLifecycleService(SharpClawRuntimeHost runtimeHost) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + => runtimeHost.StartAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) + => runtimeHost.StopAsync(cancellationToken); +} diff --git a/examples/WorkerServiceHost/Program.cs b/examples/WorkerServiceHost/Program.cs new file mode 100644 index 0000000..dff998f --- /dev/null +++ b/examples/WorkerServiceHost/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Hosting; +using SharpClaw.Code; +using WorkerServiceHost; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSingleton(_ => new SharpClawRuntimeHostBuilder(args).Build()); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +await builder.Build().RunAsync(); diff --git a/examples/WorkerServiceHost/PromptWorker.cs b/examples/WorkerServiceHost/PromptWorker.cs new file mode 100644 index 0000000..0f0ae02 --- /dev/null +++ b/examples/WorkerServiceHost/PromptWorker.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SharpClaw.Code; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace WorkerServiceHost; + +sealed class PromptWorker( + IConfiguration configuration, + ILogger logger, + SharpClawRuntimeHost runtimeHost, + IHostApplicationLifetime hostApplicationLifetime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var configuredWorkspacePath = configuration["Worker:WorkspacePath"]; + var workspacePath = string.IsNullOrWhiteSpace(configuredWorkspacePath) + ? Directory.GetCurrentDirectory() + : configuredWorkspacePath; + var prompt = configuration["Worker:Prompt"] ?? "Summarize the current workspace and highlight any obvious risks."; + var configuredModel = configuration["Worker:Model"]; + var model = string.IsNullOrWhiteSpace(configuredModel) ? "default" : configuredModel; + var tenantId = string.IsNullOrWhiteSpace(configuration["Worker:TenantId"]) + ? null + : configuration["Worker:TenantId"]; + var hostContext = new RuntimeHostContext( + HostId: "worker-service-host", + TenantId: tenantId, + IsEmbeddedHost: true); + + try + { + var session = await runtimeHost.CreateSessionAsync( + workspacePath, + PermissionMode.ReadOnly, + OutputFormat.Text, + hostContext, + stoppingToken); + + var result = await runtimeHost.ExecutePromptAsync( + prompt, + workspacePath, + model, + PermissionMode.ReadOnly, + OutputFormat.Text, + sessionId: session.Id, + hostContext: hostContext, + cancellationToken: stoppingToken); + + logger.LogInformation("Worker prompt completed for session {SessionId}.", session.Id); + logger.LogInformation("{Output}", result.FinalOutput ?? "(no output)"); + } + catch (Exception exception) + { + logger.LogError(exception, "Worker prompt execution failed."); + } + finally + { + hostApplicationLifetime.StopApplication(); + } + } +} diff --git a/examples/WorkerServiceHost/WorkerServiceHost.csproj b/examples/WorkerServiceHost/WorkerServiceHost.csproj new file mode 100644 index 0000000..314e287 --- /dev/null +++ b/examples/WorkerServiceHost/WorkerServiceHost.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + Worker-service example for hosting SharpClaw Code through the embeddable runtime SDK. + + + + + + + + + + + diff --git a/examples/WorkerServiceHost/appsettings.json b/examples/WorkerServiceHost/appsettings.json new file mode 100644 index 0000000..047fca9 --- /dev/null +++ b/examples/WorkerServiceHost/appsettings.json @@ -0,0 +1,25 @@ +{ + "SharpClaw": { + "Providers": { + "Catalog": { + "DefaultProvider": "Anthropic" + }, + "Anthropic": { + "ApiKey": "", + "DefaultModel": "claude-sonnet-4-5" + } + } + }, + "Worker": { + "Prompt": "Summarize the current workspace and highlight any obvious risks.", + "WorkspacePath": "", + "Model": "default", + "TenantId": "" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "SharpClaw": "Information" + } + } +} diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs index c4f8b31..49c9439 100644 --- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs +++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs @@ -9,7 +9,6 @@ using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Telemetry.Diagnostics; -using SharpClaw.Code.Telemetry.Metrics; using SharpClaw.Code.Tools.Models; namespace SharpClaw.Code.Agents.Internal; @@ -180,7 +179,6 @@ internal async Task ExecuteAsync( providerSw.Stop(); providerScope.SetCompleted(iterationUsage?.InputTokens, iterationUsage?.OutputTokens); - SharpClawMeterSource.ProviderDuration.Record(providerSw.Elapsed.TotalMilliseconds); } catch (Exception ex) { diff --git a/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs b/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs index 6cab425..475b12d 100644 --- a/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs +++ b/src/SharpClaw.Code.Infrastructure/Abstractions/IRuntimeStoragePathResolver.cs @@ -56,12 +56,21 @@ public interface IRuntimeStoragePathResolver /// Gets the workspace exports directory path. string GetExportsRoot(string workspacePath); + /// Gets the workspace telemetry directory path. + string GetTelemetryRoot(string workspacePath); + + /// Gets the SQLite database path used by usage metering. + string GetUsageMeteringDatabasePath(string workspacePath); + /// Gets the SQLite database path used by alternate session and event stores. string GetSessionStoreDatabasePath(string workspacePath); /// Gets the tool package catalog directory path. string GetToolPackagesRoot(string workspacePath); + /// Gets the extracted package directory path for a packaged tool install. + string GetExtractedToolPackageRoot(string workspacePath, string packageId, string version); + /// Gets the user-level SharpClaw root with any active tenant partition applied. string GetUserSharpClawRoot(); } diff --git a/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs b/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs index e8d0a25..dc42d70 100644 --- a/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs +++ b/src/SharpClaw.Code.Infrastructure/Services/RuntimeStoragePathResolver.cs @@ -93,6 +93,14 @@ public string GetWorkspaceKnowledgeRoot(string workspacePath) public string GetExportsRoot(string workspacePath) => pathService.Combine(GetSharpClawRoot(workspacePath), "exports"); + /// + public string GetTelemetryRoot(string workspacePath) + => pathService.Combine(GetSharpClawRoot(workspacePath), "telemetry"); + + /// + public string GetUsageMeteringDatabasePath(string workspacePath) + => pathService.Combine(GetTelemetryRoot(workspacePath), "usage-metering.db"); + /// public string GetSessionStoreDatabasePath(string workspacePath) => pathService.Combine(GetSharpClawRoot(workspacePath), "session-store.db"); @@ -101,6 +109,13 @@ public string GetSessionStoreDatabasePath(string workspacePath) public string GetToolPackagesRoot(string workspacePath) => pathService.Combine(GetSharpClawRoot(workspacePath), "tool-packages"); + /// + public string GetExtractedToolPackageRoot(string workspacePath, string packageId, string version) + => pathService.Combine( + GetToolPackagesRoot(workspacePath), + "extracted", + $"{SanitizeSegment(packageId)}-{SanitizeSegment(version)}"); + /// public string GetUserSharpClawRoot() { diff --git a/src/SharpClaw.Code.Permissions/Abstractions/IApprovalIdentityService.cs b/src/SharpClaw.Code.Permissions/Abstractions/IApprovalIdentityService.cs new file mode 100644 index 0000000..e290dda --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Abstractions/IApprovalIdentityService.cs @@ -0,0 +1,23 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Permissions.Abstractions; + +/// +/// Resolves and validates approval identities for embedded host requests. +/// +public interface IApprovalIdentityService +{ + /// + /// Resolves the approval-auth configuration and health for the workspace. + /// + Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken); + + /// + /// Resolves the current approval principal for the supplied request, if any. + /// + Task ResolveAsync( + string workspaceRoot, + ApprovalIdentityRequest request, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Permissions/Abstractions/IApprovalPrincipalAccessor.cs b/src/SharpClaw.Code.Permissions/Abstractions/IApprovalPrincipalAccessor.cs new file mode 100644 index 0000000..d00e7e8 --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Abstractions/IApprovalPrincipalAccessor.cs @@ -0,0 +1,24 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Permissions.Abstractions; + +/// +/// Exposes the current approval principal and auth status for the active async flow. +/// +public interface IApprovalPrincipalAccessor +{ + /// + /// Gets the current approval principal, when available. + /// + ApprovalPrincipal? CurrentPrincipal { get; } + + /// + /// Gets the current approval-auth status for the active request. + /// + ApprovalAuthStatus? CurrentStatus { get; } + + /// + /// Sets the current approval principal and auth status for the active async flow. + /// + IDisposable BeginScope(ApprovalPrincipal? principal, ApprovalAuthStatus? status); +} diff --git a/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs b/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs index 9a73142..f529dce 100644 --- a/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs +++ b/src/SharpClaw.Code.Permissions/Models/PermissionEvaluationContext.cs @@ -19,6 +19,7 @@ namespace SharpClaw.Code.Permissions.Models; /// The plugin id when executing a plugin-surfaced tool; otherwise null. /// The manifest trust tier for the originating plugin tool, if any. /// Build vs plan workflow; plan mode blocks mutating tools. +/// The active tenant identifier, when one is bound to the host context. public sealed record PermissionEvaluationContext( string SessionId, string WorkspaceRoot, @@ -33,4 +34,5 @@ public sealed record PermissionEvaluationContext( IReadOnlyCollection? TrustedMcpServerNames, string? ToolOriginatingPluginId = null, PluginTrustLevel? ToolOriginatingPluginTrust = null, - PrimaryMode PrimaryMode = PrimaryMode.Build); + PrimaryMode PrimaryMode = PrimaryMode.Build, + string? TenantId = null); diff --git a/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs index 06f92cc..4d8fc58 100644 --- a/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Permissions/PermissionsServiceCollectionExtensions.cs @@ -18,8 +18,10 @@ public static class PermissionsServiceCollectionExtensions public static IServiceCollection AddSharpClawPermissions(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Permissions/Services/ApprovalPrincipalAccessor.cs b/src/SharpClaw.Code.Permissions/Services/ApprovalPrincipalAccessor.cs new file mode 100644 index 0000000..556c9f4 --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Services/ApprovalPrincipalAccessor.cs @@ -0,0 +1,40 @@ +using System.Threading; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Permissions.Services; + +/// +/// Async-local approval principal scope used by embedded server and admin requests. +/// +public sealed class ApprovalPrincipalAccessor : IApprovalPrincipalAccessor +{ + private static readonly AsyncLocal Current = new(); + + /// + public ApprovalPrincipal? CurrentPrincipal => Current.Value?.Principal; + + /// + public ApprovalAuthStatus? CurrentStatus => Current.Value?.Status; + + /// + public IDisposable BeginScope(ApprovalPrincipal? principal, ApprovalAuthStatus? status) + { + var previous = Current.Value; + Current.Value = new ApprovalScopeState(principal, status); + return new Scope(previous); + } + + private sealed record ApprovalScopeState(ApprovalPrincipal? Principal, ApprovalAuthStatus? Status); + + private sealed class Scope(ApprovalScopeState? previous) : IDisposable + { + private ApprovalScopeState? previousState = previous; + + public void Dispose() + { + Current.Value = previousState; + previousState = null; + } + } +} diff --git a/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs b/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs index 75afd24..1def7ea 100644 --- a/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs +++ b/src/SharpClaw.Code.Permissions/Services/ApprovalService.cs @@ -10,7 +10,8 @@ namespace SharpClaw.Code.Permissions.Services; public sealed class ApprovalService( ConsoleApprovalService consoleApprovalService, NonInteractiveApprovalService nonInteractiveApprovalService, - IEnumerable approvalTransports) : IApprovalService + IEnumerable approvalTransports, + IApprovalPrincipalAccessor approvalPrincipalAccessor) : IApprovalService { private readonly IApprovalTransport[] approvalTransports = approvalTransports.ToArray(); @@ -26,6 +27,20 @@ public Task RequestApprovalAsync( return transport.RequestApprovalAsync(request, context, cancellationToken); } + if (approvalPrincipalAccessor.CurrentStatus is { RequireAuthenticatedApprovals: true, Mode: not ApprovalAuthMode.Disabled } + && approvalPrincipalAccessor.CurrentPrincipal is null) + { + return Task.FromResult(new ApprovalDecision( + request.Scope, + Approved: false, + RequestedBy: request.RequestedBy, + ResolvedBy: "approval-auth", + Reason: "Authenticated approval is required for this host, but no valid approval identity was supplied.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: false)); + } + return context.IsInteractive ? consoleApprovalService.RequestApprovalAsync(request, context, cancellationToken) : nonInteractiveApprovalService.RequestApprovalAsync(request, context, cancellationToken); diff --git a/src/SharpClaw.Code.Permissions/Services/AuthenticatedApprovalTransport.cs b/src/SharpClaw.Code.Permissions/Services/AuthenticatedApprovalTransport.cs new file mode 100644 index 0000000..acc9295 --- /dev/null +++ b/src/SharpClaw.Code.Permissions/Services/AuthenticatedApprovalTransport.cs @@ -0,0 +1,66 @@ +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Permissions.Services; + +/// +/// Resolves approval requests through an authenticated embedded-host principal. +/// +public sealed class AuthenticatedApprovalTransport(IApprovalPrincipalAccessor principalAccessor) : IApprovalTransport +{ + /// + public bool CanHandle(PermissionEvaluationContext context) + => principalAccessor.CurrentPrincipal is not null + && principalAccessor.CurrentStatus is { Mode: not ApprovalAuthMode.Disabled } + && !string.Equals(context.SourceName, "acp", StringComparison.OrdinalIgnoreCase); + + /// + public Task RequestApprovalAsync( + ApprovalRequest request, + PermissionEvaluationContext context, + CancellationToken cancellationToken) + { + _ = cancellationToken; + var principal = principalAccessor.CurrentPrincipal; + if (principal is null) + { + return Task.FromResult(new ApprovalDecision( + request.Scope, + Approved: false, + RequestedBy: request.RequestedBy, + ResolvedBy: "approval-auth", + Reason: "Approval identity was not available for the current request.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: false)); + } + + if (!string.IsNullOrWhiteSpace(context.TenantId) + && (string.IsNullOrWhiteSpace(principal.TenantId) + || !string.Equals(context.TenantId, principal.TenantId, StringComparison.Ordinal))) + { + return Task.FromResult(new ApprovalDecision( + request.Scope, + Approved: false, + RequestedBy: request.RequestedBy, + ResolvedBy: principal.SubjectId, + Reason: $"Approver tenant '{principal.TenantId ?? ""}' does not match runtime tenant '{context.TenantId}'.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: false, + Principal: principal)); + } + + return Task.FromResult(new ApprovalDecision( + request.Scope, + Approved: true, + RequestedBy: request.RequestedBy, + ResolvedBy: principal.SubjectId, + Reason: "Approved by authenticated embedded-host principal.", + ResolvedAtUtc: DateTimeOffset.UtcNow, + ExpiresAtUtc: null, + RememberForSession: request.CanRememberDecision, + Principal: principal)); + } +} diff --git a/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs b/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs index 88e0069..5c074eb 100644 --- a/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs +++ b/src/SharpClaw.Code.Permissions/Services/PermissionPolicyEngine.cs @@ -118,7 +118,8 @@ private async Task ResolveApprovalAsync( CancellationToken cancellationToken) { var approvalKey = CreateApprovalKey(request, context); - if (ruleResult.CanRememberApproval && sessionApprovalMemory.TryGet(context.SessionId, approvalKey) is { } rememberedEntry && rememberedEntry.Decision.Approved) + var approvalMemorySessionId = GetApprovalMemorySessionId(context); + if (ruleResult.CanRememberApproval && sessionApprovalMemory.TryGet(approvalMemorySessionId, approvalKey) is { } rememberedEntry && rememberedEntry.Decision.Approved) { return CreateDecision( request.ApprovalScope, @@ -137,7 +138,7 @@ private async Task ResolveApprovalAsync( var approvalDecision = await approvalService.RequestApprovalAsync(approvalRequest, context, cancellationToken).ConfigureAwait(false); if (approvalDecision.Approved && ruleResult.CanRememberApproval && approvalDecision.RememberForSession) { - sessionApprovalMemory.Store(context.SessionId, approvalKey, new ApprovalMemoryEntry(approvalDecision)); + sessionApprovalMemory.Store(approvalMemorySessionId, approvalKey, new ApprovalMemoryEntry(approvalDecision)); } return CreateDecision( @@ -180,12 +181,18 @@ private static string CreateApprovalKey(ToolExecutionRequest request, Permission request.ApprovalScope, context.SourceKind, context.SourceName ?? string.Empty, + context.TenantId ?? string.Empty, request.WorkingDirectory ?? string.Empty, context.ToolOriginatingPluginId ?? string.Empty, trustSegment, pathSegment); } + private static string GetApprovalMemorySessionId(PermissionEvaluationContext context) + => string.IsNullOrWhiteSpace(context.TenantId) + ? context.SessionId + : $"{context.TenantId}::{context.SessionId}"; + private static string TryReadPathArgument(string argumentsJson) { try diff --git a/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs b/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs index 7e91488..7cd147c 100644 --- a/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs +++ b/src/SharpClaw.Code.Protocol/Models/ApprovalDecision.cs @@ -21,4 +21,5 @@ public sealed record ApprovalDecision( string? Reason, DateTimeOffset ResolvedAtUtc, DateTimeOffset? ExpiresAtUtc, - bool RememberForSession = false); + bool RememberForSession = false, + ApprovalPrincipal? Principal = null); diff --git a/src/SharpClaw.Code.Protocol/Models/EnterpriseRuntimeModels.cs b/src/SharpClaw.Code.Protocol/Models/EnterpriseRuntimeModels.cs new file mode 100644 index 0000000..f49ecdd --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/EnterpriseRuntimeModels.cs @@ -0,0 +1,174 @@ +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Protocol.Models; + +/// +/// Configures approval-identity handling for the embedded HTTP and admin surfaces. +/// +public enum ApprovalAuthMode +{ + /// + /// Approval identity is disabled. + /// + Disabled, + + /// + /// Approval identity is resolved from trusted upstream headers. + /// + TrustedHeader, + + /// + /// Approval identity is resolved from an OIDC bearer token. + /// + Oidc, +} + +/// +/// Describes approval-auth configuration resolved for the embedded server. +/// +public sealed record SharpClawApprovalAuthOptions( + ApprovalAuthMode Mode = ApprovalAuthMode.Disabled, + bool RequireForAdmin = false, + bool RequireAuthenticatedApprovals = false, + string? Authority = null, + string? Audience = null, + string? SubjectHeader = null, + string? DisplayNameHeader = null, + string? TenantHeader = null, + string? RolesHeader = null, + string? ScopesHeader = null, + string? SubjectClaim = null, + string? DisplayNameClaim = null, + string? TenantClaim = null, + string? RolesClaim = null, + string? ScopesClaim = null); + +/// +/// Represents the current approver identity for an authenticated approval flow. +/// +public sealed record ApprovalPrincipal( + string SubjectId, + string? DisplayName = null, + string? TenantId = null, + string[]? Roles = null, + string[]? Scopes = null, + string? AuthenticationType = null); + +/// +/// Describes the configured approval-auth mode and current health. +/// +public sealed record ApprovalAuthStatus( + ApprovalAuthMode Mode, + bool IsConfigured, + bool IsHealthy, + bool RequireForAdmin = false, + bool RequireAuthenticatedApprovals = false, + string? Authority = null, + string? Audience = null, + string? Detail = null); + +/// +/// Carries request headers used to resolve an approval principal. +/// +public sealed record ApprovalIdentityRequest( + string? AuthorizationHeader, + IReadOnlyDictionary Headers); + +/// +/// Requests creation of a durable session through the admin API. +/// +public sealed record AdminCreateSessionRequest( + PermissionMode? PermissionMode = null, + OutputFormat? OutputFormat = null); + +/// +/// Requests a metering summary or detail report. +/// +public sealed record UsageMeteringQuery( + DateTimeOffset? FromUtc = null, + DateTimeOffset? ToUtc = null, + string? TenantId = null, + string? HostId = null, + string? WorkspaceRoot = null, + string? SessionId = null); + +/// +/// Declares the type of persisted usage metering record. +/// +public enum UsageMeteringRecordKind +{ + /// + /// A provider request completed. + /// + ProviderUsage, + + /// + /// A workspace or session usage snapshot was updated. + /// + UsageSnapshot, + + /// + /// A tool execution completed. + /// + ToolExecution, + + /// + /// A turn completed. + /// + TurnExecution, + + /// + /// A session lifecycle event occurred. + /// + SessionLifecycle, +} + +/// +/// One normalized usage metering record. +/// +public sealed record UsageMeteringRecord( + string Id, + UsageMeteringRecordKind Kind, + DateTimeOffset OccurredAtUtc, + string? TenantId, + string? HostId, + string? WorkspaceRoot, + string? SessionId, + string? TurnId, + string? ProviderName = null, + string? Model = null, + string? ToolName = null, + ApprovalScope? ApprovalScope = null, + bool? Succeeded = null, + long? DurationMilliseconds = null, + UsageSnapshot? Usage = null, + string? Detail = null); + +/// +/// Aggregated metering totals for a filtered query. +/// +public sealed record UsageMeteringSummaryReport( + UsageMeteringQuery Query, + UsageSnapshot TotalUsage, + int ProviderRequestCount, + int ToolExecutionCount, + int TurnCount, + int SessionEventCount); + +/// +/// Detailed metering records for a filtered query. +/// +public sealed record UsageMeteringDetailReport( + UsageMeteringQuery Query, + IReadOnlyList Records); + +/// +/// Resolved installation metadata retained for an installed packaged tool. +/// +public sealed record ToolPackageResolvedInstall( + string? SourceReference, + string? PackageSource, + string? PackageFilePath, + string? ExtractedPackageRoot, + string ResolvedEntryAssembly, + string[]? ResolvedEntryArguments = null); diff --git a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs index 95eaf71..7d2ba33 100644 --- a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs @@ -151,10 +151,12 @@ public sealed record HookDefinition( /// Bind host. /// Bind port. /// Optional externally reachable base URL used for share links. +/// Optional approval-auth configuration for HTTP and admin callers. public sealed record SharpClawServerOptions( string Host, int Port, - string? PublicBaseUrl = null); + string? PublicBaseUrl = null, + SharpClawApprovalAuthOptions? ApprovalAuth = null); /// /// Configures browser-based connection entry points for providers or MCP servers. diff --git a/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs b/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs index c25b59c..199e573 100644 --- a/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs +++ b/src/SharpClaw.Code.Protocol/Models/Phase2Models.cs @@ -66,6 +66,7 @@ public sealed record ToolPackageReference( string Version, string PackageType, string EntryAssembly, + string[]? EntryArguments = null, string? TargetFramework = null, string[]? Tags = null); @@ -108,7 +109,8 @@ public sealed record ToolPackageManifest( public sealed record InstalledToolPackage( ToolPackageManifest Manifest, DateTimeOffset InstalledAtUtc, - string InstallSource); + string InstallSource, + ToolPackageResolvedInstall? ResolvedInstall = null); /// /// Request payload used to install a packaged tool manifest into a workspace catalog. @@ -119,4 +121,6 @@ public sealed record InstalledToolPackage( public sealed record ToolPackageInstallRequest( ToolPackageManifest Manifest, string InstallSource, - bool EnableAfterInstall = true); + bool EnableAfterInstall = true, + string? SourceReference = null, + string? PackageSource = null); diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index f286520..15593d6 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -16,7 +16,12 @@ namespace SharpClaw.Code.Protocol.Serialization; DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UseStringEnumConverter = true)] [JsonSerializable(typeof(AuthStatus))] +[JsonSerializable(typeof(ApprovalAuthMode))] +[JsonSerializable(typeof(ApprovalAuthStatus))] [JsonSerializable(typeof(ApprovalDecision))] +[JsonSerializable(typeof(ApprovalIdentityRequest))] +[JsonSerializable(typeof(ApprovalPrincipal))] +[JsonSerializable(typeof(AdminCreateSessionRequest))] [JsonSerializable(typeof(CommandResult))] [JsonSerializable(typeof(DoctorReport))] [JsonSerializable(typeof(RuntimeStatusReport))] @@ -52,6 +57,7 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(MemorySaveRequest))] [JsonSerializable(typeof(MemoryListRequest))] +[JsonSerializable(typeof(SharpClawApprovalAuthOptions))] [JsonSerializable(typeof(SessionStoreKind))] [JsonSerializable(typeof(RuntimeHostContext))] [JsonSerializable(typeof(RuntimeEventEnvelope))] @@ -61,8 +67,15 @@ namespace SharpClaw.Code.Protocol.Serialization; [JsonSerializable(typeof(PackagedToolDescriptor[]))] [JsonSerializable(typeof(ToolPackageManifest))] [JsonSerializable(typeof(ToolPackageInstallRequest))] +[JsonSerializable(typeof(ToolPackageResolvedInstall))] [JsonSerializable(typeof(InstalledToolPackage))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(UsageMeteringQuery))] +[JsonSerializable(typeof(UsageMeteringRecordKind))] +[JsonSerializable(typeof(UsageMeteringRecord))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(UsageMeteringSummaryReport))] +[JsonSerializable(typeof(UsageMeteringDetailReport))] [JsonSerializable(typeof(WorkspaceSearchHitKind))] [JsonSerializable(typeof(WorkspaceSearchHit))] [JsonSerializable(typeof(List))] diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index c4773ac..16352f6 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ using SharpClaw.Code.Sessions.Storage; using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Telemetry.Services; namespace SharpClaw.Code.Runtime.Composition; @@ -94,6 +95,10 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSharpClawMemory(); services.AddSharpClawSkills(); services.AddSharpClawGit(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -114,6 +119,7 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -142,6 +148,7 @@ private static void AddOperationalDiagnostics(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => new McpRegistryHealthCheck( sp.GetRequiredService(), diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/Checks/ApprovalAuthCheck.cs b/src/SharpClaw.Code.Runtime/Diagnostics/Checks/ApprovalAuthCheck.cs new file mode 100644 index 0000000..2553e48 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Diagnostics/Checks/ApprovalAuthCheck.cs @@ -0,0 +1,32 @@ +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Operational; + +namespace SharpClaw.Code.Runtime.Diagnostics.Checks; + +/// +/// Reports approval-auth configuration and health for embedded server workflows. +/// +public sealed class ApprovalAuthCheck(IApprovalIdentityService approvalIdentityService) : IOperationalCheck +{ + /// + public string Id => "approval.auth"; + + /// + public async Task ExecuteAsync(OperationalDiagnosticsContext context, CancellationToken cancellationToken) + { + var status = await approvalIdentityService.GetStatusAsync(context.NormalizedWorkspacePath, cancellationToken).ConfigureAwait(false); + var severity = status.Mode switch + { + Protocol.Models.ApprovalAuthMode.Disabled => OperationalCheckStatus.Ok, + _ when status.IsHealthy => OperationalCheckStatus.Ok, + _ when status.IsConfigured => OperationalCheckStatus.Warn, + _ => OperationalCheckStatus.Ok, + }; + + return new OperationalCheckItem( + Id, + severity, + $"Approval auth mode: {status.Mode}.", + status.Detail); + } +} diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs index 7e12e9c..bef85e7 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs @@ -68,7 +68,7 @@ public async Task BuildStatusReportAsync(OperationalDiagnos : input.WorkingDirectory); var context = new OperationalDiagnosticsContext(workspacePath, input.Model, input.PermissionMode); var quickChecks = new List(); - foreach (var id in new[] { "workspace.access", "session.store", "mcp.registry", "plugins.registry" }) + foreach (var id in new[] { "workspace.access", "session.store", "mcp.registry", "plugins.registry", "approval.auth" }) { var check = orderedChecks.FirstOrDefault(c => c.Id == id); if (check is not null) diff --git a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs index 422b893..41f4bff 100644 --- a/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs +++ b/src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs @@ -4,6 +4,7 @@ using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Permissions.Abstractions; using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; @@ -16,7 +17,8 @@ namespace SharpClaw.Code.Runtime.Prompts; public sealed partial class PromptReferenceResolver( IFileSystem fileSystem, IPathService pathService, - IPermissionPolicyEngine permissionPolicyEngine) : IPromptReferenceResolver + IPermissionPolicyEngine permissionPolicyEngine, + IRuntimeHostContextAccessor? hostContextAccessor = null) : IPromptReferenceResolver { /// public async Task ResolveAsync( @@ -143,7 +145,8 @@ private async Task EnsureOutsideWorkspaceAllowedAsync( SourceName: isAcp ? "acp" : null, TrustedPluginNames: null, TrustedMcpServerNames: null, - PrimaryMode: primaryMode); + PrimaryMode: primaryMode, + TenantId: hostContextAccessor?.Current?.TenantId); var decision = await permissionPolicyEngine .EvaluateAsync(toolRequest, context, cancellationToken) diff --git a/src/SharpClaw.Code.Runtime/Server/ConfiguredApprovalIdentityService.cs b/src/SharpClaw.Code.Runtime/Server/ConfiguredApprovalIdentityService.cs new file mode 100644 index 0000000..93b36ee --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Server/ConfiguredApprovalIdentityService.cs @@ -0,0 +1,249 @@ +using System.Collections.Concurrent; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.Runtime.Server; + +/// +/// Resolves approval principals from trusted headers or OIDC bearer tokens. +/// +public sealed class ConfiguredApprovalIdentityService( + ISharpClawConfigService sharpClawConfigService) : IApprovalIdentityService +{ + private readonly ConcurrentDictionary> oidcConfigurationManagers = new(StringComparer.OrdinalIgnoreCase); + + /// + public async Task GetStatusAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var options = await GetOptionsAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + if (options is null || options.Mode == ApprovalAuthMode.Disabled) + { + return new ApprovalAuthStatus( + ApprovalAuthMode.Disabled, + IsConfigured: false, + IsHealthy: true, + Detail: "Approval auth is disabled."); + } + + if (options.Mode == ApprovalAuthMode.TrustedHeader) + { + var subjectHeader = options.SubjectHeader ?? "X-SharpClaw-User"; + return new ApprovalAuthStatus( + options.Mode, + IsConfigured: true, + IsHealthy: !string.IsNullOrWhiteSpace(subjectHeader), + RequireForAdmin: options.RequireForAdmin, + RequireAuthenticatedApprovals: options.RequireAuthenticatedApprovals, + Detail: $"Trusted-header approval auth is configured with subject header '{subjectHeader}'."); + } + + if (string.IsNullOrWhiteSpace(options.Authority) || string.IsNullOrWhiteSpace(options.Audience)) + { + return new ApprovalAuthStatus( + options.Mode, + IsConfigured: true, + IsHealthy: false, + RequireForAdmin: options.RequireForAdmin, + RequireAuthenticatedApprovals: options.RequireAuthenticatedApprovals, + Authority: options.Authority, + Audience: options.Audience, + Detail: "OIDC approval auth requires both authority and audience."); + } + + try + { + _ = await GetConfigurationManager(options.Authority!).GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + return new ApprovalAuthStatus( + options.Mode, + IsConfigured: true, + IsHealthy: true, + RequireForAdmin: options.RequireForAdmin, + RequireAuthenticatedApprovals: options.RequireAuthenticatedApprovals, + Authority: options.Authority, + Audience: options.Audience, + Detail: "OIDC approval auth metadata resolved successfully."); + } + catch (Exception exception) + { + return new ApprovalAuthStatus( + options.Mode, + IsConfigured: true, + IsHealthy: false, + RequireForAdmin: options.RequireForAdmin, + RequireAuthenticatedApprovals: options.RequireAuthenticatedApprovals, + Authority: options.Authority, + Audience: options.Audience, + Detail: exception.Message); + } + } + + /// + public async Task ResolveAsync( + string workspaceRoot, + ApprovalIdentityRequest request, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var options = await GetOptionsAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + if (options is null || options.Mode == ApprovalAuthMode.Disabled) + { + return null; + } + + return options.Mode switch + { + ApprovalAuthMode.TrustedHeader => ResolveFromTrustedHeaders(request.Headers, options, hostContext), + ApprovalAuthMode.Oidc => await ResolveFromOidcAsync(request.AuthorizationHeader, options, hostContext, cancellationToken).ConfigureAwait(false), + _ => null + }; + } + + private async Task GetOptionsAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var snapshot = await sharpClawConfigService.GetConfigAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + return snapshot.Document.Server?.ApprovalAuth; + } + + private static ApprovalPrincipal? ResolveFromTrustedHeaders( + IReadOnlyDictionary headers, + SharpClawApprovalAuthOptions options, + RuntimeHostContext? hostContext) + { + var subjectHeader = options.SubjectHeader ?? "X-SharpClaw-User"; + if (!headers.TryGetValue(subjectHeader, out var subjectId) || string.IsNullOrWhiteSpace(subjectId)) + { + return null; + } + + headers.TryGetValue(options.DisplayNameHeader ?? "X-SharpClaw-Display-Name", out var displayName); + headers.TryGetValue(options.TenantHeader ?? "X-SharpClaw-Tenant-Id", out var tenantId); + var effectiveTenantId = string.IsNullOrWhiteSpace(tenantId) ? hostContext?.TenantId : tenantId; + + return new ApprovalPrincipal( + SubjectId: subjectId.Trim(), + DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), + TenantId: string.IsNullOrWhiteSpace(effectiveTenantId) ? null : effectiveTenantId.Trim(), + Roles: ReadDelimitedHeader(headers, options.RolesHeader ?? "X-SharpClaw-Roles"), + Scopes: ReadDelimitedHeader(headers, options.ScopesHeader ?? "X-SharpClaw-Scopes"), + AuthenticationType: "trusted-header"); + } + + private async Task ResolveFromOidcAsync( + string? authorizationHeader, + SharpClawApprovalAuthOptions options, + RuntimeHostContext? hostContext, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(authorizationHeader) + || !authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(options.Authority) || string.IsNullOrWhiteSpace(options.Audience)) + { + return null; + } + + var token = authorizationHeader["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var configuration = await GetConfigurationManager(options.Authority!).GetConfigurationAsync(cancellationToken).ConfigureAwait(false); + var validationParameters = new TokenValidationParameters + { + ValidAudience = options.Audience, + ValidIssuers = + [ + configuration.Issuer, + options.Authority.TrimEnd('/'), + ], + IssuerSigningKeys = configuration.SigningKeys, + ValidateAudience = true, + ValidateIssuer = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + }; + + var handler = new JsonWebTokenHandler(); + var result = await handler.ValidateTokenAsync(token, validationParameters).ConfigureAwait(false); + if (!result.IsValid || result.ClaimsIdentity is null) + { + return null; + } + + var subjectId = result.ClaimsIdentity.FindFirst(options.SubjectClaim ?? "sub")?.Value; + if (string.IsNullOrWhiteSpace(subjectId)) + { + return null; + } + + var tenantId = result.ClaimsIdentity.FindFirst(options.TenantClaim ?? "tid")?.Value; + if (string.IsNullOrWhiteSpace(tenantId)) + { + tenantId = hostContext?.TenantId; + } + + return new ApprovalPrincipal( + SubjectId: subjectId, + DisplayName: result.ClaimsIdentity.FindFirst(options.DisplayNameClaim ?? "name")?.Value, + TenantId: tenantId, + Roles: ReadClaimValues(result.ClaimsIdentity, options.RolesClaim ?? "role"), + Scopes: ReadScopeValues(result.ClaimsIdentity, options.ScopesClaim ?? "scope"), + AuthenticationType: "oidc"); + } + + private IConfigurationManager GetConfigurationManager(string authority) + => oidcConfigurationManagers.GetOrAdd( + authority.TrimEnd('/'), + static key => new ConfigurationManager( + $"{key}/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever + { + RequireHttps = false, + })); + + private static string[]? ReadDelimitedHeader(IReadOnlyDictionary headers, string headerName) + { + if (!headers.TryGetValue(headerName, out var value) || string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var items = value + .Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return items.Length == 0 ? null : items; + } + + private static string[]? ReadClaimValues(System.Security.Claims.ClaimsIdentity identity, string claimType) + { + var values = identity.FindAll(claimType) + .Select(static claim => claim.Value) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return values.Length == 0 ? null : values; + } + + private static string[]? ReadScopeValues(System.Security.Claims.ClaimsIdentity identity, string claimType) + { + var values = identity.FindAll(claimType) + .SelectMany(static claim => claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return values.Length == 0 ? null : values; + } +} diff --git a/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs b/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs new file mode 100644 index 0000000..1322e84 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs @@ -0,0 +1,263 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry.Abstractions; + +namespace SharpClaw.Code.Runtime.Server; + +/// +/// Persists normalized usage metering records in a tenant-aware SQLite store. +/// +public sealed class SqliteUsageMeteringStore( + IFileSystem fileSystem, + IPathService pathService, + IRuntimeStoragePathResolver storagePathResolver) : IUsageMeteringStore +{ + /// + public async Task AppendAsync(string workspaceRoot, UsageMeteringRecord record, CancellationToken cancellationToken) + { + await using var connection = await OpenConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT OR REPLACE INTO usage_records ( + id, kind, occurred_at_utc, tenant_id, host_id, workspace_root, session_id, turn_id, + provider_name, model, tool_name, approval_scope, succeeded, duration_ms, + input_tokens, output_tokens, cached_input_tokens, total_tokens, estimated_cost_usd, detail) + VALUES ( + $id, $kind, $occurredAtUtc, $tenantId, $hostId, $workspaceRoot, $sessionId, $turnId, + $providerName, $model, $toolName, $approvalScope, $succeeded, $durationMs, + $inputTokens, $outputTokens, $cachedInputTokens, $totalTokens, $estimatedCostUsd, $detail); + """; + BindRecord(command, record); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetSummaryAsync(string workspaceRoot, UsageMeteringQuery query, CancellationToken cancellationToken) + { + await using var connection = await OpenConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var whereClause = BuildWhereClause(query, out var parameters); + var sql = $""" + SELECT + COALESCE(SUM(CASE WHEN kind = 'ProviderUsage' THEN input_tokens ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind = 'ProviderUsage' THEN output_tokens ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind = 'ProviderUsage' THEN cached_input_tokens ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind = 'ProviderUsage' THEN total_tokens ELSE 0 END), 0), + SUM(CASE WHEN kind = 'ProviderUsage' THEN estimated_cost_usd END), + COALESCE(SUM(CASE WHEN kind = 'ProviderUsage' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind = 'ToolExecution' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind = 'TurnExecution' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN kind = 'SessionLifecycle' THEN 1 ELSE 0 END), 0) + FROM usage_records + {whereClause}; + """; + await using var command = connection.CreateCommand(); + command.CommandText = sql; + AddParameters(command, parameters); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + await reader.ReadAsync(cancellationToken).ConfigureAwait(false); + + decimal? estimatedCost = reader.IsDBNull(4) ? null : reader.GetDecimal(4); + return new UsageMeteringSummaryReport( + query, + new UsageSnapshot( + checked((int)reader.GetInt64(0)), + checked((int)reader.GetInt64(1)), + checked((int)reader.GetInt64(2)), + checked((int)reader.GetInt64(3)), + estimatedCost), + checked((int)reader.GetInt64(5)), + checked((int)reader.GetInt64(6)), + checked((int)reader.GetInt64(7)), + checked((int)reader.GetInt64(8))); + } + + /// + public async Task GetDetailAsync( + string workspaceRoot, + UsageMeteringQuery query, + int limit, + CancellationToken cancellationToken) + { + await using var connection = await OpenConnectionAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var whereClause = BuildWhereClause(query, out var parameters); + var sql = $""" + SELECT + id, kind, occurred_at_utc, tenant_id, host_id, workspace_root, session_id, turn_id, + provider_name, model, tool_name, approval_scope, succeeded, duration_ms, + input_tokens, output_tokens, cached_input_tokens, total_tokens, estimated_cost_usd, detail + FROM usage_records + {whereClause} + ORDER BY occurred_at_utc DESC, id DESC + LIMIT $limit; + """; + await using var command = connection.CreateCommand(); + command.CommandText = sql; + AddParameters(command, parameters); + command.Parameters.AddWithValue("$limit", limit); + + var records = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + records.Add(new UsageMeteringRecord( + Id: reader.GetString(0), + Kind: Enum.Parse(reader.GetString(1), ignoreCase: true), + OccurredAtUtc: DateTimeOffset.Parse(reader.GetString(2), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + TenantId: reader.IsDBNull(3) ? null : reader.GetString(3), + HostId: reader.IsDBNull(4) ? null : reader.GetString(4), + WorkspaceRoot: reader.IsDBNull(5) ? null : reader.GetString(5), + SessionId: reader.IsDBNull(6) ? null : reader.GetString(6), + TurnId: reader.IsDBNull(7) ? null : reader.GetString(7), + ProviderName: reader.IsDBNull(8) ? null : reader.GetString(8), + Model: reader.IsDBNull(9) ? null : reader.GetString(9), + ToolName: reader.IsDBNull(10) ? null : reader.GetString(10), + ApprovalScope: reader.IsDBNull(11) ? null : Enum.Parse(reader.GetString(11), ignoreCase: true), + Succeeded: reader.IsDBNull(12) ? null : reader.GetInt64(12) != 0, + DurationMilliseconds: reader.IsDBNull(13) ? null : reader.GetInt64(13), + Usage: HasUsage(reader) + ? new UsageSnapshot( + reader.GetInt32(14), + reader.GetInt32(15), + reader.GetInt32(16), + reader.GetInt32(17), + reader.IsDBNull(18) ? null : reader.GetDecimal(18)) + : null, + Detail: reader.IsDBNull(19) ? null : reader.GetString(19))); + } + + return new UsageMeteringDetailReport(query, records); + } + + private async Task OpenConnectionAsync(string workspaceRoot, CancellationToken cancellationToken) + { + var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + var dbPath = storagePathResolver.GetUsageMeteringDatabasePath(normalizedWorkspace); + var telemetryRoot = Path.GetDirectoryName(dbPath) + ?? storagePathResolver.GetTelemetryRoot(normalizedWorkspace); + fileSystem.CreateDirectory(telemetryRoot); + + var connection = new SqliteConnection($"Data Source={dbPath}"); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + + private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE TABLE IF NOT EXISTS usage_records ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + occurred_at_utc TEXT NOT NULL, + tenant_id TEXT NULL, + host_id TEXT NULL, + workspace_root TEXT NULL, + session_id TEXT NULL, + turn_id TEXT NULL, + provider_name TEXT NULL, + model TEXT NULL, + tool_name TEXT NULL, + approval_scope TEXT NULL, + succeeded INTEGER NULL, + duration_ms INTEGER NULL, + input_tokens INTEGER NULL, + output_tokens INTEGER NULL, + cached_input_tokens INTEGER NULL, + total_tokens INTEGER NULL, + estimated_cost_usd REAL NULL, + detail TEXT NULL + ); + CREATE INDEX IF NOT EXISTS ix_usage_records_occurred_at ON usage_records(occurred_at_utc DESC); + CREATE INDEX IF NOT EXISTS ix_usage_records_session ON usage_records(session_id); + CREATE INDEX IF NOT EXISTS ix_usage_records_tenant_host ON usage_records(tenant_id, host_id); + """; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static string BuildWhereClause(UsageMeteringQuery query, out Dictionary parameters) + { + parameters = new Dictionary(StringComparer.Ordinal); + var conditions = new List(); + if (query.FromUtc is { } fromUtc) + { + conditions.Add("occurred_at_utc >= $fromUtc"); + parameters["$fromUtc"] = fromUtc.ToString("O", CultureInfo.InvariantCulture); + } + + if (query.ToUtc is { } toUtc) + { + conditions.Add("occurred_at_utc <= $toUtc"); + parameters["$toUtc"] = toUtc.ToString("O", CultureInfo.InvariantCulture); + } + + if (!string.IsNullOrWhiteSpace(query.TenantId)) + { + conditions.Add("tenant_id = $tenantId"); + parameters["$tenantId"] = query.TenantId!; + } + + if (!string.IsNullOrWhiteSpace(query.HostId)) + { + conditions.Add("host_id = $hostId"); + parameters["$hostId"] = query.HostId!; + } + + if (!string.IsNullOrWhiteSpace(query.WorkspaceRoot)) + { + conditions.Add("workspace_root = $workspaceRoot"); + parameters["$workspaceRoot"] = query.WorkspaceRoot!; + } + + if (!string.IsNullOrWhiteSpace(query.SessionId)) + { + conditions.Add("session_id = $sessionId"); + parameters["$sessionId"] = query.SessionId!; + } + + return conditions.Count == 0 + ? string.Empty + : $"WHERE {string.Join(" AND ", conditions)}"; + } + + private static void AddParameters(SqliteCommand command, IReadOnlyDictionary parameters) + { + foreach (var pair in parameters) + { + command.Parameters.AddWithValue(pair.Key, pair.Value); + } + } + + private static void BindRecord(SqliteCommand command, UsageMeteringRecord record) + { + command.Parameters.AddWithValue("$id", record.Id); + command.Parameters.AddWithValue("$kind", record.Kind.ToString()); + command.Parameters.AddWithValue("$occurredAtUtc", record.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture)); + command.Parameters.AddWithValue("$tenantId", (object?)record.TenantId ?? DBNull.Value); + command.Parameters.AddWithValue("$hostId", (object?)record.HostId ?? DBNull.Value); + command.Parameters.AddWithValue("$workspaceRoot", (object?)record.WorkspaceRoot ?? DBNull.Value); + command.Parameters.AddWithValue("$sessionId", (object?)record.SessionId ?? DBNull.Value); + command.Parameters.AddWithValue("$turnId", (object?)record.TurnId ?? DBNull.Value); + command.Parameters.AddWithValue("$providerName", (object?)record.ProviderName ?? DBNull.Value); + command.Parameters.AddWithValue("$model", (object?)record.Model ?? DBNull.Value); + command.Parameters.AddWithValue("$toolName", (object?)record.ToolName ?? DBNull.Value); + command.Parameters.AddWithValue("$approvalScope", record.ApprovalScope?.ToString() is { } scope ? scope : DBNull.Value); + command.Parameters.AddWithValue("$succeeded", record.Succeeded.HasValue ? record.Succeeded.Value ? 1 : 0 : DBNull.Value); + command.Parameters.AddWithValue("$durationMs", record.DurationMilliseconds.HasValue ? record.DurationMilliseconds.Value : DBNull.Value); + command.Parameters.AddWithValue("$inputTokens", record.Usage?.InputTokens is { } input ? input : DBNull.Value); + command.Parameters.AddWithValue("$outputTokens", record.Usage?.OutputTokens is { } output ? output : DBNull.Value); + command.Parameters.AddWithValue("$cachedInputTokens", record.Usage?.CachedInputTokens is { } cached ? cached : DBNull.Value); + command.Parameters.AddWithValue("$totalTokens", record.Usage?.TotalTokens is { } total ? total : DBNull.Value); + command.Parameters.AddWithValue("$estimatedCostUsd", record.Usage?.EstimatedCostUsd is { } cost ? cost : DBNull.Value); + command.Parameters.AddWithValue("$detail", (object?)record.Detail ?? DBNull.Value); + } + + private static bool HasUsage(SqliteDataReader reader) + => !reader.IsDBNull(14) + || !reader.IsDBNull(15) + || !reader.IsDBNull(16) + || !reader.IsDBNull(17) + || !reader.IsDBNull(18); +} diff --git a/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs b/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs index cea8597..1017177 100644 --- a/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs +++ b/src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs @@ -5,6 +5,7 @@ using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Memory.Abstractions; using Microsoft.Extensions.Logging; +using SharpClaw.Code.Permissions.Abstractions; using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Protocol.Commands; @@ -22,6 +23,7 @@ namespace SharpClaw.Code.Runtime.Server; /// public sealed class WorkspaceHttpServer( IRuntimeCommandService runtimeCommandService, + IConversationRuntime conversationRuntime, IShareSessionService shareSessionService, ISharpClawConfigService sharpClawConfigService, IHookDispatcher hookDispatcher, @@ -30,7 +32,10 @@ public sealed class WorkspaceHttpServer( IWorkspaceSearchService workspaceSearchService, IPersistentMemoryStore persistentMemoryStore, IRuntimeEventStream runtimeEventStream, + IUsageMeteringService usageMeteringService, IToolPackageService toolPackageService, + IApprovalIdentityService approvalIdentityService, + IApprovalPrincipalAccessor approvalPrincipalAccessor, IRuntimeHostContextAccessor hostContextAccessor, ILogger logger) : IWorkspaceHttpServer { @@ -96,11 +101,26 @@ private async Task HandleRequestAsync( var statusCode = 500; var path = request.Url?.AbsolutePath ?? "/"; var requestHostContext = ResolveRequestHostContext(request, defaultContext.HostContext, tenantOverride: null); + var approvalAuthStatus = await approvalIdentityService.GetStatusAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var approvalPrincipal = await approvalIdentityService + .ResolveAsync(workspaceRoot, CreateApprovalIdentityRequest(request), requestHostContext, cancellationToken) + .ConfigureAwait(false); using var hostScope = hostContextAccessor.BeginScope(requestHostContext); + using var approvalScope = approvalPrincipalAccessor.BeginScope(approvalPrincipal, approvalAuthStatus); var requestContext = defaultContext with { HostContext = requestHostContext }; try { + if (IsAdminPath(path) + && approvalAuthStatus.RequireForAdmin + && approvalPrincipal is null) + { + statusCode = 401; + await WriteJsonAsync(response, 401, new ErrorEnvelope("Admin authentication is required for this endpoint."), cancellationToken).ConfigureAwait(false); + requestSucceeded = true; + return; + } + if (request.HttpMethod == "GET" && path == "/v1/status") { var result = await runtimeCommandService.GetStatusAsync(requestContext, cancellationToken).ConfigureAwait(false); @@ -144,6 +164,20 @@ private async Task HandleRequestAsync( var catalog = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false); await WriteJsonAsync(response, 200, catalog, cancellationToken).ConfigureAwait(false); } + else if (request.HttpMethod == "GET" && path == "/v1/admin/auth/status") + { + statusCode = 200; + await WriteJsonAsync(response, 200, approvalAuthStatus, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "POST" && path == "/v1/admin/sessions") + { + statusCode = await HandleAdminCreateSessionAsync(request, response, workspaceRoot, requestContext, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "POST" && path.StartsWith("/v1/admin/sessions/", StringComparison.Ordinal) && path.EndsWith("/fork", StringComparison.Ordinal)) + { + var sessionId = Uri.UnescapeDataString(path["/v1/admin/sessions/".Length..^"/fork".Length]); + statusCode = await HandleAdminForkSessionAsync(response, workspaceRoot, sessionId, requestContext, cancellationToken).ConfigureAwait(false); + } else if (request.HttpMethod == "GET" && path == "/v1/admin/index/status") { statusCode = 200; @@ -182,6 +216,14 @@ private async Task HandleRequestAsync( { statusCode = await HandleToolPackageInstallAsync(request, response, workspaceRoot, cancellationToken).ConfigureAwait(false); } + else if (request.HttpMethod == "GET" && path == "/v1/admin/usage/summary") + { + statusCode = await HandleUsageSummaryAsync(request, response, workspaceRoot, cancellationToken).ConfigureAwait(false); + } + else if (request.HttpMethod == "GET" && path == "/v1/admin/usage/detail") + { + statusCode = await HandleUsageDetailAsync(request, response, workspaceRoot, cancellationToken).ConfigureAwait(false); + } else if (request.HttpMethod == "GET" && path.StartsWith("/s/", StringComparison.Ordinal)) { var shareId = Uri.UnescapeDataString(path["/s/".Length..]); @@ -244,7 +286,11 @@ private async Task HandlePromptAsync( IsInteractive: false, HostContext: ResolveRequestHostContext(request, defaultContext.HostContext, payload.TenantId)); - var result = await runtimeCommandService.ExecutePromptAsync(payload.Prompt, commandContext, cancellationToken).ConfigureAwait(false); + TurnExecutionResult result; + using (hostContextAccessor.BeginScope(commandContext.HostContext)) + { + result = await runtimeCommandService.ExecutePromptAsync(payload.Prompt, commandContext, cancellationToken).ConfigureAwait(false); + } var accept = request.Headers["Accept"]; var wantsSse = (accept?.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) ?? false) || string.Equals(request.QueryString["stream"], "true", StringComparison.OrdinalIgnoreCase); @@ -277,6 +323,44 @@ await WriteSseAsync( return 200; } + private async Task HandleAdminCreateSessionAsync( + HttpListenerRequest request, + HttpListenerResponse response, + string workspaceRoot, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + AdminCreateSessionRequest? payload = null; + if (request.HasEntityBody) + { + await using var body = request.InputStream; + payload = await JsonSerializer.DeserializeAsync(body, ProtocolJsonContext.Default.AdminCreateSessionRequest, cancellationToken).ConfigureAwait(false); + } + + var session = await conversationRuntime + .CreateSessionAsync( + workspaceRoot, + payload?.PermissionMode ?? context.PermissionMode, + payload?.OutputFormat ?? context.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + await WriteJsonAsync(response, 200, session, cancellationToken).ConfigureAwait(false); + return 200; + } + + private async Task HandleAdminForkSessionAsync( + HttpListenerResponse response, + string workspaceRoot, + string sessionId, + RuntimeCommandContext context, + CancellationToken cancellationToken) + { + using var hostScope = hostContextAccessor.BeginScope(context.HostContext); + var session = await conversationRuntime.ForkSessionAsync(workspaceRoot, sessionId, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, session, cancellationToken).ConfigureAwait(false); + return 200; + } + private async Task HandleWorkspaceSearchAsync( HttpListenerRequest request, HttpListenerResponse response, @@ -364,6 +448,31 @@ private async Task HandleToolPackageInstallAsync( return 200; } + private async Task HandleUsageSummaryAsync( + HttpListenerRequest request, + HttpListenerResponse response, + string workspaceRoot, + CancellationToken cancellationToken) + { + var query = ParseUsageQuery(request, workspaceRoot); + var report = await usageMeteringService.GetSummaryAsync(workspaceRoot, query, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, report, cancellationToken).ConfigureAwait(false); + return 200; + } + + private async Task HandleUsageDetailAsync( + HttpListenerRequest request, + HttpListenerResponse response, + string workspaceRoot, + CancellationToken cancellationToken) + { + var query = ParseUsageQuery(request, workspaceRoot); + var limit = ParseInt(request.QueryString["limit"], 100, 1, 1000); + var report = await usageMeteringService.GetDetailAsync(workspaceRoot, query, limit, cancellationToken).ConfigureAwait(false); + await WriteJsonAsync(response, 200, report, cancellationToken).ConfigureAwait(false); + return 200; + } + private static async Task WriteSseAsync(StreamWriter writer, string eventName, string payload) { await writer.WriteLineAsync($"event: {eventName}").ConfigureAwait(false); @@ -444,6 +553,26 @@ private static JsonSerializerOptions CreateServerJsonOptions() return options; } + private static ApprovalIdentityRequest CreateApprovalIdentityRequest(HttpListenerRequest request) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var key in request.Headers.AllKeys.Where(static key => !string.IsNullOrWhiteSpace(key))) + { + headers[key!] = request.Headers[key!]!; + } + + return new ApprovalIdentityRequest(request.Headers["Authorization"], headers); + } + + private static UsageMeteringQuery ParseUsageQuery(HttpListenerRequest request, string workspaceRoot) + => new( + FromUtc: TryParseDateTimeOffset(request.QueryString["fromUtc"] ?? request.QueryString["from"]), + ToUtc: TryParseDateTimeOffset(request.QueryString["toUtc"] ?? request.QueryString["to"]), + TenantId: request.QueryString["tenantId"], + HostId: request.QueryString["hostId"], + WorkspaceRoot: workspaceRoot, + SessionId: request.QueryString["sessionId"]); + private static RuntimeHostContext? ResolveRequestHostContext( HttpListenerRequest request, RuntimeHostContext? fallback, @@ -473,6 +602,9 @@ private static JsonSerializerOptions CreateServerJsonOptions() IsEmbeddedHost: isEmbeddedHost || !string.IsNullOrWhiteSpace(storageRoot) || !string.IsNullOrWhiteSpace(tenantId)); } + private static bool IsAdminPath(string path) + => path.StartsWith("/v1/admin/", StringComparison.Ordinal); + private static IReadOnlyList FilterEnvelopes( IEnumerable source, string workspaceRoot, @@ -509,6 +641,9 @@ private static bool ShouldIncludeEnvelope( private static int ParseInt(string? value, int fallback, int min, int max) => int.TryParse(value, out var parsed) ? Math.Clamp(parsed, min, max) : fallback; + private static DateTimeOffset? TryParseDateTimeOffset(string? value) + => DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; + private sealed record ServerCommandEnvelope( bool Succeeded, int ExitCode, diff --git a/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj b/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj index 95471d7..ed39a60 100644 --- a/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj +++ b/src/SharpClaw.Code.Runtime/SharpClaw.Code.Runtime.csproj @@ -9,6 +9,9 @@ + + + diff --git a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs index 341c25c..8e28333 100644 --- a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs +++ b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs @@ -7,7 +7,6 @@ using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Runtime.Workflow; using SharpClaw.Code.Telemetry.Diagnostics; -using SharpClaw.Code.Telemetry.Metrics; using SharpClaw.Code.Tools.Abstractions; namespace SharpClaw.Code.Runtime.Turns; @@ -69,12 +68,6 @@ public async Task RunAsync( agentResult = await agent.RunAsync(agentContext, cancellationToken).ConfigureAwait(false); sw.Stop(); turnScope.SetOutput(agentResult.Output, agentResult.Usage?.InputTokens, agentResult.Usage?.OutputTokens); - SharpClawMeterSource.TurnDuration.Record(sw.Elapsed.TotalMilliseconds); - if (agentResult.Usage is not null) - { - SharpClawMeterSource.InputTokens.Add(agentResult.Usage.InputTokens); - SharpClawMeterSource.OutputTokens.Add(agentResult.Usage.OutputTokens); - } } catch (Exception ex) { @@ -86,7 +79,7 @@ public async Task RunAsync( var mutations = mutationAccumulator.ToSnapshot(); return new TurnRunResult( Output: agentResult.Output, - Usage: agentResult.Usage, + Usage: agentResult.Usage ?? new UsageSnapshot(0, 0, 0, 0, null), Summary: agentResult.Summary, ProviderRequest: agentResult.ProviderRequest, ProviderEvents: agentResult.ProviderEvents, diff --git a/src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringService.cs b/src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringService.cs new file mode 100644 index 0000000..8cead5d --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringService.cs @@ -0,0 +1,23 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Telemetry.Abstractions; + +/// +/// Exposes query surfaces for persisted usage metering. +/// +public interface IUsageMeteringService +{ + /// + /// Builds an aggregated usage summary for the supplied workspace query. + /// + Task GetSummaryAsync(string workspaceRoot, UsageMeteringQuery query, CancellationToken cancellationToken); + + /// + /// Lists detailed metering records for the supplied workspace query. + /// + Task GetDetailAsync( + string workspaceRoot, + UsageMeteringQuery query, + int limit, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringStore.cs b/src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringStore.cs new file mode 100644 index 0000000..2afb331 --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Abstractions/IUsageMeteringStore.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Telemetry.Abstractions; + +/// +/// Persists and queries normalized usage metering records. +/// +public interface IUsageMeteringStore +{ + /// + /// Persists one usage metering record for a workspace. + /// + Task AppendAsync(string workspaceRoot, UsageMeteringRecord record, CancellationToken cancellationToken); + + /// + /// Builds an aggregated usage summary for the supplied workspace query. + /// + Task GetSummaryAsync(string workspaceRoot, UsageMeteringQuery query, CancellationToken cancellationToken); + + /// + /// Lists detailed usage metering records for the supplied workspace query. + /// + Task GetDetailAsync( + string workspaceRoot, + UsageMeteringQuery query, + int limit, + CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Telemetry/Services/UsageMeteringService.cs b/src/SharpClaw.Code.Telemetry/Services/UsageMeteringService.cs new file mode 100644 index 0000000..e041c63 --- /dev/null +++ b/src/SharpClaw.Code.Telemetry/Services/UsageMeteringService.cs @@ -0,0 +1,222 @@ +using System.Collections.Concurrent; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Telemetry.Metrics; + +namespace SharpClaw.Code.Telemetry.Services; + +/// +/// Converts runtime events into normalized usage metering records and metrics. +/// +public sealed class UsageMeteringService(IUsageMeteringStore store) : IUsageMeteringService, IRuntimeEventSink +{ + private readonly ConcurrentDictionary toolStarts = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> providerStarts = new(StringComparer.Ordinal); + + /// + public Task GetSummaryAsync(string workspaceRoot, UsageMeteringQuery query, CancellationToken cancellationToken) + => store.GetSummaryAsync(workspaceRoot, query, cancellationToken); + + /// + public Task GetDetailAsync( + string workspaceRoot, + UsageMeteringQuery query, + int limit, + CancellationToken cancellationToken) + => store.GetDetailAsync(workspaceRoot, query, limit, cancellationToken); + + /// + public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(envelope.WorkspacePath)) + { + return; + } + + UsageMeteringRecord? record = envelope.Event switch + { + ToolStartedEvent toolStarted => RememberToolStart(toolStarted), + ProviderStartedEvent providerStarted => RememberProviderStart(providerStarted), + ProviderCompletedEvent providerCompleted => CreateProviderUsageRecord(envelope, providerCompleted), + UsageUpdatedEvent usageUpdated => CreateUsageSnapshotRecord(envelope, usageUpdated), + ToolCompletedEvent toolCompleted => CreateToolExecutionRecord(envelope, toolCompleted), + TurnCompletedEvent turnCompleted => CreateTurnExecutionRecord(envelope, turnCompleted), + SessionCreatedEvent sessionCreated => CreateSessionLifecycleRecord(envelope, sessionCreated, "created"), + SessionForkedEvent sessionForked => CreateSessionLifecycleRecord(envelope, sessionForked, $"forked:{sessionForked.ParentSessionId}"), + SessionStateChangedEvent sessionStateChanged => CreateSessionLifecycleRecord(envelope, sessionStateChanged, $"state:{sessionStateChanged.CurrentState}"), + _ => null + }; + + if (record is null) + { + return; + } + + await store.AppendAsync(envelope.WorkspacePath, record, cancellationToken).ConfigureAwait(false); + RecordMetrics(record); + } + + private UsageMeteringRecord? RememberToolStart(ToolStartedEvent startedEvent) + { + toolStarts[startedEvent.Request.Id] = new ToolExecutionStart( + startedEvent.OccurredAtUtc, + startedEvent.Request.ApprovalScope); + return null; + } + + private UsageMeteringRecord? RememberProviderStart(ProviderStartedEvent startedEvent) + { + var queue = providerStarts.GetOrAdd(CreateProviderKey(startedEvent), static _ => new ConcurrentQueue()); + queue.Enqueue(startedEvent.OccurredAtUtc); + return null; + } + + private static UsageMeteringRecord CreateUsageSnapshotRecord(RuntimeEventEnvelope envelope, UsageUpdatedEvent usageUpdated) + => new( + Id: usageUpdated.EventId, + Kind: UsageMeteringRecordKind.UsageSnapshot, + OccurredAtUtc: usageUpdated.OccurredAtUtc, + TenantId: envelope.TenantId, + HostId: envelope.HostId, + WorkspaceRoot: envelope.WorkspacePath, + SessionId: usageUpdated.SessionId, + TurnId: usageUpdated.TurnId, + Usage: usageUpdated.Usage, + Detail: "usage-updated"); + + private UsageMeteringRecord CreateProviderUsageRecord(RuntimeEventEnvelope envelope, ProviderCompletedEvent completedEvent) + { + long? durationMilliseconds = null; + if (providerStarts.TryGetValue(CreateProviderKey(completedEvent), out var queue) + && queue.TryDequeue(out var startedAtUtc)) + { + durationMilliseconds = Math.Max(0L, (long)(completedEvent.OccurredAtUtc - startedAtUtc).TotalMilliseconds); + } + + return new UsageMeteringRecord( + Id: completedEvent.EventId, + Kind: UsageMeteringRecordKind.ProviderUsage, + OccurredAtUtc: completedEvent.OccurredAtUtc, + TenantId: envelope.TenantId, + HostId: envelope.HostId, + WorkspaceRoot: envelope.WorkspacePath, + SessionId: completedEvent.SessionId, + TurnId: completedEvent.TurnId, + ProviderName: completedEvent.ProviderName, + Model: completedEvent.Model, + DurationMilliseconds: durationMilliseconds, + Usage: completedEvent.Usage, + Detail: completedEvent.Kind); + } + + private UsageMeteringRecord CreateToolExecutionRecord(RuntimeEventEnvelope envelope, ToolCompletedEvent completedEvent) + { + var durationMilliseconds = completedEvent.Result.DurationMilliseconds; + SharpClaw.Code.Protocol.Enums.ApprovalScope? approvalScope = null; + if (toolStarts.TryRemove(completedEvent.Result.RequestId, out var started)) + { + approvalScope = started.ApprovalScope; + if (durationMilliseconds is null) + { + durationMilliseconds = Math.Max(0L, (long)(completedEvent.OccurredAtUtc - started.OccurredAtUtc).TotalMilliseconds); + } + } + + return new UsageMeteringRecord( + Id: completedEvent.EventId, + Kind: UsageMeteringRecordKind.ToolExecution, + OccurredAtUtc: completedEvent.OccurredAtUtc, + TenantId: envelope.TenantId, + HostId: envelope.HostId, + WorkspaceRoot: envelope.WorkspacePath, + SessionId: completedEvent.SessionId, + TurnId: completedEvent.TurnId, + ToolName: completedEvent.Result.ToolName, + ApprovalScope: approvalScope, + Succeeded: completedEvent.Result.Succeeded, + DurationMilliseconds: durationMilliseconds, + Detail: completedEvent.Result.ErrorMessage); + } + + private static UsageMeteringRecord CreateTurnExecutionRecord(RuntimeEventEnvelope envelope, TurnCompletedEvent completedEvent) + { + long? durationMilliseconds = null; + if (completedEvent.Turn.CompletedAtUtc is { } completedAtUtc) + { + durationMilliseconds = Math.Max(0L, (long)(completedAtUtc - completedEvent.Turn.StartedAtUtc).TotalMilliseconds); + } + + return new UsageMeteringRecord( + Id: completedEvent.EventId, + Kind: UsageMeteringRecordKind.TurnExecution, + OccurredAtUtc: completedEvent.OccurredAtUtc, + TenantId: envelope.TenantId, + HostId: envelope.HostId, + WorkspaceRoot: envelope.WorkspacePath, + SessionId: completedEvent.SessionId, + TurnId: completedEvent.TurnId, + Succeeded: completedEvent.Succeeded, + DurationMilliseconds: durationMilliseconds, + Usage: completedEvent.Turn.Usage, + Detail: completedEvent.Summary); + } + + private static UsageMeteringRecord CreateSessionLifecycleRecord(RuntimeEventEnvelope envelope, RuntimeEvent runtimeEvent, string detail) + => new( + Id: runtimeEvent.EventId, + Kind: UsageMeteringRecordKind.SessionLifecycle, + OccurredAtUtc: runtimeEvent.OccurredAtUtc, + TenantId: envelope.TenantId, + HostId: envelope.HostId, + WorkspaceRoot: envelope.WorkspacePath, + SessionId: runtimeEvent.SessionId, + TurnId: runtimeEvent.TurnId, + Detail: detail); + + private static void RecordMetrics(UsageMeteringRecord record) + { + if (record.Kind == UsageMeteringRecordKind.ProviderUsage) + { + if (record.Usage is { } usage) + { + SharpClawMeterSource.InputTokens.Add(usage.InputTokens); + SharpClawMeterSource.OutputTokens.Add(usage.OutputTokens); + } + + if (record.DurationMilliseconds is { } providerDuration) + { + SharpClawMeterSource.ProviderDuration.Record(providerDuration); + } + + return; + } + + if (record.Kind == UsageMeteringRecordKind.ToolExecution) + { + SharpClawMeterSource.ToolInvocations.Add(1); + if (record.DurationMilliseconds is { } toolDuration) + { + SharpClawMeterSource.ToolDuration.Record(toolDuration); + } + + return; + } + + if (record.Kind == UsageMeteringRecordKind.TurnExecution + && record.DurationMilliseconds is { } turnDuration) + { + SharpClawMeterSource.TurnDuration.Record(turnDuration); + } + } + + private static string CreateProviderKey(ProviderStartedEvent startedEvent) + => string.Join("::", startedEvent.SessionId, startedEvent.TurnId ?? string.Empty, startedEvent.ProviderName, startedEvent.Model); + + private static string CreateProviderKey(ProviderCompletedEvent completedEvent) + => string.Join("::", completedEvent.SessionId, completedEvent.TurnId ?? string.Empty, completedEvent.ProviderName, completedEvent.Model); + + private sealed record ToolExecutionStart( + DateTimeOffset OccurredAtUtc, + SharpClaw.Code.Protocol.Enums.ApprovalScope ApprovalScope); +} diff --git a/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs b/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs index ce565f9..d27b53a 100644 --- a/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs +++ b/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs @@ -34,15 +34,32 @@ public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken var payload = JsonSerializer.Serialize(envelope, ProtocolJsonContext.Default.RuntimeEventEnvelope); foreach (var url in telemetryOptions.EventWebhookUrls) { - try + var attempt = 0; + while (attempt++ < Math.Max(1, telemetryOptions.WebhookMaxAttempts)) { - using var content = new StringContent(payload, Encoding.UTF8, "application/json"); - using var response = await httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Failed to post runtime event {EventId} to webhook {WebhookUrl}.", envelope.Event.EventId, url); + try + { + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var response = await httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + break; + } + catch (Exception exception) when (attempt < Math.Max(1, telemetryOptions.WebhookMaxAttempts)) + { + logger.LogWarning( + exception, + "Retrying runtime event {EventId} webhook delivery to {WebhookUrl} after attempt {Attempt}.", + envelope.Event.EventId, + url, + attempt); + var delay = TimeSpan.FromMilliseconds(telemetryOptions.WebhookInitialBackoffMilliseconds * Math.Pow(2, attempt - 1)); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to post runtime event {EventId} to webhook {WebhookUrl}.", envelope.Event.EventId, url); + break; + } } } } diff --git a/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs b/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs index b954c90..c2cbab3 100644 --- a/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs +++ b/src/SharpClaw.Code.Telemetry/TelemetryOptions.cs @@ -14,4 +14,14 @@ public sealed class TelemetryOptions /// Optional webhook destinations that receive normalized runtime event envelopes. /// public List EventWebhookUrls { get; } = []; + + /// + /// Maximum number of webhook delivery attempts per event. + /// + public int WebhookMaxAttempts { get; set; } = 3; + + /// + /// Initial webhook retry delay in milliseconds. + /// + public int WebhookInitialBackoffMilliseconds { get; set; } = 200; } diff --git a/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs b/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs index 3da28d5..7506f33 100644 --- a/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs +++ b/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs @@ -3,6 +3,7 @@ using SharpClaw.Code.Permissions.Abstractions; using SharpClaw.Code.Permissions.Models; using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Abstractions; @@ -18,6 +19,7 @@ public sealed class ToolExecutor( IToolRegistry toolRegistry, IPermissionPolicyEngine permissionPolicyEngine, IRuntimeEventPublisher? eventPublisher = null, + IRuntimeHostContextAccessor? hostContextAccessor = null, ILogger? logger = null) : IToolExecutor { private readonly ILogger logger = logger ?? NullLogger.Instance; @@ -62,7 +64,8 @@ public async Task ExecuteAsync( TrustedMcpServerNames: context.TrustedMcpServerNames, ToolOriginatingPluginId: pluginSource?.PluginId, ToolOriginatingPluginTrust: pluginSource?.Trust, - PrimaryMode: context.PrimaryMode); + PrimaryMode: context.PrimaryMode, + TenantId: hostContextAccessor?.Current?.TenantId); var publishOptions = CreatePublishOptions(context); var now = DateTimeOffset.UtcNow; diff --git a/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs b/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs index d9fdcdd..0338b1a 100644 --- a/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs +++ b/src/SharpClaw.Code.Tools/Services/ToolPackageService.cs @@ -1,6 +1,8 @@ using System.Text; using System.Text.Json; +using System.IO.Compression; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; using SharpClaw.Code.Plugins.Abstractions; using SharpClaw.Code.Plugins.Models; using SharpClaw.Code.Protocol.Enums; @@ -17,8 +19,11 @@ public sealed class ToolPackageService( IFileSystem fileSystem, IPathService pathService, IRuntimeStoragePathResolver storagePathResolver, + IProcessRunner processRunner, IPluginManager pluginManager) : IToolPackageService { + private const string SupportedTargetFramework = "net10.0"; + /// public async Task> ListInstalledAsync(string workspaceRoot, CancellationToken cancellationToken) { @@ -58,16 +63,19 @@ public async Task InstallAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); - Validate(request.Manifest); var normalizedWorkspace = pathService.GetFullPath(workspaceRoot); + Validate(request.Manifest); + await EnsureNoToolNameConflictsAsync(normalizedWorkspace, request.Manifest, cancellationToken).ConfigureAwait(false); + var resolvedInstall = await ResolveInstallAsync(normalizedWorkspace, request, cancellationToken).ConfigureAwait(false); var packagesRoot = storagePathResolver.GetToolPackagesRoot(normalizedWorkspace); fileSystem.CreateDirectory(packagesRoot); var installed = new InstalledToolPackage( Manifest: request.Manifest, InstalledAtUtc: DateTimeOffset.UtcNow, - InstallSource: request.InstallSource.Trim()); + InstallSource: request.InstallSource.Trim(), + ResolvedInstall: resolvedInstall); var path = pathService.Combine(packagesRoot, $"{SanitizeFileName(request.Manifest.Package.PackageId)}.json"); await fileSystem .WriteAllTextAsync( @@ -76,13 +84,13 @@ await fileSystem cancellationToken) .ConfigureAwait(false); - var pluginManifest = ToPluginManifest(request.Manifest); + var pluginManifest = ToPluginManifest(request.Manifest, resolvedInstall); await pluginManager .InstallAsync( normalizedWorkspace, new PluginInstallRequest( pluginManifest, - JsonSerializer.Serialize(request.Manifest, ProtocolJsonContext.Default.ToolPackageManifest)), + JsonSerializer.Serialize(installed, ProtocolJsonContext.Default.InstalledToolPackage)), cancellationToken) .ConfigureAwait(false); if (request.EnableAfterInstall) @@ -93,14 +101,14 @@ await pluginManager return installed; } - private static PluginManifest ToPluginManifest(ToolPackageManifest manifest) + private static PluginManifest ToPluginManifest(ToolPackageManifest manifest, ToolPackageResolvedInstall resolvedInstall) => new( Id: manifest.Package.PackageId, Name: manifest.Package.PackageId, Version: manifest.Package.Version, Description: manifest.Description, - EntryPoint: manifest.Package.EntryAssembly, - Arguments: [], + EntryPoint: SelectPluginEntryPoint(resolvedInstall), + Arguments: SelectPluginArguments(resolvedInstall), Capabilities: [ "tool-package", @@ -132,11 +140,24 @@ private static void Validate(ToolPackageManifest manifest) ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Package.PackageId); ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Package.Version); ArgumentException.ThrowIfNullOrWhiteSpace(manifest.Package.EntryAssembly); + if (!string.IsNullOrWhiteSpace(manifest.Package.TargetFramework) + && !string.Equals(manifest.Package.TargetFramework, SupportedTargetFramework, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Tool package '{manifest.Package.PackageId}' targets '{manifest.Package.TargetFramework}', but this runtime only supports '{SupportedTargetFramework}'."); + } + if (manifest.Tools.Length == 0) { throw new InvalidOperationException("Tool packages must declare at least one tool."); } + foreach (var tool in manifest.Tools) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tool.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(tool.Description); + } + var duplicate = manifest.Tools .GroupBy(static tool => tool.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(static group => group.Count() > 1); @@ -146,6 +167,195 @@ private static void Validate(ToolPackageManifest manifest) } } + private async Task EnsureNoToolNameConflictsAsync( + string workspaceRoot, + ToolPackageManifest manifest, + CancellationToken cancellationToken) + { + var installed = await ListInstalledAsync(workspaceRoot, cancellationToken).ConfigureAwait(false); + var existingToolNames = installed + .Where(package => !string.Equals(package.Manifest.Package.PackageId, manifest.Package.PackageId, StringComparison.OrdinalIgnoreCase)) + .SelectMany(static package => package.Manifest.Tools) + .Select(static tool => tool.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var conflictingTool = manifest.Tools.FirstOrDefault(tool => existingToolNames.Contains(tool.Name)); + if (conflictingTool is not null) + { + throw new InvalidOperationException( + $"Tool '{conflictingTool.Name}' is already installed from another package and cannot be activated twice."); + } + } + + private async Task ResolveInstallAsync( + string workspaceRoot, + ToolPackageInstallRequest request, + CancellationToken cancellationToken) + { + if (string.Equals(request.Manifest.Package.PackageType, "nuget", StringComparison.OrdinalIgnoreCase)) + { + return await ResolveNugetInstallAsync(workspaceRoot, request, cancellationToken).ConfigureAwait(false); + } + + var resolvedEntryAssembly = ResolveLocalEntryAssembly(request.Manifest.Package.EntryAssembly, request.SourceReference); + return new ToolPackageResolvedInstall( + SourceReference: request.SourceReference, + PackageSource: request.PackageSource, + PackageFilePath: null, + ExtractedPackageRoot: null, + ResolvedEntryAssembly: resolvedEntryAssembly, + ResolvedEntryArguments: request.Manifest.Package.EntryArguments); + } + + private async Task ResolveNugetInstallAsync( + string workspaceRoot, + ToolPackageInstallRequest request, + CancellationToken cancellationToken) + { + var packageId = request.Manifest.Package.PackageId; + var version = request.Manifest.Package.Version; + var packagesRoot = storagePathResolver.GetToolPackagesRoot(workspaceRoot); + fileSystem.CreateDirectory(packagesRoot); + + var packageFilePath = pathService.Combine(packagesRoot, $"{SanitizeFileName(packageId)}-{SanitizeFileName(version)}.nupkg"); + if (!string.IsNullOrWhiteSpace(request.SourceReference) + && Path.GetExtension(request.SourceReference) is ".nupkg" + && fileSystem.FileExists(request.SourceReference)) + { + await fileSystem.CopyFileAsync(request.SourceReference, packageFilePath, cancellationToken).ConfigureAwait(false); + } + else + { + var downloadRoot = pathService.Combine(packagesRoot, ".downloads", $"{SanitizeFileName(packageId)}-{Guid.NewGuid():N}"); + fileSystem.CreateDirectory(downloadRoot); + var arguments = new List + { + "nuget", + "download", + packageId, + "--version", + version, + "--output", + downloadRoot, + }; + if (!string.IsNullOrWhiteSpace(request.PackageSource)) + { + arguments.Add("--source"); + arguments.Add(request.PackageSource!); + } + + var result = await processRunner + .RunAsync( + new ProcessRunRequest( + FileName: "dotnet", + Arguments: [.. arguments], + WorkingDirectory: workspaceRoot, + EnvironmentVariables: null), + cancellationToken) + .ConfigureAwait(false); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(result.StandardError) + ? $"dotnet nuget download failed for '{packageId}'." + : result.StandardError.Trim()); + } + + var downloadedPackage = fileSystem.EnumerateFiles(downloadRoot, "*.nupkg").FirstOrDefault() + ?? throw new InvalidOperationException($"No .nupkg was downloaded for '{packageId}'."); + await fileSystem.CopyFileAsync(downloadedPackage, packageFilePath, cancellationToken).ConfigureAwait(false); + fileSystem.DeleteDirectoryRecursive(downloadRoot); + } + + var extractedPackageRoot = storagePathResolver.GetExtractedToolPackageRoot(workspaceRoot, packageId, version); + if (fileSystem.DirectoryExists(extractedPackageRoot)) + { + fileSystem.DeleteDirectoryRecursive(extractedPackageRoot); + } + + fileSystem.CreateDirectory(extractedPackageRoot); + ZipFile.ExtractToDirectory(packageFilePath, extractedPackageRoot, overwriteFiles: true); + + var resolvedEntryAssembly = ResolveExtractedEntryAssembly(request.Manifest.Package.EntryAssembly, extractedPackageRoot); + EnsureUnixExecuteBit(resolvedEntryAssembly); + return new ToolPackageResolvedInstall( + SourceReference: request.SourceReference, + PackageSource: request.PackageSource, + PackageFilePath: packageFilePath, + ExtractedPackageRoot: extractedPackageRoot, + ResolvedEntryAssembly: resolvedEntryAssembly, + ResolvedEntryArguments: request.Manifest.Package.EntryArguments); + } + + private string ResolveLocalEntryAssembly(string entryAssembly, string? sourceReference) + { + if (Path.IsPathRooted(entryAssembly) || string.IsNullOrWhiteSpace(sourceReference)) + { + return entryAssembly; + } + + if (fileSystem.DirectoryExists(sourceReference)) + { + return pathService.GetFullPath(pathService.Combine(sourceReference, entryAssembly)); + } + + var sourceDirectory = Path.GetDirectoryName(sourceReference); + return string.IsNullOrWhiteSpace(sourceDirectory) + ? entryAssembly + : pathService.GetFullPath(pathService.Combine(sourceDirectory, entryAssembly)); + } + + private string ResolveExtractedEntryAssembly(string entryAssembly, string extractedPackageRoot) + { + if (Path.IsPathRooted(entryAssembly)) + { + return entryAssembly; + } + + return pathService.GetFullPath(pathService.Combine(extractedPackageRoot, entryAssembly)); + } + + private static string SelectPluginEntryPoint(ToolPackageResolvedInstall resolvedInstall) + => string.Equals(Path.GetExtension(resolvedInstall.ResolvedEntryAssembly), ".dll", StringComparison.OrdinalIgnoreCase) + ? "dotnet" + : resolvedInstall.ResolvedEntryAssembly; + + private static string[] SelectPluginArguments(ToolPackageResolvedInstall resolvedInstall) + { + if (string.Equals(Path.GetExtension(resolvedInstall.ResolvedEntryAssembly), ".dll", StringComparison.OrdinalIgnoreCase)) + { + return [resolvedInstall.ResolvedEntryAssembly, .. resolvedInstall.ResolvedEntryArguments ?? []]; + } + + return resolvedInstall.ResolvedEntryArguments ?? []; + } + + private static void EnsureUnixExecuteBit(string path) + { + if (OperatingSystem.IsWindows() + || string.Equals(Path.GetExtension(path), ".dll", StringComparison.OrdinalIgnoreCase) + || !File.Exists(path)) + { + return; + } + + try + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute); + } + catch (PlatformNotSupportedException) + { + } + } + private static string SanitizeFileName(string value) { var invalid = Path.GetInvalidFileNameChars().ToHashSet(); diff --git a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs index 992c2cc..0a7d4e3 100644 --- a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ using SharpClaw.Code.Telemetry; using SharpClaw.Code.Telemetry.Abstractions; using SharpClaw.Code.Web; +using SharpClaw.Code.Protocol.Abstractions; namespace SharpClaw.Code.Tools; @@ -89,7 +90,8 @@ private static IServiceCollection AddSharpClawToolsCore(IServiceCollection servi services.AddSingleton(serviceProvider => new ToolExecutor( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), - serviceProvider.GetService())); + serviceProvider.GetService(), + serviceProvider.GetService())); services.AddSingleton(); return services; } diff --git a/src/SharpClaw.Code/SharpClawRuntimeHost.cs b/src/SharpClaw.Code/SharpClawRuntimeHost.cs index 22a7583..9d75c49 100644 --- a/src/SharpClaw.Code/SharpClawRuntimeHost.cs +++ b/src/SharpClaw.Code/SharpClawRuntimeHost.cs @@ -97,6 +97,35 @@ public Task ExecutePromptAsync( CancellationToken cancellationToken = default) => runtimeCommandService.ExecutePromptAsync(prompt, context, cancellationToken); + /// + /// Executes a prompt through the runtime command service without requiring callers to construct runtime-specific context types. + /// + public Task ExecutePromptAsync( + string prompt, + string workspacePath, + string? model, + PermissionMode permissionMode, + OutputFormat outputFormat, + string? sessionId = null, + RuntimeHostContext? hostContext = null, + PrimaryMode? primaryMode = null, + string? agentId = null, + bool isInteractive = true, + CancellationToken cancellationToken = default) + => runtimeCommandService.ExecutePromptAsync( + prompt, + new RuntimeCommandContext( + WorkingDirectory: workspacePath, + Model: model, + PermissionMode: permissionMode, + OutputFormat: outputFormat, + PrimaryMode: primaryMode, + SessionId: sessionId, + AgentId: agentId, + IsInteractive: isInteractive, + HostContext: hostContext), + cancellationToken); + /// /// Retrieves the runtime status report. /// diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs new file mode 100644 index 0000000..a97fef3 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs @@ -0,0 +1,317 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using SharpClaw.Code.MockProvider; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Runtime; + +/// +/// Verifies OIDC-backed admin auth and approval enforcement for the embedded HTTP server. +/// +public sealed class ApprovalAuthIntegrationTests +{ + /// + /// Ensures the embedded HTTP server enforces OIDC identity for admin routes and approval-gated prompt references. + /// + [Fact] + public async Task Embedded_http_server_should_enforce_oidc_admin_and_prompt_approval_auth() + { + await using var authority = new TestOidcAuthority(); + var workspaceRoot = CreateTemporaryWorkspace(); + var outsideFile = Path.Combine(Path.GetTempPath(), "sharpclaw-approval-auth-targets", Guid.NewGuid().ToString("N"), "secret.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(outsideFile)!); + await File.WriteAllTextAsync(outsideFile, "secret outside the workspace"); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "sharpclaw.jsonc"), + $$""" + { + "server": { + "host": "127.0.0.1", + "port": 7345, + "approvalAuth": { + "mode": "oidc", + "authority": "{{authority.AuthorityUrl}}", + "audience": "{{authority.Audience}}", + "requireForAdmin": true, + "requireAuthenticatedApprovals": true + } + } + } + """); + + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddDeterministicMockModelProvider(); + using var serviceProvider = services.BuildServiceProvider(); + + var server = serviceProvider.GetRequiredService(); + var port = FindFreePort(); + using var serverCts = new CancellationTokenSource(); + var serverTask = server.RunAsync( + workspaceRoot, + "127.0.0.1", + port, + new RuntimeCommandContext( + WorkingDirectory: workspaceRoot, + Model: "default", + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Json), + serverCts.Token); + + try + { + using var httpClient = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{port}/"), + Timeout = TimeSpan.FromSeconds(10), + }; + await WaitForServerAsync(httpClient, CancellationToken.None); + + using var unauthenticatedAdminResponse = await httpClient.GetAsync("v1/admin/providers", CancellationToken.None); + unauthenticatedAdminResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + using var authenticatedAdminRequest = new HttpRequestMessage(HttpMethod.Get, "v1/admin/providers"); + authenticatedAdminRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authority.CreateToken("alice", "tenant-a")); + using var authenticatedAdminResponse = await httpClient.SendAsync(authenticatedAdminRequest, CancellationToken.None); + authenticatedAdminResponse.EnsureSuccessStatusCode(); + + using var missingApprovalIdentityRequest = BuildPromptRequest(outsideFile, tenantId: "tenant-a"); + using var missingApprovalIdentityResponse = await httpClient.SendAsync(missingApprovalIdentityRequest, CancellationToken.None); + missingApprovalIdentityResponse.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + (await ReadErrorAsync(missingApprovalIdentityResponse)).Should().Contain("Authenticated approval is required"); + + using var approvedPromptRequest = BuildPromptRequest(outsideFile, tenantId: "tenant-a"); + approvedPromptRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authority.CreateToken("alice", "tenant-a")); + using var approvedPromptResponse = await httpClient.SendAsync(approvedPromptRequest, CancellationToken.None); + approvedPromptResponse.EnsureSuccessStatusCode(); + + using var wrongTenantPromptRequest = BuildPromptRequest(outsideFile, tenantId: "tenant-b"); + wrongTenantPromptRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authority.CreateToken("alice", "tenant-a")); + using var wrongTenantPromptResponse = await httpClient.SendAsync(wrongTenantPromptRequest, CancellationToken.None); + wrongTenantPromptResponse.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + (await ReadErrorAsync(wrongTenantPromptResponse)).Should().Contain("does not match runtime tenant"); + } + finally + { + serverCts.Cancel(); + await serverTask; + } + } + + private static HttpRequestMessage BuildPromptRequest(string outsideFile, string tenantId) + { + var payload = new ServerPromptRequest( + Prompt: $"inspect @{outsideFile}", + SessionId: null, + Model: "default", + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Json, + PrimaryMode: PrimaryMode.Build, + AgentId: null, + TenantId: tenantId); + + return new HttpRequestMessage(HttpMethod.Post, "v1/prompt") + { + Content = new StringContent( + JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.ServerPromptRequest), + Encoding.UTF8, + "application/json"), + }; + } + + private static async Task ReadErrorAsync(HttpResponseMessage response) + { + var content = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(content); + return document.RootElement.TryGetProperty("error", out var error) + ? error.GetString() ?? string.Empty + : content; + } + + private static async Task WaitForServerAsync(HttpClient httpClient, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 30; attempt++) + { + try + { + using var response = await httpClient.GetAsync("v1/status", cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + } + catch (HttpRequestException) + { + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException("Embedded workspace HTTP server did not become ready."); + } + + private static int FindFreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static string CreateTemporaryWorkspace() + { + var path = Path.Combine(Path.GetTempPath(), "sharpclaw-approval-auth-server", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private sealed class TestOidcAuthority : IAsyncDisposable + { + private readonly HttpListener listener = new(); + private readonly CancellationTokenSource cancellationTokenSource = new(); + private readonly RSA rsa = RSA.Create(2048); + private readonly Task serverTask; + private readonly RsaSecurityKey signingKey; + + public TestOidcAuthority() + { + var port = FindFreePort(); + AuthorityUrl = $"http://127.0.0.1:{port}"; + Audience = "sharpclaw-tests"; + signingKey = new RsaSecurityKey(rsa) { KeyId = Guid.NewGuid().ToString("N") }; + + listener.Prefixes.Add($"{AuthorityUrl}/"); + listener.Start(); + serverTask = Task.Run(() => RunAsync(cancellationTokenSource.Token)); + } + + public string AuthorityUrl { get; } + + public string Audience { get; } + + public string CreateToken(string subjectId, string tenantId) + { + var claims = new List + { + new("sub", subjectId), + new("name", $"User {subjectId}"), + new("tid", tenantId), + new("scope", "approvals:write approvals:read"), + new("role", "approver"), + }; + + var descriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Audience = Audience, + Issuer = AuthorityUrl, + Expires = DateTime.UtcNow.AddMinutes(10), + NotBefore = DateTime.UtcNow.AddMinutes(-1), + SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256), + }; + + return new JsonWebTokenHandler().CreateToken(descriptor); + } + + public async ValueTask DisposeAsync() + { + cancellationTokenSource.Cancel(); + if (listener.IsListening) + { + listener.Stop(); + } + + try + { + await serverTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (HttpListenerException) + { + } + + listener.Close(); + rsa.Dispose(); + cancellationTokenSource.Dispose(); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await listener.GetContextAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (HttpListenerException) when (cancellationToken.IsCancellationRequested) + { + break; + } + + _ = Task.Run(() => HandleAsync(context, cancellationToken), CancellationToken.None); + } + } + + private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + var path = context.Request.Url?.AbsolutePath ?? "/"; + object payload = path switch + { + "/.well-known/openid-configuration" => new + { + issuer = AuthorityUrl, + jwks_uri = $"{AuthorityUrl}/.well-known/jwks.json", + }, + "/.well-known/jwks.json" => new + { + keys = new[] + { + new + { + kty = "RSA", + use = "sig", + kid = signingKey.KeyId, + alg = "RS256", + n = Base64UrlEncoder.Encode(rsa.ExportParameters(false).Modulus!), + e = Base64UrlEncoder.Encode(rsa.ExportParameters(false).Exponent!), + } + } + }, + _ => new { error = "not-found" } + }; + + var statusCode = path is "/.well-known/openid-configuration" or "/.well-known/jwks.json" ? 200 : 404; + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + await using var writer = new StreamWriter(context.Response.OutputStream, new UTF8Encoding(false), leaveOpen: true); + await writer.WriteAsync(JsonSerializer.Serialize(payload).AsMemory(), cancellationToken).ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + context.Response.Close(); + } + } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs index d78f698..cc20030 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceHttpServerAdminTests.cs @@ -15,15 +15,15 @@ namespace SharpClaw.Code.IntegrationTests.Runtime; /// -/// Verifies the embedded admin HTTP surface for provider, index, package, and event inspection. +/// Verifies the embedded admin HTTP surface for provider, session, usage, package, and event inspection. /// public sealed class WorkspaceHttpServerAdminTests { /// - /// Ensures the admin server exposes provider catalog, index, package, and recent-event payloads. + /// Ensures the admin server exposes provider catalog, session management, usage, package, and recent-event payloads. /// [Fact] - public async Task Admin_endpoints_should_expose_provider_index_package_and_event_data() + public async Task Admin_endpoints_should_expose_provider_session_usage_package_and_event_data() { var workspaceRoot = CreateTemporaryWorkspace(); await File.WriteAllTextAsync(Path.Combine(workspaceRoot, "README.md"), "Workspace admin search content."); @@ -59,6 +59,32 @@ public async Task Admin_endpoints_should_expose_provider_index_package_and_event var providersJson = await httpClient.GetStringAsync("v1/admin/providers"); providersJson.Should().Contain("mock"); + var authStatus = JsonSerializer.Deserialize( + await httpClient.GetStringAsync("v1/admin/auth/status"), + ProtocolJsonContext.Default.ApprovalAuthStatus); + authStatus.Should().NotBeNull(); + + using var createSessionResponse = await httpClient.PostAsync( + "v1/admin/sessions", + new StringContent("""{"permissionMode":"workspaceWrite","outputFormat":"json"}""", Encoding.UTF8, "application/json"), + CancellationToken.None); + createSessionResponse.EnsureSuccessStatusCode(); + var createdSession = JsonSerializer.Deserialize( + await createSessionResponse.Content.ReadAsStringAsync(), + ProtocolJsonContext.Default.ConversationSession); + createdSession.Should().NotBeNull(); + + using var forkSessionResponse = await httpClient.PostAsync( + $"v1/admin/sessions/{createdSession!.Id}/fork", + new StringContent(string.Empty, Encoding.UTF8, "application/json"), + CancellationToken.None); + forkSessionResponse.EnsureSuccessStatusCode(); + var forkedSession = JsonSerializer.Deserialize( + await forkSessionResponse.Content.ReadAsStringAsync(), + ProtocolJsonContext.Default.ConversationSession); + forkedSession.Should().NotBeNull(); + forkedSession!.Id.Should().NotBe(createdSession.Id); + using var refreshResponse = await httpClient.PostAsync("v1/admin/index/refresh", new StringContent(string.Empty), CancellationToken.None); refreshResponse.EnsureSuccessStatusCode(); var refresh = JsonSerializer.Deserialize( @@ -91,7 +117,7 @@ await installResponse.Content.ReadAsStringAsync(), using var promptResponse = await httpClient.PostAsync( "v1/prompt", - new StringContent("""{"prompt":"run the admin server flow","model":"default"}""", Encoding.UTF8, "application/json"), + new StringContent($$"""{"prompt":"run the admin server flow","model":"default","sessionId":"{{createdSession.Id}}"}""", Encoding.UTF8, "application/json"), CancellationToken.None); promptResponse.EnsureSuccessStatusCode(); var promptResult = JsonSerializer.Deserialize( @@ -99,6 +125,20 @@ await promptResponse.Content.ReadAsStringAsync(), ProtocolJsonContext.Default.TurnExecutionResult); promptResult.Should().NotBeNull(); + var usageSummary = JsonSerializer.Deserialize( + await httpClient.GetStringAsync($"v1/admin/usage/summary?sessionId={Uri.EscapeDataString(createdSession.Id)}"), + ProtocolJsonContext.Default.UsageMeteringSummaryReport); + usageSummary.Should().NotBeNull(); + usageSummary!.ProviderRequestCount.Should().BeGreaterThan(0); + usageSummary.TurnCount.Should().BeGreaterThan(0); + + var usageDetail = JsonSerializer.Deserialize( + await httpClient.GetStringAsync($"v1/admin/usage/detail?sessionId={Uri.EscapeDataString(createdSession.Id)}&limit=10"), + ProtocolJsonContext.Default.UsageMeteringDetailReport); + usageDetail.Should().NotBeNull(); + usageDetail!.Records.Should().Contain(record => record.Kind == UsageMeteringRecordKind.ProviderUsage); + usageDetail.Records.Should().Contain(record => record.Kind == UsageMeteringRecordKind.TurnExecution); + var events = JsonSerializer.Deserialize( await httpClient.GetStringAsync("v1/admin/events/recent"), ProtocolJsonContext.Default.ListRuntimeEventEnvelope); diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs new file mode 100644 index 0000000..45daaf7 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/ConfiguredApprovalIdentityServiceTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Configuration; +using SharpClaw.Code.Runtime.Server; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Verifies trusted-header approval identity resolution and status reporting. +/// +public sealed class ConfiguredApprovalIdentityServiceTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-approval-auth", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Trusted_header_mode_should_map_subject_tenant_roles_and_scopes() + { + Directory.CreateDirectory(workspaceRoot); + await File.WriteAllTextAsync( + Path.Combine(workspaceRoot, "sharpclaw.jsonc"), + """ + { + "server": { + "host": "127.0.0.1", + "port": 7345, + "approvalAuth": { + "mode": "trustedHeader", + "requireForAdmin": true, + "requireAuthenticatedApprovals": true + } + } + } + """); + + var service = new ConfiguredApprovalIdentityService(new SharpClawConfigService(new SharpClaw.Code.Infrastructure.Services.LocalFileSystem(), new SharpClaw.Code.Infrastructure.Services.PathService())); + var status = await service.GetStatusAsync(workspaceRoot, CancellationToken.None); + var principal = await service.ResolveAsync( + workspaceRoot, + new ApprovalIdentityRequest( + AuthorizationHeader: null, + Headers: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-SharpClaw-User"] = "alice", + ["X-SharpClaw-Display-Name"] = "Alice Example", + ["X-SharpClaw-Tenant-Id"] = "tenant-a", + ["X-SharpClaw-Roles"] = "approver,admin", + ["X-SharpClaw-Scopes"] = "approvals:write approvals:read", + }), + new RuntimeHostContext("host-a", "tenant-a"), + CancellationToken.None); + + status.Mode.Should().Be(ApprovalAuthMode.TrustedHeader); + status.RequireForAdmin.Should().BeTrue(); + status.RequireAuthenticatedApprovals.Should().BeTrue(); + principal.Should().NotBeNull(); + principal!.SubjectId.Should().Be("alice"); + principal.DisplayName.Should().Be("Alice Example"); + principal.TenantId.Should().Be("tenant-a"); + principal.Roles.Should().BeEquivalentTo(["approver", "admin"]); + principal.Scopes.Should().BeEquivalentTo(["approvals:write", "approvals:read"]); + principal.AuthenticationType.Should().Be("trusted-header"); + } + + public void Dispose() + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs new file mode 100644 index 0000000..fd42bdb --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs @@ -0,0 +1,175 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Server; +using SharpClaw.Code.Telemetry.Services; +using SharpClaw.Code.UnitTests.Support; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies event-driven usage metering persistence and filtering. +/// +public sealed class UsageMeteringServiceTests : IDisposable +{ + private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-metering", Guid.NewGuid().ToString("N")); + private readonly string userRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-metering-user", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Usage_metering_should_aggregate_usage_and_filter_by_time_window() + { + Directory.CreateDirectory(workspaceRoot); + Directory.CreateDirectory(userRoot); + + var store = new SqliteUsageMeteringStore( + new LocalFileSystem(), + new PathService(), + TestRuntimeStorageResolver.Create(userRoot)); + var metering = new UsageMeteringService(store); + var startedAt = DateTimeOffset.Parse("2026-04-16T18:00:00Z"); + var turn = new ConversationTurn( + Id: "turn-1", + SessionId: "session-1", + SequenceNumber: 1, + Input: "Build the feature", + Output: "Done.", + StartedAtUtc: startedAt, + CompletedAtUtc: startedAt.AddMilliseconds(120), + AgentId: "primary", + SlashCommandName: null, + Usage: new UsageSnapshot(10, 6, 0, 16, 0.24m), + Metadata: null); + var providerRequest = new ProviderRequest( + Id: "provider-1", + SessionId: "session-1", + TurnId: "turn-1", + ProviderName: "mock", + Model: "default", + Prompt: "Build the feature", + SystemPrompt: null, + OutputFormat: OutputFormat.Text, + Temperature: null, + Metadata: null); + + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ProviderStartedEvent), + OccurredAtUtc: startedAt, + Event: new ProviderStartedEvent("evt-provider-start", "session-1", "turn-1", startedAt, "mock", "default", providerRequest), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ProviderCompletedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(50), + Event: new ProviderCompletedEvent( + "evt-provider-complete", + "session-1", + "turn-1", + startedAt.AddMilliseconds(50), + "mock", + "default", + "provider-terminal", + "completed", + new UsageSnapshot(10, 6, 0, 16, 0.24m)), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ToolStartedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(60), + Event: new ToolStartedEvent( + "evt-tool-start", + "session-1", + "turn-1", + startedAt.AddMilliseconds(60), + new ToolExecutionRequest( + "tool-1", + "session-1", + "turn-1", + "write_file", + """{"path":"notes.txt","content":"ok"}""", + ApprovalScope.FileSystemWrite, + workspaceRoot, + RequiresApproval: true, + IsDestructive: true)), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(ToolCompletedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(90), + Event: new ToolCompletedEvent( + "evt-tool-complete", + "session-1", + "turn-1", + startedAt.AddMilliseconds(90), + new ToolResult("tool-1", "write_file", true, OutputFormat.Text, "ok", null, 0, null, null)), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + await metering.PublishAsync( + new RuntimeEventEnvelope( + EventType: nameof(TurnCompletedEvent), + OccurredAtUtc: startedAt.AddMilliseconds(120), + Event: new TurnCompletedEvent("evt-turn-complete", "session-1", "turn-1", startedAt.AddMilliseconds(120), turn, true, "success"), + WorkspacePath: workspaceRoot, + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"), + CancellationToken.None); + + var summary = await metering.GetSummaryAsync( + workspaceRoot, + new UsageMeteringQuery(TenantId: "tenant-a", HostId: "host-a", WorkspaceRoot: workspaceRoot), + CancellationToken.None); + var detail = await metering.GetDetailAsync( + workspaceRoot, + new UsageMeteringQuery(WorkspaceRoot: workspaceRoot), + 20, + CancellationToken.None); + var filtered = await metering.GetSummaryAsync( + workspaceRoot, + new UsageMeteringQuery(FromUtc: startedAt.AddMilliseconds(55), WorkspaceRoot: workspaceRoot), + CancellationToken.None); + + summary.TotalUsage.TotalTokens.Should().Be(16); + summary.ProviderRequestCount.Should().Be(1); + summary.ToolExecutionCount.Should().Be(1); + summary.TurnCount.Should().Be(1); + detail.Records.Should().Contain(record => record.Kind == UsageMeteringRecordKind.ProviderUsage && record.DurationMilliseconds == 50); + detail.Records.Should().Contain(record => + record.Kind == UsageMeteringRecordKind.ToolExecution + && record.DurationMilliseconds == 30 + && record.ApprovalScope == ApprovalScope.FileSystemWrite); + filtered.TotalUsage.TotalTokens.Should().Be(0); + filtered.ProviderRequestCount.Should().Be(0); + filtered.ToolExecutionCount.Should().Be(1); + } + + public void Dispose() + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + + if (Directory.Exists(userRoot)) + { + Directory.Delete(userRoot, recursive: true); + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs index 7ad4b6b..90377fc 100644 --- a/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Tools/ToolPackageServiceTests.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using SharpClaw.Code.Plugins.Abstractions; @@ -15,33 +16,139 @@ public sealed class ToolPackageServiceTests : IDisposable private readonly string workspaceRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-tool-packages", Guid.NewGuid().ToString("N")); [Fact] - public async Task Tool_package_service_should_install_and_list_workspace_packages() + public async Task Tool_package_service_should_install_local_packages_with_resolved_entry_metadata() { Directory.CreateDirectory(workspaceRoot); - var services = new ServiceCollection(); - services.AddSharpClawTools(); - using var serviceProvider = services.BuildServiceProvider(); + var sourceRoot = CreatePackageSource("local", "bin/widget-tool.dll"); + using var serviceProvider = CreateServiceProvider(); var packageService = serviceProvider.GetRequiredService(); var pluginManager = serviceProvider.GetRequiredService(); var request = new ToolPackageInstallRequest( new ToolPackageManifest( - new ToolPackageReference("acme.widgets", "1.2.3", "local", "widget-tool"), + new ToolPackageReference( + "acme.widgets", + "1.2.3", + "local", + "bin/widget-tool.dll", + EntryArguments: ["--mode", "serve"], + TargetFramework: "net10.0"), "acme", "Widget helper tools", [new PackagedToolDescriptor("widget_lookup", "Looks up widgets", """{"type":"object"}""", Tags: ["widgets"])]), InstallSource: "unit-test", - EnableAfterInstall: false); + EnableAfterInstall: false, + SourceReference: sourceRoot); var installed = await packageService.InstallAsync(workspaceRoot, request, CancellationToken.None); var listed = await packageService.ListInstalledAsync(workspaceRoot, CancellationToken.None); var plugins = await pluginManager.ListAsync(workspaceRoot, CancellationToken.None); installed.Manifest.Package.PackageId.Should().Be("acme.widgets"); + installed.ResolvedInstall.Should().NotBeNull(); + installed.ResolvedInstall!.ResolvedEntryAssembly.Should().Be(Path.Combine(sourceRoot, "bin", "widget-tool.dll")); + installed.ResolvedInstall.ResolvedEntryArguments.Should().BeEquivalentTo(["--mode", "serve"]); listed.Should().ContainSingle(package => package.Manifest.Package.PackageId == "acme.widgets"); plugins.Should().ContainSingle(plugin => plugin.Descriptor.Id == "acme.widgets"); } + [Fact] + public async Task Tool_package_service_should_install_nuget_packages_from_local_archives() + { + Directory.CreateDirectory(workspaceRoot); + var archivePath = CreateNuGetArchive("nuget", "tools/echo.sh"); + using var serviceProvider = CreateServiceProvider(); + + var packageService = serviceProvider.GetRequiredService(); + var request = new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference( + "acme.echo", + "2.0.0", + "nuget", + "tools/echo.sh", + TargetFramework: "net10.0"), + "acme", + "Echo tool package", + [new PackagedToolDescriptor("echo_tool", "Echoes content", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: archivePath, + PackageSource: "local-archive"); + + var installed = await packageService.InstallAsync(workspaceRoot, request, CancellationToken.None); + + installed.ResolvedInstall.Should().NotBeNull(); + installed.ResolvedInstall!.PackageFilePath.Should().NotBeNull(); + installed.ResolvedInstall.ExtractedPackageRoot.Should().NotBeNull(); + installed.ResolvedInstall.ResolvedEntryAssembly.Should().EndWith(Path.Combine("tools", "echo.sh")); + File.Exists(installed.ResolvedInstall.PackageFilePath!).Should().BeTrue(); + File.Exists(installed.ResolvedInstall.ResolvedEntryAssembly).Should().BeTrue(); + } + + [Fact] + public async Task Tool_package_service_should_reject_duplicate_tool_names_across_packages() + { + Directory.CreateDirectory(workspaceRoot); + var sourceRoot = CreatePackageSource("conflict", "tool.dll"); + using var serviceProvider = CreateServiceProvider(); + var packageService = serviceProvider.GetRequiredService(); + + await packageService.InstallAsync( + workspaceRoot, + new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.first", "1.0.0", "local", "tool.dll", TargetFramework: "net10.0"), + "acme", + "First package", + [new PackagedToolDescriptor("shared_tool", "Shared tool", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot), + CancellationToken.None); + + var act = () => packageService.InstallAsync( + workspaceRoot, + new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.second", "1.0.0", "local", "tool.dll", TargetFramework: "net10.0"), + "acme", + "Second package", + [new PackagedToolDescriptor("shared_tool", "Shared tool again", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*shared_tool*already installed*"); + } + + [Fact] + public async Task Tool_package_service_should_reject_unsupported_target_frameworks() + { + Directory.CreateDirectory(workspaceRoot); + var sourceRoot = CreatePackageSource("framework", "tool.dll"); + using var serviceProvider = CreateServiceProvider(); + var packageService = serviceProvider.GetRequiredService(); + + var act = () => packageService.InstallAsync( + workspaceRoot, + new ToolPackageInstallRequest( + new ToolPackageManifest( + new ToolPackageReference("acme.legacy", "1.0.0", "local", "tool.dll", TargetFramework: "net8.0"), + "acme", + "Legacy package", + [new PackagedToolDescriptor("legacy_tool", "Legacy tool", """{"type":"object"}""")]), + InstallSource: "unit-test", + EnableAfterInstall: false, + SourceReference: sourceRoot), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*net8.0*net10.0*"); + } + public void Dispose() { if (Directory.Exists(workspaceRoot)) @@ -49,4 +156,28 @@ public void Dispose() Directory.Delete(workspaceRoot, recursive: true); } } + + private static ServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + services.AddSharpClawTools(); + return services.BuildServiceProvider(); + } + + private string CreatePackageSource(string name, string relativeEntryAssembly) + { + var root = Path.Combine(workspaceRoot, name); + var fullEntryAssemblyPath = Path.Combine(root, relativeEntryAssembly.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullEntryAssemblyPath)!); + File.WriteAllText(fullEntryAssemblyPath, "placeholder"); + return root; + } + + private string CreateNuGetArchive(string name, string relativeEntryAssembly) + { + var sourceRoot = CreatePackageSource(name, relativeEntryAssembly); + var archivePath = Path.Combine(workspaceRoot, $"{name}.nupkg"); + ZipFile.CreateFromDirectory(sourceRoot, archivePath); + return archivePath; + } } From 2c1e8d41c0f7f1b43c3fb4eddc9d2e9cac5249b4 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 20 Apr 2026 18:01:55 -0700 Subject: [PATCH 4/8] feat: add enterprise cli follow-through and docs parity --- .github/workflows/ci.yml | 6 + README.md | 44 +++- SharpClawCode.sln | 62 ++++++ docs/architecture.md | 10 +- docs/permissions.md | 18 +- docs/runtime.md | 25 +++ docs/testing.md | 11 +- .../CliServiceCollectionExtensions.cs | 1 + .../CliCommandFactory.cs | 12 +- .../Handlers/CompactCommandHandler.cs | 14 +- .../Handlers/DoctorCommandHandler.cs | 13 +- .../Handlers/EditorSlashCommandHandler.cs | 11 +- .../Handlers/ExportSlashCommandHandler.cs | 11 +- .../Handlers/PromptCommandHandler.cs | 13 +- .../Handlers/RedoCommandHandler.cs | 13 +- .../Handlers/ServeCommandHandler.cs | 14 +- .../Handlers/SessionCommandHandler.cs | 41 ++-- .../Handlers/ShareCommandHandler.cs | 14 +- .../Handlers/StatusCommandHandler.cs | 13 +- .../Handlers/ToolPackagesCommandHandler.cs | 152 ++++++++++++++ .../Handlers/UndoCommandHandler.cs | 13 +- .../Handlers/UnshareCommandHandler.cs | 14 +- .../Handlers/UsageCommandHandler.cs | 122 ++++++++++- .../Models/CommandExecutionContext.cs | 29 ++- .../Options/GlobalCliOptions.cs | 106 +++++++++- src/SharpClaw.Code.Commands/Repl/ReplHost.cs | 23 +-- .../SharpClaw.Code.Commands.csproj | 2 + .../Models/ApprovalDecision.cs | 1 + .../Models/OpenCodeParityModels.cs | 2 + .../Models/Phase2Models.cs | 4 + .../Abstractions/IWebhookDelayStrategy.cs | 14 ++ .../Diagnostics/ProviderActivityScope.cs | 16 ++ .../Diagnostics/TurnActivityScope.cs | 17 ++ .../Metrics/SharpClawMeterSource.cs | 3 + .../Services/RuntimeEventPublisher.cs | 2 + .../Services/WebhookDelayStrategy.cs | 13 ++ .../Services/WebhookRuntimeEventSink.cs | 10 +- .../SharpClaw.Code.Telemetry.csproj | 2 + .../TelemetryServiceCollectionExtensions.cs | 12 +- .../Runtime/ApprovalAuthIntegrationTests.cs | 49 +++-- .../Smoke/CliCommandSurfaceTests.cs | 7 +- .../Commands/FeatureCommandHandlersTests.cs | 191 +++++++++++++++++- .../Commands/ModeAndCliOptionsTests.cs | 23 +++ .../Telemetry/WebhookRuntimeEventSinkTests.cs | 148 ++++++++++++++ 44 files changed, 1112 insertions(+), 209 deletions(-) create mode 100644 src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs create mode 100644 src/SharpClaw.Code.Telemetry/Abstractions/IWebhookDelayStrategy.cs create mode 100644 src/SharpClaw.Code.Telemetry/Services/WebhookDelayStrategy.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ca383a..824c9c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,12 @@ jobs: run: dotnet restore SharpClawCode.sln - name: Build run: dotnet build SharpClawCode.sln --no-restore --configuration Release + - name: Build examples + run: | + dotnet build examples/WebApiAgent/WebApiAgent.csproj --no-restore --configuration Release + dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj --no-restore --configuration Release + dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj --no-restore --configuration Release + dotnet build examples/McpToolAgent/McpToolAgent.csproj --no-restore --configuration Release - name: Test run: dotnet test SharpClawCode.sln --no-build --configuration Release --collect:"XPlat Code Coverage" --results-directory ./coverage - name: Upload coverage diff --git a/README.md b/README.md index ea5030a..02b732c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ SharpClaw Code is a C# and .NET-native coding agent runtime for teams building A It combines durable sessions, permission-aware tool execution, provider abstraction, structured telemetry, and an automation-friendly command-line surface in a runtime shaped for real .NET systems: explicit, testable, and operationally legible. +The repository now ships both a terminal-first agent runtime and an embeddable host SDK through `SharpClaw.Code`, which makes it viable for standalone CLIs, local editor backends, and tenant-aware embedded services. + ## What It Is SharpClaw Code is an open-source runtime for building and operating coding-agent experiences in the .NET ecosystem. @@ -73,6 +75,13 @@ dotnet run --project src/SharpClaw.Code.Cli -- index query WidgetService dotnet run --project src/SharpClaw.Code.Cli -- memory save --scope project "Keep prompts concise" dotnet run --project src/SharpClaw.Code.Cli -- memory list --scope project +# Inspect metering summaries and details +dotnet run --project src/SharpClaw.Code.Cli -- usage summary +dotnet run --project src/SharpClaw.Code.Cli -- usage detail --limit 25 + +# Manage packaged tool bundles +dotnet run --project src/SharpClaw.Code.Cli -- tool-packages list + # Emit machine-readable output dotnet run --project src/SharpClaw.Code.Cli -- --output-format json doctor ``` @@ -117,9 +126,10 @@ Primary workflow modes: | Workspace knowledge | Build a durable local index for lexical, symbol, and semantic workspace search | | Cross-session memory | Persist project and user memory so later sessions can recall repo-specific guidance and user preferences | | Structured telemetry | Emit runtime events and usage signals that support diagnostics, replay, and automation | +| Enterprise host controls | Add tenant-aware storage, authenticated approvals, admin APIs, and usage metering for embedded deployments | | JSON-friendly CLI | Use the same runtime through human-readable terminal flows or machine-readable command output | | Spec workflow mode | Turn prompts into structured requirements, technical design, and task documents for feature proposals | -| Embedded local server | Expose prompt, session, status, doctor, and share endpoints for editor or automation clients | +| Embedded SDK + server | Host the runtime via `SharpClaw.Code` or expose prompt, session, admin, and SSE endpoints for editor or automation clients | | Config + agent catalog | Layer user/workspace JSONC config with typed agent defaults, tool allowlists, and runtime hooks | | Session sharing | Create self-hosted share links and durable sanitized share snapshots under `.sharpclaw/` | | Diagnostics context | Surface configured diagnostics sources into prompt context, status, and machine-readable output | @@ -136,6 +146,7 @@ Primary workflow modes: | Area | Project(s) | |---|---| +| Embeddable SDK | `SharpClaw.Code` | | CLI and command handlers | `SharpClaw.Code.Cli`, `SharpClaw.Code.Commands` | | Core contracts | `SharpClaw.Code.Protocol` | | Runtime orchestration | `SharpClaw.Code.Runtime` | @@ -148,12 +159,27 @@ Primary workflow modes: For dependency boundaries and project responsibilities, see [docs/architecture.md](docs/architecture.md) and [AGENTS.md](AGENTS.md). +## Example Hosts + +The solution includes embeddable host samples under `examples/`: + +- `MinimalConsoleAgent` for direct SDK prompt execution +- `WebApiAgent` for an HTTP-hosted runtime surface +- `WorkerServiceHost` for lifecycle-managed background hosting +- `McpToolAgent` for MCP-aware host composition + ## Testing ```bash # Run all tests dotnet test SharpClawCode.sln +# Build example hosts as part of local validation +dotnet build examples/WebApiAgent/WebApiAgent.csproj +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj +dotnet build examples/McpToolAgent/McpToolAgent.csproj + # Run a single test by name dotnet test SharpClawCode.sln --filter "FullyQualifiedName~YourTestName" @@ -180,8 +206,12 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests" | `--primary-mode ` | Workflow bias for prompts: `build`, `plan`, or `spec` | | `--session ` | Reuse a specific SharpClaw session id for prompt execution | | `--agent ` | Select the active agent for prompt execution | +| `--host-id ` | Stable embedded-host identifier for metering, admin, and event envelopes | +| `--tenant-id ` | Tenant identifier used by host-aware storage, approvals, and metering | +| `--storage-root ` | External root for host-managed durable runtime state | +| `--session-store fileSystem\|sqlite` | Select the embedded session/event storage backend | -Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`. +Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `tool-packages`, `acp`, `bridge`, and `version`. ## Documentation Map @@ -220,7 +250,7 @@ Key runtime configuration sections: | `SharpClaw:Providers:Anthropic` | Anthropic API key, base URL, default model | | `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible base settings plus local runtime profiles, auth mode, and default embedding model | | `SharpClaw:Web` | Web search provider name, endpoint template, user agent | -| `SharpClaw:Telemetry` | Runtime event ring buffer capacity | +| `SharpClaw:Telemetry` | Runtime event ring buffer capacity plus webhook event export behavior | Key `sharpclaw.jsonc` capabilities: @@ -242,8 +272,10 @@ All options are validated at startup via `IValidateOptions` implementations. - Workspace indexing, symbol search, and durable memory are available through both CLI commands and built-in tools. - ACP now carries editor context, approval round-trips, model catalog queries, workspace search/index actions, and memory actions, which is enough for a real VS Code client over a single transport. - OpenAI-compatible local runtime profiles can surface Ollama, llama.cpp, and similar endpoints with profile-aware auth and model discovery. +- Embedded hosts can opt into trusted-header or OIDC-backed approval identity, tenant-aware usage metering, webhook/SSE event streaming, and admin APIs for provider catalog, index status, search, memory inspection, and tool package management. +- The CLI mirrors those enterprise surfaces with `usage summary`, `usage detail`, and `tool-packages` commands while preserving the existing workspace-local `usage`, `cost`, and `stats` flows. - Operational commands support stable JSON output via `--output-format json`, which makes them suitable for scripts, editors, and automation. -- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, sharing, status, and doctor flows. +- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, admin control, metering, and event streaming. ## Contributing @@ -254,6 +286,10 @@ Before opening a PR: ```bash dotnet build SharpClawCode.sln dotnet test SharpClawCode.sln +dotnet build examples/WebApiAgent/WebApiAgent.csproj +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj +dotnet build examples/McpToolAgent/McpToolAgent.csproj ``` ## License diff --git a/SharpClawCode.sln b/SharpClawCode.sln index 1af073c..41659ce 100644 --- a/SharpClawCode.sln +++ b/SharpClawCode.sln @@ -55,6 +55,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Mcp.FixtureS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code", "src\SharpClaw.Code\SharpClaw.Code.csproj", "{8552440E-B169-4CD9-9B52-4BFFDDADF053}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpToolAgent", "examples\McpToolAgent\McpToolAgent.csproj", "{97EC01C1-A53A-475B-9364-0BD79E9CE272}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiAgent", "examples\WebApiAgent\WebApiAgent.csproj", "{963C636F-2096-45B1-8101-B8345967F197}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalConsoleAgent", "examples\MinimalConsoleAgent\MinimalConsoleAgent.csproj", "{7BA2E64A-B330-4783-9330-AEF46B91929A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerServiceHost", "examples\WorkerServiceHost\WorkerServiceHost.csproj", "{2E8A9F4F-8161-4E49-9F04-533D972C11CB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -353,6 +363,54 @@ Global {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.Build.0 = Release|Any CPU {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.ActiveCfg = Release|Any CPU {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.Build.0 = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x64.ActiveCfg = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x64.Build.0 = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x86.ActiveCfg = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x86.Build.0 = Debug|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|Any CPU.Build.0 = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x64.ActiveCfg = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x64.Build.0 = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x86.ActiveCfg = Release|Any CPU + {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x86.Build.0 = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|Any CPU.Build.0 = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x64.ActiveCfg = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x64.Build.0 = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x86.ActiveCfg = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Debug|x86.Build.0 = Debug|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|Any CPU.ActiveCfg = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|Any CPU.Build.0 = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x64.ActiveCfg = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x64.Build.0 = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x86.ActiveCfg = Release|Any CPU + {963C636F-2096-45B1-8101-B8345967F197}.Release|x86.Build.0 = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x64.Build.0 = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x86.Build.0 = Debug|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|Any CPU.Build.0 = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x64.ActiveCfg = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x64.Build.0 = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x86.ActiveCfg = Release|Any CPU + {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x86.Build.0 = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x64.Build.0 = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x86.Build.0 = Debug|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|Any CPU.Build.0 = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x64.ActiveCfg = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x64.Build.0 = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x86.ActiveCfg = Release|Any CPU + {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -382,5 +440,9 @@ Global {0060F8FF-0714-4C01-936F-719D7E5F124D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {7F55705B-1E53-4075-AB6F-3BA1BDD2CF85} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {8552440E-B169-4CD9-9B52-4BFFDDADF053} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {97EC01C1-A53A-475B-9364-0BD79E9CE272} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {963C636F-2096-45B1-8101-B8345967F197} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {7BA2E64A-B330-4783-9330-AEF46B91929A} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {2E8A9F4F-8161-4E49-9F04-533D972C11CB} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index 180086c..34cafd1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,16 +19,18 @@ This document matches the **current** solution: `SharpClawCode.sln` with project | **SharpClaw.Code.Memory / Git / Web / Skills** | Context and auxiliary services composed by runtime/agents as implemented today. | | **SharpClaw.Code.Agents** | Microsoft Agent Framework bridge, `ProviderBackedAgentKernel`, concrete agents. | | **SharpClaw.Code.Runtime** | `ConversationRuntime`, `DefaultTurnRunner`, lifecycle/state machine, operational diagnostics DI. | +| **SharpClaw.Code** | Embeddable runtime SDK: `SharpClawRuntimeHostBuilder`, `SharpClawRuntimeHost`, host-aware runtime entrypoints. | | **SharpClaw.Code.Commands** | System.CommandLine handlers, REPL host, slash commands, output renderers dispatch. | | **SharpClaw.Code.Cli** | Entry point (`Program.cs`), `Host` wiring: `AddSharpClawRuntime` + `AddSharpClawCli`. | -Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHarness**, **Mcp.FixtureServer**. +Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHarness**, **Mcp.FixtureServer**. Example hosts are included in the solution under `examples/`. ## Composition overview 1. **`CliHostBuilder.BuildHost`** builds `Host.CreateApplicationBuilder`, registers **Runtime** then **CLI**. -2. **`RuntimeServiceCollectionExtensions.AddSharpClawRuntime`** (with `IConfiguration` when used from CLI) registers in order: Telemetry, Infrastructure, **Providers** (from config), **Mcp**, **Tools**, **Agents**, Memory, Skills, Git, **Sessions** stores, context assembler, **DefaultTurnRunner**, state machine, **operational diagnostics checks**, **`ConversationRuntime`** (as `IConversationRuntime` + `IRuntimeCommandService`), and a minimal **`IHostedService`** (`RuntimeCoordinatorHostedServiceAdapter`). -3. **`AddSharpClawCli`** registers command handlers, REPL, renderers, `CommandRegistry`. +2. **`SharpClawRuntimeHostBuilder`** builds the same runtime slice without CLI assumptions for embedded ASP.NET Core, worker-service, or SDK hosts. +3. **`RuntimeServiceCollectionExtensions.AddSharpClawRuntime`** (with `IConfiguration` when used from CLI) registers in order: Telemetry, Infrastructure, **Providers** (from config), **Mcp**, **Tools**, **Agents**, Memory, Skills, Git, **Sessions** stores, context assembler, **DefaultTurnRunner**, state machine, **operational diagnostics checks**, **`ConversationRuntime`** (as `IConversationRuntime` + `IRuntimeCommandService`), and a minimal **`IHostedService`** (`RuntimeCoordinatorHostedServiceAdapter`). +4. **`AddSharpClawCli`** registers command handlers, REPL, renderers, `CommandRegistry`. ## Major execution flows @@ -36,7 +38,7 @@ Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHa 1. **`IRuntimeCommandService.ExecutePromptAsync`** → **`ConversationRuntime.RunPromptAsync`**. 2. Resolves or creates **`ConversationSession`** under the workspace; transitions lifecycle; appends **`RuntimeEvent`**s when persistence is enabled. -3. **`DefaultTurnRunner.RunAsync`** builds prompt context (`IPromptContextAssembler`), constructs **`AgentRunContext`** (includes **`IToolExecutor`**), calls **`PrimaryCodingAgent.RunAsync`**. +3. **`DefaultTurnRunner.RunAsync`** builds prompt context (`IPromptContextAssembler`), constructs **`AgentRunContext`** (includes **`IToolExecutor`** and normalized host/tenant context), calls **`PrimaryCodingAgent.RunAsync`**. 4. **`SharpClawAgentBase`** delegates to **`AgentFrameworkBridge.RunAsync`**, which drives **`ProviderBackedAgentKernel`** (streaming `IModelProvider`, auth checks, **`ProviderExecutionException`** on hard failures). 5. Turn completion updates session, checkpoints as implemented in **`ConversationRuntime`**, publishes events via **`IRuntimeEventPublisher`**. diff --git a/docs/permissions.md b/docs/permissions.md index 9ba3475..7872a87 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -35,11 +35,27 @@ If all rules abstain, **`EvaluateByModeAsync`** applies mode defaults (e.g. **Re **`ConsoleApprovalService`** prints the tool name, scope, prompt, optional “may be remembered” line, and waits for **`y`/`yes`**. +### Authenticated approval transports + +Embedded hosts can enable approval identity independently from provider auth. The current runtime supports: + +- **trusted-header** mode, where an upstream host supplies subject, tenant, role, and scope headers +- **OIDC** mode, where the embedded/admin HTTP surface validates a bearer token against discovery + JWKS metadata + +`ConfiguredApprovalIdentityService` resolves the current `ApprovalPrincipal`, and `AuthenticatedApprovalTransport` approves or denies requests before the console/non-interactive transports are considered. + +Two host flags matter: + +- `RequireForAdmin`: admin routes must present a valid approval identity +- `RequireAuthenticatedApprovals`: approval-required operations are denied when no valid approval identity is present, even if the caller is otherwise interactive + +Authenticated approvals are tenant-bound. If the runtime host context carries `TenantId`, an approval principal with a different tenant is denied before any remembered approval or console fallback path is used. + ### Remembered approvals **`ISessionApprovalMemory`** (**`SessionApprovalMemory`**) stores **approved** decisions in a **process-scoped** dictionary keyed by **`sessionId`** and a composite key (**tool name, scope, source, working directory, originating plugin id/trust**). -When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**. +When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**. In embedded-host flows, the remembered approval remains scoped to the current session and tenant context. ## Tool execution context diff --git a/docs/runtime.md b/docs/runtime.md index 1d44e6a..90b7f66 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -121,6 +121,31 @@ These services are intentionally small and runtime-owned rather than separate or Prompt requests can return JSON or replay the completed runtime event stream as SSE. +The embedded server also exposes a tenant-aware admin/control surface under `/v1/admin`: + +- `GET /v1/admin/providers` +- `GET /v1/admin/auth/status` +- `POST /v1/admin/sessions` +- `POST /v1/admin/sessions/{id}/fork` +- `GET /v1/admin/index/status` +- `POST /v1/admin/index/refresh` +- `POST /v1/admin/search` +- `GET /v1/admin/memory` +- `GET /v1/admin/events/recent` +- `GET /v1/admin/events/stream` +- `GET /v1/admin/tool-packages` +- `POST /v1/admin/tool-packages/install` +- `GET /v1/admin/usage/summary` +- `GET /v1/admin/usage/detail` + +Admin requests reuse the normalized `RuntimeCommandContext.HostContext`, so tenant id, host id, storage root, and selected session store backend remain consistent between CLI, SDK, and HTTP-hosted invocations. + +Usage metering is persisted in a dedicated tenant-aware SQLite store under the resolved storage root. Workspace-local `usage`, `cost`, and `stats` continue to read the existing workspace insights model, while the admin and enterprise CLI surfaces query the normalized metering store for tenant/host reporting. + +When approval auth is enabled, HTTP/admin requests resolve approval identity through either trusted headers or OIDC bearer tokens before approval-sensitive operations run. The resolved `ApprovalPrincipal` is then applied to authenticated approval transports and tenant-matching checks. + +Webhook and SSE event delivery both use the same `RuntimeEventEnvelope` shape, which freezes the external event contract across in-process streams, HTTP event streams, and configured outbound webhook sinks. + ## Hosted service **`RuntimeCoordinatorHostedServiceAdapter`** is registered as **`IHostedService`** and currently logs start/stop only (placeholder for future lifecycle coordination). diff --git a/docs/testing.md b/docs/testing.md index 2dd1840..e5174c3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,15 @@ Run all tests: dotnet test SharpClawCode.sln ``` +Build the example hosts as part of normal validation: + +```bash +dotnet build examples/WebApiAgent/WebApiAgent.csproj +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj +dotnet build examples/McpToolAgent/McpToolAgent.csproj +``` + Filter examples: ```bash @@ -52,4 +61,4 @@ Stable scenario **ids** are listed in **`ParityScenarioIds`** (e.g. `streaming_t ## CI -Use **`dotnet test`** on the solution; parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. +CI restores and builds the full solution, explicitly builds every example host project, and then runs `dotnet test` on the solution. Parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index 8c49d5c..d54fac9 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -50,6 +50,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Commands/CliCommandFactory.cs b/src/SharpClaw.Code.Commands/CliCommandFactory.cs index 6239a93..aaf730c 100644 --- a/src/SharpClaw.Code.Commands/CliCommandFactory.cs +++ b/src/SharpClaw.Code.Commands/CliCommandFactory.cs @@ -80,7 +80,7 @@ private async Task AddDiscoveredCustomCommandsAsync(RootCommand rootCommand, Can try { var result = await runtimeCommandService - .ExecuteCustomCommandAsync(definition.Name, argLine, ToRuntimeContext(ctx), cancellationToken) + .ExecuteCustomCommandAsync(definition.Name, argLine, ctx.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; @@ -102,14 +102,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( rootCommand.Subcommands.Add(command); } } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs index 83a042a..311feb2 100644 --- a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); - var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.CompactSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; - var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.CompactSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs index d6ffde5..559a107 100644 --- a/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs @@ -28,7 +28,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) command.SetAction(async (parseResult, cancellationToken) => { var context = globalOptions.Resolve(parseResult); - var result = await runtimeCommandService.RunDoctorAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.RunDoctorAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; }); @@ -39,17 +39,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) /// public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { - var result = await runtimeCommandService.RunDoctorAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.RunDoctorAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs index f12b571..2bfa487 100644 --- a/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs @@ -36,7 +36,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( try { var result = await runtimeCommandService - .ExecutePromptAsync(composed.Trim(), ToRuntimeContext(context), cancellationToken) + .ExecutePromptAsync(composed.Trim(), context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; @@ -50,13 +50,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( return 1; } } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs index a25d8f9..2f4150d 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs @@ -48,7 +48,7 @@ private async Task ExportAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ExportSessionAsync(sessionId, format, null, ToRuntimeContext(context), cancellationToken) + .ExportSessionAsync(sessionId, format, null, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -62,13 +62,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( cancellationToken).ConfigureAwait(false); return success ? 0 : 1; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs index 024d9c2..3f22474 100644 --- a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs @@ -37,7 +37,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); try { - var result = await runtimeCommandService.ExecutePromptAsync(prompt, ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken); return 0; } @@ -53,17 +53,6 @@ await outputRendererDispatcher.RenderCommandResultAsync( return command; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); - private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat) => new( Succeeded: false, diff --git a/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs index 9c72aab..ed4bce1 100644 --- a/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var ctx = globalOptions.Resolve(parseResult); var sid = parseResult.GetValue(id); - var result = await runtimeCommandService.RedoAsync(sid, ToRuntimeContext(ctx), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.RedoAsync(sid, ctx.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -48,18 +48,9 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteRedoAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .RedoAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .RedoAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs index 5b17bf3..7308740 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs @@ -40,7 +40,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( context.OutputFormat, cancellationToken).ConfigureAwait(false); await workspaceHttpServer - .RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken) + .RunAsync(context.WorkingDirectory, host, port, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); return 0; }); @@ -66,17 +66,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( new CommandResult(true, 0, context.OutputFormat, "Starting embedded SharpClaw server. Press Ctrl+C to stop.", null), context.OutputFormat, cancellationToken).ConfigureAwait(false); - await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); return 0; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs index a9418ae..e956e1b 100644 --- a/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs @@ -39,7 +39,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); var result = await runtimeCommandService - .InspectSessionAsync(id, ToRuntimeContext(context), cancellationToken) + .InspectSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -57,7 +57,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(forkId); var result = await runtimeCommandService - .ForkSessionAsync(id, ToRuntimeContext(context), cancellationToken) + .ForkSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -85,7 +85,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) : SessionExportFormat.Markdown; var path = parseResult.GetValue(outPath); var result = await runtimeCommandService - .ExportSessionAsync(sid, fmt, path, ToRuntimeContext(context), cancellationToken) + .ExportSessionAsync(sid, fmt, path, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -97,7 +97,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var result = await runtimeCommandService - .ListSessionsAsync(ToRuntimeContext(context), cancellationToken) + .ListSessionsAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -112,7 +112,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var context = globalOptions.Resolve(parseResult); var sid = parseResult.GetValue(attachId) ?? throw new InvalidOperationException("--id is required."); var result = await runtimeCommandService - .AttachSessionAsync(sid, ToRuntimeContext(context), cancellationToken) + .AttachSessionAsync(sid, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -124,7 +124,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var result = await runtimeCommandService - .DetachSessionAsync(ToRuntimeContext(context), cancellationToken) + .DetachSessionAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -142,7 +142,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var sid = parseResult.GetValue(bundleId); var outp = parseResult.GetValue(bundleOut); var result = await runtimeCommandService - .ExportPortableSessionBundleAsync(sid, outp, ToRuntimeContext(context), cancellationToken) + .ExportPortableSessionBundleAsync(sid, outp, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -171,7 +171,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) var replace = parseResult.GetValue(importReplace); var attach = parseResult.GetValue(importAttach); var result = await runtimeCommandService - .ImportPortableSessionBundleAsync(from, replace, attach, ToRuntimeContext(context), cancellationToken) + .ImportPortableSessionBundleAsync(from, replace, attach, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -255,7 +255,7 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteInspectAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .InspectSessionAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .InspectSessionAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -264,7 +264,7 @@ private async Task ExecuteInspectAsync(string? sessionId, CommandExecutionC private async Task ExecuteForkAsync(string? sourceId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .ForkSessionAsync(sourceId, ToRuntimeContext(context), cancellationToken) + .ForkSessionAsync(sourceId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -273,7 +273,7 @@ private async Task ExecuteForkAsync(string? sourceId, CommandExecutionConte private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .ListSessionsAsync(ToRuntimeContext(context), cancellationToken) + .ListSessionsAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -282,7 +282,7 @@ private async Task ExecuteListAsync(CommandExecutionContext context, Cancel private async Task ExecuteAttachAsync(string sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .AttachSessionAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .AttachSessionAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -291,7 +291,7 @@ private async Task ExecuteAttachAsync(string sessionId, CommandExecutionCon private async Task ExecuteDetachAsync(CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .DetachSessionAsync(ToRuntimeContext(context), cancellationToken) + .DetachSessionAsync(context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -304,7 +304,7 @@ private async Task ExecuteBundleAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ExportPortableSessionBundleAsync(sessionId, outputPath, ToRuntimeContext(context), cancellationToken) + .ExportPortableSessionBundleAsync(sessionId, outputPath, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -318,7 +318,7 @@ private async Task ExecuteImportAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ImportPortableSessionBundleAsync(bundleZipPath, replaceExisting, attachAfterImport, ToRuntimeContext(context), cancellationToken) + .ImportPortableSessionBundleAsync(bundleZipPath, replaceExisting, attachAfterImport, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -331,7 +331,7 @@ private async Task ExecuteExportAsync( CancellationToken cancellationToken) { var result = await runtimeCommandService - .ExportSessionAsync(sessionId, format, null, ToRuntimeContext(context), cancellationToken) + .ExportSessionAsync(sessionId, format, null, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; @@ -345,13 +345,4 @@ await outputRendererDispatcher.RenderCommandResultAsync( cancellationToken).ConfigureAwait(false); return success ? 0 : 1; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs index 0b8afe0..fdd3a45 100644 --- a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); - var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.ShareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; - var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.ShareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs index be94b10..0ec69e4 100644 --- a/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs @@ -28,7 +28,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) command.SetAction(async (parseResult, cancellationToken) => { var context = globalOptions.Resolve(parseResult); - var result = await runtimeCommandService.GetStatusAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.GetStatusAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; }); @@ -39,17 +39,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) /// public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { - var result = await runtimeCommandService.GetStatusAsync(ToRuntimeContext(context), cancellationToken); + var result = await runtimeCommandService.GetStatusAsync(context.ToRuntimeCommandContext(), cancellationToken); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs new file mode 100644 index 0000000..afa50ce --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs @@ -0,0 +1,152 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Tools.Abstractions; + +namespace SharpClaw.Code.Commands; + +/// +/// Lists and installs packaged tool bundles for the current workspace. +/// +public sealed class ToolPackagesCommandHandler( + IToolPackageService toolPackageService, + OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler +{ + /// + public string Name => "tool-packages"; + + /// + public string Description => "Lists and installs packaged third-party tool bundles."; + + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + command.Subcommands.Add(BuildListCommand(globalOptions)); + command.Subcommands.Add(BuildInstallCommand(globalOptions)); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildListCommand(GlobalCliOptions globalOptions) + { + var command = new Command("list", "Lists installed packaged tool bundles."); + command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken)); + return command; + } + + private Command BuildInstallCommand(GlobalCliOptions globalOptions) + { + var command = new Command("install", "Installs a packaged tool manifest."); + var manifestOption = new Option("--manifest") + { + Required = true, + Description = "Path to a serialized ToolPackageManifest JSON file." + }; + var installSourceOption = new Option("--install-source") + { + Description = "Install-source label recorded with the package.", + DefaultValueFactory = _ => "cli", + }; + var sourceReferenceOption = new Option("--source-reference") + { + Description = "Optional package directory, binary path, or .nupkg source reference." + }; + var packageSourceOption = new Option("--package-source") + { + Description = "Optional NuGet source feed URL." + }; + var disableOption = new Option("--disable") + { + Description = "Install the package without enabling its plugin surface." + }; + + command.Options.Add(manifestOption); + command.Options.Add(installSourceOption); + command.Options.Add(sourceReferenceOption); + command.Options.Add(packageSourceOption); + command.Options.Add(disableOption); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var manifestPath = parseResult.GetValue(manifestOption) ?? throw new InvalidOperationException("The --manifest option is required."); + var installSource = parseResult.GetValue(installSourceOption) ?? "cli"; + var sourceReference = parseResult.GetValue(sourceReferenceOption); + var packageSource = parseResult.GetValue(packageSourceOption); + var disable = parseResult.GetValue(disableOption); + return await ExecuteInstallAsync( + manifestPath, + installSource, + sourceReference, + packageSource, + disable, + context, + cancellationToken).ConfigureAwait(false); + }); + return command; + } + + private async Task ExecuteListAsync( + SharpClaw.Code.Commands.Models.CommandExecutionContext context, + CancellationToken cancellationToken) + { + var packages = await toolPackageService.ListInstalledAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + packages.Count == 0 ? "No tool packages are installed for this workspace." : $"{packages.Count} tool package(s).", + JsonSerializer.Serialize(packages.ToList(), ProtocolJsonContext.Default.ListInstalledToolPackage)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteInstallAsync( + string manifestPath, + string installSource, + string? sourceReference, + string? packageSource, + bool disable, + SharpClaw.Code.Commands.Models.CommandExecutionContext context, + CancellationToken cancellationToken) + { + var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(manifestJson, ProtocolJsonContext.Default.ToolPackageManifest) + ?? throw new InvalidOperationException($"Manifest '{manifestPath}' could not be parsed."); + var resolvedSourceReference = ResolveSourceReference(manifestPath, sourceReference, manifest.Package.PackageType); + var installed = await toolPackageService + .InstallAsync( + context.WorkingDirectory, + new ToolPackageInstallRequest( + Manifest: manifest, + InstallSource: installSource, + EnableAfterInstall: !disable, + SourceReference: resolvedSourceReference, + PackageSource: packageSource), + cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Installed tool package '{installed.Manifest.Package.PackageId}' ({installed.Manifest.Package.Version}).", + JsonSerializer.Serialize(installed, ProtocolJsonContext.Default.InstalledToolPackage)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private static string? ResolveSourceReference(string manifestPath, string? sourceReference, string packageType) + { + if (!string.IsNullOrWhiteSpace(sourceReference)) + { + return sourceReference; + } + + return string.Equals(packageType, "local", StringComparison.OrdinalIgnoreCase) + ? Path.GetDirectoryName(Path.GetFullPath(manifestPath)) + : null; + } +} diff --git a/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs index 154f142..ea63703 100644 --- a/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var ctx = globalOptions.Resolve(parseResult); var sid = parseResult.GetValue(id); - var result = await runtimeCommandService.UndoAsync(sid, ToRuntimeContext(ctx), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UndoAsync(sid, ctx.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -48,18 +48,9 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC private async Task ExecuteUndoAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken) { var result = await runtimeCommandService - .UndoAsync(sessionId, ToRuntimeContext(context), cancellationToken) + .UndoAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs index ec32c45..cc4ae03 100644 --- a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs @@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions) { var context = globalOptions.Resolve(parseResult); var id = parseResult.GetValue(idOption); - var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UnshareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; }); @@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions) public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) { var id = command.Arguments.Length > 0 ? command.Arguments[0] : null; - var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false); + var result = await runtimeCommandService.UnshareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return result.ExitCode; } - - private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context) - => new( - context.WorkingDirectory, - context.Model, - context.PermissionMode, - context.OutputFormat, - context.PrimaryMode, - context.SessionId, - context.AgentId); } diff --git a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs index f100794..dc02a4d 100644 --- a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs @@ -6,6 +6,7 @@ using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Telemetry.Abstractions; namespace SharpClaw.Code.Commands; @@ -14,6 +15,7 @@ namespace SharpClaw.Code.Commands; /// public sealed class UsageCommandHandler( IWorkspaceInsightsService workspaceInsightsService, + IUsageMeteringService usageMeteringService, OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler { /// @@ -29,13 +31,61 @@ public sealed class UsageCommandHandler( public Command BuildCommand(GlobalCliOptions globalOptions) { var command = new Command(Name, Description); + command.Subcommands.Add(BuildSummaryCommand(globalOptions)); + command.Subcommands.Add(BuildDetailCommand(globalOptions)); command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken)); return command; } /// public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) - => ExecuteAsync(context, cancellationToken); + => command.Arguments.Length switch + { + 0 => ExecuteAsync(context, cancellationToken), + _ when string.Equals(command.Arguments[0], "summary", StringComparison.OrdinalIgnoreCase) + => ExecuteSummaryAsync(context, null, null, cancellationToken), + _ when string.Equals(command.Arguments[0], "detail", StringComparison.OrdinalIgnoreCase) + => ExecuteDetailAsync( + context, + null, + null, + command.Arguments.Length > 1 && int.TryParse(command.Arguments[1], out var limit) ? limit : null, + cancellationToken), + _ => ExecuteAsync(context, cancellationToken) + }; + + private Command BuildSummaryCommand(GlobalCliOptions globalOptions) + { + var command = new Command("summary", "Shows tenant-aware metering totals for the current workspace."); + var fromUtcOption = new Option("--from-utc") { Description = "Inclusive lower-bound timestamp in UTC (ISO-8601)." }; + var toUtcOption = new Option("--to-utc") { Description = "Exclusive upper-bound timestamp in UTC (ISO-8601)." }; + command.Options.Add(fromUtcOption); + command.Options.Add(toUtcOption); + command.SetAction((parseResult, cancellationToken) => ExecuteSummaryAsync( + globalOptions.Resolve(parseResult), + ParseTimestamp(parseResult.GetValue(fromUtcOption), "--from-utc"), + ParseTimestamp(parseResult.GetValue(toUtcOption), "--to-utc"), + cancellationToken)); + return command; + } + + private Command BuildDetailCommand(GlobalCliOptions globalOptions) + { + var command = new Command("detail", "Lists normalized usage metering records for the current workspace."); + var fromUtcOption = new Option("--from-utc") { Description = "Inclusive lower-bound timestamp in UTC (ISO-8601)." }; + var toUtcOption = new Option("--to-utc") { Description = "Exclusive upper-bound timestamp in UTC (ISO-8601)." }; + var limitOption = new Option("--limit") { Description = "Maximum number of records to return." }; + command.Options.Add(fromUtcOption); + command.Options.Add(toUtcOption); + command.Options.Add(limitOption); + command.SetAction((parseResult, cancellationToken) => ExecuteDetailAsync( + globalOptions.Resolve(parseResult), + ParseTimestamp(parseResult.GetValue(fromUtcOption), "--from-utc"), + ParseTimestamp(parseResult.GetValue(toUtcOption), "--to-utc"), + parseResult.GetValue(limitOption), + cancellationToken)); + return command; + } private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken) { @@ -51,4 +101,74 @@ private async Task ExecuteAsync(CommandExecutionContext context, Cancellati await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); return 0; } + + private async Task ExecuteSummaryAsync( + CommandExecutionContext context, + DateTimeOffset? fromUtc, + DateTimeOffset? toUtc, + CancellationToken cancellationToken) + { + var report = await usageMeteringService + .GetSummaryAsync(context.WorkingDirectory, CreateQuery(context, fromUtc, toUtc), cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Usage summary: {report.TotalUsage.TotalTokens} tokens, {report.ProviderRequestCount} provider request(s), {report.ToolExecutionCount} tool execution(s), {report.TurnCount} turn(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.UsageMeteringSummaryReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private async Task ExecuteDetailAsync( + CommandExecutionContext context, + DateTimeOffset? fromUtc, + DateTimeOffset? toUtc, + int? limit, + CancellationToken cancellationToken) + { + var report = await usageMeteringService + .GetDetailAsync( + context.WorkingDirectory, + CreateQuery(context, fromUtc, toUtc), + Math.Clamp(limit.GetValueOrDefault(50), 1, 500), + cancellationToken) + .ConfigureAwait(false); + var result = new CommandResult( + true, + 0, + context.OutputFormat, + $"Usage detail: {report.Records.Count} record(s).", + JsonSerializer.Serialize(report, ProtocolJsonContext.Default.UsageMeteringDetailReport)); + await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); + return 0; + } + + private static UsageMeteringQuery CreateQuery( + CommandExecutionContext context, + DateTimeOffset? fromUtc, + DateTimeOffset? toUtc) + => new( + FromUtc: fromUtc, + ToUtc: toUtc, + TenantId: context.HostContext?.TenantId, + HostId: context.HostContext?.HostId, + WorkspaceRoot: context.WorkingDirectory, + SessionId: context.SessionId); + + private static DateTimeOffset? ParseTimestamp(string? value, string optionName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (DateTimeOffset.TryParse(value, out var parsed)) + { + return parsed; + } + + throw new InvalidOperationException($"Option '{optionName}' must be a valid UTC timestamp."); + } } diff --git a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs index 2e00c4f..8b7a39e 100644 --- a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs +++ b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs @@ -1,4 +1,6 @@ using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; namespace SharpClaw.Code.Commands.Models; @@ -12,6 +14,7 @@ namespace SharpClaw.Code.Commands.Models; /// Build, plan, or spec workflow from global CLI options. /// Optional explicit session id for prompts and session-scoped commands. /// Optional explicit agent id for prompt execution. +/// Optional embedded-host identity and tenant/storage context. public sealed record CommandExecutionContext( string WorkingDirectory, string? Model, @@ -19,4 +22,28 @@ public sealed record CommandExecutionContext( OutputFormat OutputFormat, PrimaryMode PrimaryMode, string? SessionId = null, - string? AgentId = null); + string? AgentId = null, + RuntimeHostContext? HostContext = null) +{ + /// + /// Converts the CLI command context into the runtime invocation context. + /// + /// Whether the current caller can participate in approval prompts. + /// Optional primary-mode override. + /// Optional agent id override. + /// The runtime command context. + public RuntimeCommandContext ToRuntimeCommandContext( + bool isInteractive = true, + PrimaryMode? primaryModeOverride = null, + string? agentIdOverride = null) + => new( + WorkingDirectory, + Model, + PermissionMode, + OutputFormat, + primaryModeOverride ?? PrimaryMode, + SessionId, + agentIdOverride ?? AgentId, + isInteractive, + HostContext); +} diff --git a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs index 4e2e96d..ff84de4 100644 --- a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs +++ b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs @@ -2,6 +2,7 @@ using System.CommandLine.Parsing; using SharpClaw.Code.Commands.Models; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Commands.Options; @@ -59,6 +60,30 @@ public GlobalCliOptions() Description = "Selects the effective agent id for prompt execution.", Recursive = true }; + + HostIdOption = new Option("--host-id") + { + Description = "Sets the embedded host identifier for tenant-aware state and diagnostics.", + Recursive = true + }; + + TenantIdOption = new Option("--tenant-id") + { + Description = "Sets the tenant identifier for enterprise session, memory, and metering operations.", + Recursive = true + }; + + StorageRootOption = new Option("--storage-root") + { + Description = "Overrides the external storage root for embedded-host durable state.", + Recursive = true + }; + + SessionStoreOption = new Option("--session-store") + { + Description = "Selects the embedded session store backend: fileSystem or sqlite.", + Recursive = true + }; } /// @@ -96,10 +121,43 @@ public GlobalCliOptions() /// public Option AgentOption { get; } + /// + /// Gets the optional embedded host id option. + /// + public Option HostIdOption { get; } + + /// + /// Gets the optional tenant id option. + /// + public Option TenantIdOption { get; } + + /// + /// Gets the optional external storage root option. + /// + public Option StorageRootOption { get; } + + /// + /// Gets the optional embedded session store kind option. + /// + public Option SessionStoreOption { get; } + /// /// Gets all global options. /// - public IEnumerable public sealed class WebhookRuntimeEventSink( IOptions telemetryOptionsAccessor, + HttpClient httpClient, + IWebhookDelayStrategy webhookDelayStrategy, ILogger? logger = null) : IRuntimeEventSink { private readonly TelemetryOptions telemetryOptions = telemetryOptionsAccessor.Value; + private readonly HttpClient httpClient = httpClient; + private readonly IWebhookDelayStrategy webhookDelayStrategy = webhookDelayStrategy; private readonly ILogger logger = logger ?? NullLogger.Instance; - private readonly HttpClient httpClient = new() - { - Timeout = TimeSpan.FromSeconds(5), - }; /// public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) @@ -53,7 +53,7 @@ public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken url, attempt); var delay = TimeSpan.FromMilliseconds(telemetryOptions.WebhookInitialBackoffMilliseconds * Math.Pow(2, attempt - 1)); - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await webhookDelayStrategy.DelayAsync(delay, cancellationToken).ConfigureAwait(false); } catch (Exception exception) { diff --git a/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj b/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj index 828692c..c26f671 100644 --- a/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj +++ b/src/SharpClaw.Code.Telemetry/SharpClaw.Code.Telemetry.csproj @@ -4,6 +4,7 @@ + @@ -17,6 +18,7 @@ enable enable Structured telemetry, event publishing, and usage tracking for SharpClaw Code. + $(WarningsAsErrors);CS1573;CS1591 diff --git a/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs b/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs index 7e6f102..7642727 100644 --- a/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Telemetry/TelemetryServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharpClaw.Code.Protocol.Abstractions; using SharpClaw.Code.Telemetry.Abstractions; @@ -46,8 +47,10 @@ public static IServiceCollection AddSharpClawTelemetry(this IServiceCollection s private static IServiceCollection AddSharpClawTelemetryCore(IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + services.AddHttpClient("sharpclaw-telemetry-webhook", client => client.Timeout = TimeSpan.FromSeconds(5)); services.TryAddSingleton, TelemetryOptionsValidator>(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(serviceProvider => serviceProvider.GetRequiredService()); @@ -56,9 +59,14 @@ private static IServiceCollection AddSharpClawTelemetryCore(IServiceCollection s services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); } - if (!services.Any(static descriptor => descriptor.ServiceType == typeof(IRuntimeEventSink) && descriptor.ImplementationType == typeof(WebhookRuntimeEventSink))) + if (!services.Any(static descriptor => descriptor.ServiceType == typeof(WebhookRuntimeEventSink))) { - services.AddSingleton(); + services.TryAddSingleton(serviceProvider => new WebhookRuntimeEventSink( + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService().CreateClient("sharpclaw-telemetry-webhook"), + serviceProvider.GetRequiredService(), + serviceProvider.GetService>())); + services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); } services.TryAddSingleton(serviceProvider => new RuntimeEventPublisher( diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs index a97fef3..2cd956f 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs @@ -40,7 +40,6 @@ await File.WriteAllTextAsync( { "server": { "host": "127.0.0.1", - "port": 7345, "approvalAuth": { "mode": "oidc", "authority": "{{authority.AuthorityUrl}}", @@ -58,18 +57,8 @@ await File.WriteAllTextAsync( using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); - var port = FindFreePort(); using var serverCts = new CancellationTokenSource(); - var serverTask = server.RunAsync( - workspaceRoot, - "127.0.0.1", - port, - new RuntimeCommandContext( - WorkingDirectory: workspaceRoot, - Model: "default", - PermissionMode: PermissionMode.WorkspaceWrite, - OutputFormat: OutputFormat.Json), - serverCts.Token); + var (port, serverTask) = await StartServerAsync(server, workspaceRoot, serverCts.Token); try { @@ -163,6 +152,42 @@ private static async Task WaitForServerAsync(HttpClient httpClient, Cancellation throw new TimeoutException("Embedded workspace HTTP server did not become ready."); } + private static async Task<(int Port, Task ServerTask)> StartServerAsync( + IWorkspaceHttpServer server, + string workspaceRoot, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 5; attempt++) + { + var port = FindFreePort(); + var serverTask = server.RunAsync( + workspaceRoot, + "127.0.0.1", + port, + new RuntimeCommandContext( + WorkingDirectory: workspaceRoot, + Model: "default", + PermissionMode: PermissionMode.WorkspaceWrite, + OutputFormat: OutputFormat.Json), + cancellationToken); + await Task.Delay(100, CancellationToken.None).ConfigureAwait(false); + if (!serverTask.IsFaulted) + { + return (port, serverTask); + } + + try + { + await serverTask.ConfigureAwait(false); + } + catch (HttpListenerException exception) when (exception.Message.Contains("Address already in use", StringComparison.OrdinalIgnoreCase)) + { + } + } + + throw new InvalidOperationException("The embedded workspace HTTP server could not bind to a free local port."); + } + private static int FindFreePort() { using var listener = new TcpListener(IPAddress.Loopback, 0); diff --git a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs index 39ddbbc..5fd719e 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Smoke/CliCommandSurfaceTests.cs @@ -49,6 +49,7 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "session", "share", "status", + "tool-packages", "doctor", "index", "unshare", @@ -64,7 +65,11 @@ public async Task Root_command_should_expose_expected_commands_and_global_option "--permission-mode", "--primary-mode", "--session", - "--agent" + "--agent", + "--host-id", + "--tenant-id", + "--storage-root", + "--session-store" ]); } } diff --git a/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs index 595cba9..4363b0d 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/FeatureCommandHandlersTests.cs @@ -1,7 +1,9 @@ +using System.CommandLine; using System.Text.Json; using FluentAssertions; using SharpClaw.Code.Commands; using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; @@ -9,6 +11,8 @@ using SharpClaw.Code.Protocol.Serialization; using SharpClaw.Code.Providers.Abstractions; using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Abstractions; namespace SharpClaw.Code.UnitTests.Commands; @@ -18,7 +22,7 @@ public sealed class FeatureCommandHandlersTests public async Task Usage_command_should_render_workspace_usage_payload() { var renderer = new RecordingRenderer(); - var handler = new UsageCommandHandler(new StubInsightsService(), new OutputRendererDispatcher([renderer])); + var handler = new UsageCommandHandler(new StubInsightsService(), new StubUsageMeteringService(), new OutputRendererDispatcher([renderer])); var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build, "session-1"); var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "usage", []), context, CancellationToken.None); @@ -29,6 +33,46 @@ public async Task Usage_command_should_render_workspace_usage_payload() payload!.WorkspaceTotal.TotalTokens.Should().Be(42); } + [Fact] + public async Task Usage_summary_should_render_metering_summary_payload_and_use_host_context() + { + var renderer = new RecordingRenderer(); + var metering = new StubUsageMeteringService(); + var handler = new UsageCommandHandler(new StubInsightsService(), metering, new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext( + "/workspace", + null, + PermissionMode.WorkspaceWrite, + OutputFormat.Json, + PrimaryMode.Build, + "session-1", + HostContext: new RuntimeHostContext("host-a", "tenant-a", null, SessionStoreKind.Sqlite, true)); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "usage", ["summary"]), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.UsageMeteringSummaryReport); + payload!.TotalUsage.TotalTokens.Should().Be(16); + metering.LastQuery.Should().NotBeNull(); + metering.LastQuery!.TenantId.Should().Be("tenant-a"); + metering.LastQuery.HostId.Should().Be("host-a"); + metering.LastQuery.SessionId.Should().Be("session-1"); + } + + [Fact] + public async Task Usage_detail_should_render_metering_detail_payload() + { + var renderer = new RecordingRenderer(); + var handler = new UsageCommandHandler(new StubInsightsService(), new StubUsageMeteringService(), new OutputRendererDispatcher([renderer])); + var context = new CommandExecutionContext("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json, PrimaryMode.Build, "session-1"); + + var exitCode = await handler.ExecuteAsync(new SlashCommandParseResult(true, "usage", ["detail", "25"]), context, CancellationToken.None); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.UsageMeteringDetailReport); + payload!.Records.Should().ContainSingle(record => record.ToolName == "workspace_search"); + } + [Fact] public async Task Hooks_command_should_execute_named_test_from_slash_command() { @@ -98,6 +142,74 @@ public async Task Memory_command_should_save_and_list_entries() payload.Should().ContainSingle(entry => entry.Content.Contains("concise", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task Tool_packages_list_command_should_render_installed_package_payload() + { + var renderer = new RecordingRenderer(); + var toolPackages = new StubToolPackageService(); + var globalOptions = new GlobalCliOptions(); + var handler = new ToolPackagesCommandHandler(toolPackages, new OutputRendererDispatcher([renderer])); + var exitCode = await InvokeCommandAsync(handler.BuildCommand(globalOptions), globalOptions, "tool-packages list --cwd /workspace --output-format json"); + + exitCode.Should().Be(0); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.ListInstalledToolPackage); + payload.Should().ContainSingle(package => package.Manifest.Package.PackageId == "contoso.tools"); + } + + [Fact] + public async Task Tool_packages_install_command_should_render_installed_package_payload() + { + var renderer = new RecordingRenderer(); + var toolPackages = new StubToolPackageService(); + var globalOptions = new GlobalCliOptions(); + var handler = new ToolPackagesCommandHandler(toolPackages, new OutputRendererDispatcher([renderer])); + var manifestPath = Path.Combine(Path.GetTempPath(), $"tool-package-{Guid.NewGuid():N}.json"); + var manifest = new ToolPackageManifest( + new ToolPackageReference("contoso.tools", "1.2.3", "local", "bin/Contoso.Tools.dll", ["--serve"], "net10.0", ["tools"]), + "contoso", + "Contoso tools", + [new PackagedToolDescriptor("workspace_search", "Searches the workspace.", "{}")]); + + try + { + await File.WriteAllTextAsync( + manifestPath, + JsonSerializer.Serialize(manifest, ProtocolJsonContext.Default.ToolPackageManifest), + CancellationToken.None); + + var exitCode = await InvokeCommandAsync( + handler.BuildCommand(globalOptions), + globalOptions, + $"tool-packages install --manifest \"{manifestPath}\" --install-source cli --disable --cwd /workspace --output-format json"); + + exitCode.Should().Be(0); + toolPackages.LastInstallRequest.Should().NotBeNull(); + toolPackages.LastInstallRequest!.EnableAfterInstall.Should().BeFalse(); + toolPackages.LastInstallRequest.SourceReference.Should().Be(Path.GetDirectoryName(Path.GetFullPath(manifestPath))); + var payload = JsonSerializer.Deserialize(renderer.LastResult!.DataJson!, ProtocolJsonContext.Default.InstalledToolPackage); + payload!.Manifest.Package.PackageId.Should().Be("contoso.tools"); + } + finally + { + if (File.Exists(manifestPath)) + { + File.Delete(manifestPath); + } + } + } + + private static Task InvokeCommandAsync(Command command, GlobalCliOptions globalOptions, string commandLine) + { + var root = new RootCommand(); + foreach (var option in globalOptions.All) + { + root.Options.Add(option); + } + + root.Subcommands.Add(command); + return root.Parse(commandLine).InvokeAsync(); + } + private sealed class RecordingRenderer : IOutputRenderer { public OutputFormat Format => OutputFormat.Json; @@ -150,6 +262,49 @@ public Task TestAsync(string workspaceRoot, string hookName, str } } + private sealed class StubUsageMeteringService : IUsageMeteringService + { + public UsageMeteringQuery? LastQuery { get; private set; } + + public Task GetDetailAsync(string workspaceRoot, UsageMeteringQuery query, int limit, CancellationToken cancellationToken) + { + LastQuery = query; + return Task.FromResult(new UsageMeteringDetailReport( + query, + [ + new UsageMeteringRecord( + "meter-1", + UsageMeteringRecordKind.ToolExecution, + DateTimeOffset.UtcNow, + query.TenantId, + query.HostId, + workspaceRoot, + query.SessionId, + "turn-1", + ProviderName: "openai-compatible", + Model: "gpt-5.4-mini", + ToolName: "workspace_search", + ApprovalScope: ApprovalScope.ToolExecution, + Succeeded: true, + DurationMilliseconds: 20, + Usage: null, + Detail: "ok") + ])); + } + + public Task GetSummaryAsync(string workspaceRoot, UsageMeteringQuery query, CancellationToken cancellationToken) + { + LastQuery = query; + return Task.FromResult(new UsageMeteringSummaryReport( + query, + new UsageSnapshot(10, 6, 0, 16, 0.24m), + ProviderRequestCount: 1, + ToolExecutionCount: 1, + TurnCount: 1, + SessionEventCount: 0)); + } + } + private sealed class StubProviderCatalogService : IProviderCatalogService { public Task> ListAsync(CancellationToken cancellationToken) @@ -231,4 +386,38 @@ public Task SaveAsync(string? workspaceRoot, MemoryEntry entry, Can return Task.FromResult(entry); } } + + private sealed class StubToolPackageService : IToolPackageService + { + public ToolPackageInstallRequest? LastInstallRequest { get; private set; } + + public Task InstallAsync(string workspaceRoot, ToolPackageInstallRequest request, CancellationToken cancellationToken) + { + LastInstallRequest = request; + return Task.FromResult(new InstalledToolPackage( + request.Manifest, + DateTimeOffset.UtcNow, + request.InstallSource, + new ToolPackageResolvedInstall( + request.SourceReference, + request.PackageSource, + null, + null, + request.Manifest.Package.EntryAssembly, + request.Manifest.Package.EntryArguments))); + } + + public Task> ListInstalledAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>( + [ + new InstalledToolPackage( + new ToolPackageManifest( + new ToolPackageReference("contoso.tools", "1.0.0", "local", "Contoso.Tools.dll", null, "net10.0", ["tools"]), + "contoso", + "Contoso tool bundle", + [new PackagedToolDescriptor("workspace_search", "Searches the workspace.", "{}")]), + DateTimeOffset.UtcNow, + "cli") + ]); + } } diff --git a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs index c4c1790..654c024 100644 --- a/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Commands/ModeAndCliOptionsTests.cs @@ -5,6 +5,7 @@ using SharpClaw.Code.Commands.Options; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.UnitTests.Commands; @@ -29,6 +30,28 @@ public void Global_cli_options_should_parse_spec_primary_mode() context.PrimaryMode.Should().Be(PrimaryMode.Spec); } + [Fact] + public void Global_cli_options_should_parse_embedded_host_context() + { + var options = new GlobalCliOptions(); + var command = new RootCommand(); + foreach (var option in options.All) + { + command.Options.Add(option); + } + + var storageRoot = Path.Combine(Path.GetTempPath(), "sharpclaw-state"); + var parseResult = command.Parse($"--tenant-id tenant-a --host-id host-a --storage-root \"{storageRoot}\" --session-store sqlite"); + var context = options.Resolve(parseResult); + + context.HostContext.Should().BeEquivalentTo(new RuntimeHostContext( + HostId: "host-a", + TenantId: "tenant-a", + StorageRoot: Path.GetFullPath(storageRoot), + SessionStoreKind: SessionStoreKind.Sqlite, + IsEmbeddedHost: true)); + } + [Fact] public async Task Mode_slash_command_should_set_spec_mode() { diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs new file mode 100644 index 0000000..8335029 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using System.Net.Http; +using FluentAssertions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Telemetry.Services; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies webhook runtime event delivery and retry behavior. +/// +public sealed class WebhookRuntimeEventSinkTests +{ + [Fact] + public async Task PublishAsync_should_skip_delivery_when_no_webhooks_are_configured() + { + var handler = new SequenceMessageHandler(HttpStatusCode.OK); + var sink = new WebhookRuntimeEventSink( + Options.Create(new TelemetryOptions()), + new HttpClient(handler), + new RecordingDelayStrategy()); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + handler.AttemptCount.Should().Be(0); + } + + [Fact] + public async Task PublishAsync_should_retry_after_a_failure_and_eventually_succeed() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 3, + WebhookInitialBackoffMilliseconds = 25, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + var handler = new SequenceMessageHandler(HttpStatusCode.InternalServerError, HttpStatusCode.OK); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + handler.AttemptCount.Should().Be(2); + delayStrategy.Delays.Should().Equal(TimeSpan.FromMilliseconds(25)); + } + + [Fact] + public async Task PublishAsync_should_stop_after_the_configured_attempt_limit() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 3, + WebhookInitialBackoffMilliseconds = 10, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + var handler = new SequenceMessageHandler( + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + handler.AttemptCount.Should().Be(3); + delayStrategy.Delays.Should().Equal( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(20)); + } + + [Fact] + public async Task PublishAsync_should_apply_exponential_backoff_between_attempts() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 4, + WebhookInitialBackoffMilliseconds = 50, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + var handler = new SequenceMessageHandler( + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError, + HttpStatusCode.InternalServerError, + HttpStatusCode.OK); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await sink.PublishAsync(CreateEnvelope(), CancellationToken.None); + + delayStrategy.Delays.Should().Equal( + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(200)); + } + + private static RuntimeEventEnvelope CreateEnvelope() + => new( + EventType: nameof(UsageUpdatedEvent), + OccurredAtUtc: DateTimeOffset.UtcNow, + Event: new UsageUpdatedEvent( + EventId: "evt-usage", + SessionId: "session-1", + TurnId: "turn-1", + OccurredAtUtc: DateTimeOffset.UtcNow, + Usage: new UsageSnapshot(1, 2, 0, 3, 0.01m)), + WorkspacePath: "/workspace", + SessionId: "session-1", + TenantId: "tenant-a", + HostId: "host-a"); + + private sealed class RecordingDelayStrategy : IWebhookDelayStrategy + { + public List Delays { get; } = []; + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + Delays.Add(delay); + return Task.CompletedTask; + } + } + + private sealed class SequenceMessageHandler(params HttpStatusCode[] responses) : HttpMessageHandler + { + private readonly Queue responses = new(responses); + + public int AttemptCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + AttemptCount++; + var statusCode = responses.Count == 0 ? HttpStatusCode.OK : responses.Dequeue(); + return Task.FromResult(new HttpResponseMessage(statusCode)); + } + } +} From 7b39d38923ccd20bf84c6e6d3646882fe0e7f20e Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 20 Apr 2026 22:28:13 -0700 Subject: [PATCH 5/8] fix: address review feedback on enterprise follow-through --- .../plans/2026-04-10-phase1-gaps.md | 2 +- .../Services/HashTextEmbeddingService.cs | 19 ++- .../SharpClaw.Code.Memory.csproj | 4 + .../OperationalDiagnosticsCoordinator.cs | 2 +- .../Services/InProcessRuntimeEventStream.cs | 5 +- .../Services/RuntimeEventPublisher.cs | 17 ++- .../Services/WebhookRuntimeEventSink.cs | 9 +- .../HashTextEmbeddingServiceTests.cs | 29 ++++ .../OperationalDiagnosticsCoordinatorTests.cs | 133 ++++++++++++++++++ .../InProcessRuntimeEventStreamTests.cs | 43 ++++++ .../Telemetry/TelemetryPublisherTests.cs | 44 ++++++ .../Telemetry/WebhookRuntimeEventSinkTests.cs | 35 +++++ 12 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs diff --git a/docs/superpowers/plans/2026-04-10-phase1-gaps.md b/docs/superpowers/plans/2026-04-10-phase1-gaps.md index e1d36c4..c8b602f 100644 --- a/docs/superpowers/plans/2026-04-10-phase1-gaps.md +++ b/docs/superpowers/plans/2026-04-10-phase1-gaps.md @@ -1,4 +1,4 @@ -Phase 1 Gaps Implementation Plan +# Phase 1 Gaps Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs b/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs index e1e270f..0895003 100644 --- a/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs +++ b/src/SharpClaw.Code.Memory/Services/HashTextEmbeddingService.cs @@ -1,4 +1,7 @@ +using System.Buffers.Binary; using System.Globalization; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace SharpClaw.Code.Memory.Services; @@ -20,9 +23,7 @@ public static float[] Embed(string? text) foreach (var token in Tokenize(text)) { - var hash = string.GetHashCode(token, StringComparison.Ordinal); - var index = Math.Abs(hash % Dimensions); - vector[index] += 1f; + AccumulateToken(vector, token); } Normalize(vector); @@ -107,4 +108,16 @@ private static IEnumerable Tokenize(string text) yield return new string([.. buffer]); } } + + private static void AccumulateToken(float[] vector, string token) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + for (var offset = 0; offset < 16; offset += 4) + { + var segment = BinaryPrimitives.ReadUInt32LittleEndian(hash.AsSpan(offset, 4)); + var index = (int)(segment % Dimensions); + var sign = (segment & 1u) == 0 ? 1f : -1f; + vector[index] += sign; + } + } } diff --git a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj index 8cc19e4..6ecbb13 100644 --- a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj +++ b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj @@ -16,4 +16,8 @@ Memory extraction, indexing, and recall for SharpClaw Code. + + + + diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs index bef85e7..8814132 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/OperationalDiagnosticsCoordinator.cs @@ -68,7 +68,7 @@ public async Task BuildStatusReportAsync(OperationalDiagnos : input.WorkingDirectory); var context = new OperationalDiagnosticsContext(workspacePath, input.Model, input.PermissionMode); var quickChecks = new List(); - foreach (var id in new[] { "workspace.access", "session.store", "mcp.registry", "plugins.registry", "approval.auth" }) + foreach (var id in new[] { "workspace.access", "session.store", "mcp.registry", "plugins.registry", "approval.auth", "provider.local-runtimes" }) { var check = orderedChecks.FirstOrDefault(c => c.Id == id); if (check is not null) diff --git a/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs b/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs index 9a910d1..ae9d7bd 100644 --- a/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs +++ b/src/SharpClaw.Code.Telemetry/Services/InProcessRuntimeEventStream.cs @@ -14,14 +14,17 @@ public sealed class InProcessRuntimeEventStream(IOptions telem private readonly ConcurrentQueue recent = new(); private readonly ConcurrentDictionary> subscribers = new(); private readonly int capacity = Math.Max(64, telemetryOptionsAccessor.Value.RuntimeEventRingBufferCapacity); + private int recentCount; /// public Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) { _ = cancellationToken; recent.Enqueue(envelope); - while (recent.Count > capacity && recent.TryDequeue(out _)) + Interlocked.Increment(ref recentCount); + while (Volatile.Read(ref recentCount) > capacity && recent.TryDequeue(out _)) { + Interlocked.Decrement(ref recentCount); } foreach (var channel in subscribers.Values) diff --git a/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs b/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs index 23adff3..2f50cad 100644 --- a/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs +++ b/src/SharpClaw.Code.Telemetry/Services/RuntimeEventPublisher.cs @@ -112,7 +112,22 @@ await persistence HostId: hostContext?.HostId); foreach (var sink in sinks) { - await sink.PublishAsync(envelope, cancellationToken).ConfigureAwait(false); + try + { + await sink.PublishAsync(envelope, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Runtime event {EventId} failed to publish to sink {SinkType}.", + runtimeEvent.EventId, + sink.GetType().Name); + } } } } diff --git a/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs b/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs index 1760eec..8ca142e 100644 --- a/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs +++ b/src/SharpClaw.Code.Telemetry/Services/WebhookRuntimeEventSink.cs @@ -32,10 +32,11 @@ public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken } var payload = JsonSerializer.Serialize(envelope, ProtocolJsonContext.Default.RuntimeEventEnvelope); + var maxAttempts = Math.Max(1, telemetryOptions.WebhookMaxAttempts); foreach (var url in telemetryOptions.EventWebhookUrls) { var attempt = 0; - while (attempt++ < Math.Max(1, telemetryOptions.WebhookMaxAttempts)) + while (attempt++ < maxAttempts) { try { @@ -44,7 +45,11 @@ public async Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken response.EnsureSuccessStatusCode(); break; } - catch (Exception exception) when (attempt < Math.Max(1, telemetryOptions.WebhookMaxAttempts)) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (attempt < maxAttempts) { logger.LogWarning( exception, diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs new file mode 100644 index 0000000..0a2504b --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/HashTextEmbeddingServiceTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using SharpClaw.Code.Memory.Services; + +namespace SharpClaw.Code.UnitTests.MemorySkillsGit; + +/// +/// Covers deterministic local embedding behavior. +/// +public sealed class HashTextEmbeddingServiceTests +{ + [Fact] + public void Embed_should_be_deterministic_for_the_same_input() + { + var first = HashTextEmbeddingService.Embed("Widget prompts should stay concise."); + var second = HashTextEmbeddingService.Embed("Widget prompts should stay concise."); + + first.Should().Equal(second); + } + + [Fact] + public void Cosine_should_prefer_related_content() + { + var query = HashTextEmbeddingService.Embed("concise widget prompt"); + var related = HashTextEmbeddingService.Embed("Widget prompts should stay concise and repo specific."); + var unrelated = HashTextEmbeddingService.Embed("database migration and sql schema"); + + HashTextEmbeddingService.Cosine(query, related).Should().BeGreaterThan(HashTextEmbeddingService.Cosine(query, unrelated)); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs new file mode 100644 index 0000000..57aa307 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Runtime/OperationalDiagnosticsCoordinatorTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Mcp.Abstractions; +using SharpClaw.Code.Plugins.Abstractions; +using SharpClaw.Code.Plugins.Models; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Operational; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Diagnostics; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.UnitTests.Runtime; + +/// +/// Covers status-report quick-check selection. +/// +public sealed class OperationalDiagnosticsCoordinatorTests +{ + [Fact] + public async Task BuildStatusReportAsync_should_include_local_runtime_health_in_quick_checks() + { + var coordinator = new OperationalDiagnosticsCoordinator( + [ + new StubOperationalCheck("workspace.access"), + new StubOperationalCheck("session.store"), + new StubOperationalCheck("mcp.registry"), + new StubOperationalCheck("plugins.registry"), + new StubOperationalCheck("approval.auth"), + new StubOperationalCheck("provider.local-runtimes", OperationalCheckStatus.Warn), + ], + new FixedClock(DateTimeOffset.Parse("2026-04-21T12:00:00Z")), + new PathService(), + new StubSessionStore(), + new StubMcpRegistry(), + new StubPluginManager(), + new StubEventStore(), + new StubWorkspaceDiagnosticsService()); + + var report = await coordinator.BuildStatusReportAsync( + new OperationalDiagnosticsInput("/workspace", null, PermissionMode.WorkspaceWrite, OutputFormat.Json), + CancellationToken.None); + + report.Checks.Should().Contain(check => check.Id == "provider.local-runtimes" && check.Status == OperationalCheckStatus.Warn); + } + + private sealed class StubOperationalCheck(string id, OperationalCheckStatus status = OperationalCheckStatus.Ok) : IOperationalCheck + { + public string Id { get; } = id; + + public Task ExecuteAsync(OperationalDiagnosticsContext context, CancellationToken cancellationToken) + => Task.FromResult(new OperationalCheckItem(Id, status, "ok", "detail")); + } + + private sealed class FixedClock(DateTimeOffset utcNow) : ISystemClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } + + private sealed class StubSessionStore : ISessionStore + { + public Task GetByIdAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task GetLatestAsync(string workspacePath, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> ListAllAsync(string workspacePath, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task SaveAsync(string workspacePath, ConversationSession session, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class StubMcpRegistry : IMcpRegistry + { + public Task GetAsync(string workspaceRoot, string serverId, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task RegisterAsync(string workspaceRoot, McpServerDefinition definition, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UpdateStatusAsync(string workspaceRoot, McpServerStatus status, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class StubPluginManager : IPluginManager + { + public Task DisableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnableAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task ExecuteToolAsync(string workspaceRoot, string toolName, ToolExecutionRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InstallAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task> ListAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task> ListToolDescriptorsAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult>([]); + + public Task UninstallAsync(string workspaceRoot, string pluginId, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task UpdateAsync(string workspaceRoot, PluginInstallRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private sealed class StubEventStore : IEventStore + { + public Task AppendAsync(string workspacePath, string sessionId, RuntimeEvent runtimeEvent, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task> ReadAllAsync(string workspacePath, string sessionId, CancellationToken cancellationToken) + => Task.FromResult>([]); + } + + private sealed class StubWorkspaceDiagnosticsService : IWorkspaceDiagnosticsService + { + public Task BuildSnapshotAsync(string workspaceRoot, CancellationToken cancellationToken) + => Task.FromResult(new WorkspaceDiagnosticsSnapshot(workspaceRoot, DateTimeOffset.UtcNow, [], [])); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs new file mode 100644 index 0000000..3e15581 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/InProcessRuntimeEventStreamTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Services; + +namespace SharpClaw.Code.UnitTests.Telemetry; + +/// +/// Verifies the in-process runtime event stream keeps a bounded recent-event buffer. +/// +public sealed class InProcessRuntimeEventStreamTests +{ + [Fact] + public async Task PublishAsync_should_trim_recent_envelopes_to_capacity() + { + var stream = new InProcessRuntimeEventStream(Options.Create(new TelemetryOptions + { + RuntimeEventRingBufferCapacity = 64, + })); + + for (var i = 0; i < 80; i++) + { + await stream.PublishAsync(CreateEnvelope($"evt-{i}"), CancellationToken.None); + } + + var snapshot = stream.GetRecentEnvelopesSnapshot(); + snapshot.Should().HaveCount(64); + snapshot.Select(item => item.Event.EventId).Should().Equal(Enumerable.Range(16, 64).Select(index => $"evt-{index}")); + } + + private static RuntimeEventEnvelope CreateEnvelope(string eventId) + => new( + EventType: nameof(UsageUpdatedEvent), + OccurredAtUtc: DateTimeOffset.UtcNow, + Event: new UsageUpdatedEvent( + EventId: eventId, + SessionId: "session-1", + TurnId: "turn-1", + OccurredAtUtc: DateTimeOffset.UtcNow, + Usage: new UsageSnapshot(1, 1, 0, 2, null))); +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs index ecf4061..5e04bc3 100644 --- a/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/TelemetryPublisherTests.cs @@ -87,4 +87,48 @@ public void JsonTraceExporter_should_emit_polymorphic_event_type() var json = exporter.SerializeEvents(events, writeIndented: false); json.Should().Contain("\"$eventType\":\"toolStarted\""); } + + /// + /// Ensures one failing external sink does not break telemetry publishing for the runtime. + /// + [Fact] + public async Task RuntimeEventPublisher_should_isolate_sink_failures() + { + var usageTracker = new UsageTracker(); + var recordingSink = new RecordingSink(); + var publisher = new RuntimeEventPublisher( + Options.Create(new TelemetryOptions { RuntimeEventRingBufferCapacity = 16 }), + usageTracker, + sinks: [new ThrowingSink(), recordingSink]); + + await publisher.PublishAsync( + new UsageUpdatedEvent( + EventId: "e2", + SessionId: "s1", + TurnId: "t1", + OccurredAtUtc: DateTimeOffset.UtcNow, + Usage: new UsageSnapshot(2, 3, 0, 5, 0.1m)), + new RuntimeEventPublishOptions("/tmp/ws", "s1", PersistToSessionStore: false), + CancellationToken.None); + + recordingSink.Envelopes.Should().ContainSingle(); + usageTracker.TryGetCumulative("s1")!.TotalTokens.Should().Be(5); + } + + private sealed class ThrowingSink : SharpClaw.Code.Telemetry.Abstractions.IRuntimeEventSink + { + public Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + => throw new InvalidOperationException("sink failed"); + } + + private sealed class RecordingSink : SharpClaw.Code.Telemetry.Abstractions.IRuntimeEventSink + { + public List Envelopes { get; } = []; + + public Task PublishAsync(RuntimeEventEnvelope envelope, CancellationToken cancellationToken) + { + Envelopes.Add(envelope); + return Task.CompletedTask; + } + } } diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs index 8335029..5113820 100644 --- a/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/WebhookRuntimeEventSinkTests.cs @@ -106,6 +106,30 @@ public async Task PublishAsync_should_apply_exponential_backoff_between_attempts TimeSpan.FromMilliseconds(200)); } + [Fact] + public async Task PublishAsync_should_propagate_cancellation_without_retrying() + { + var options = new TelemetryOptions + { + WebhookMaxAttempts = 3, + WebhookInitialBackoffMilliseconds = 50, + }; + options.EventWebhookUrls.Add("https://example.com/runtime-events"); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + var handler = new CancelingMessageHandler(cancellationTokenSource.Token); + var delayStrategy = new RecordingDelayStrategy(); + var sink = new WebhookRuntimeEventSink( + Options.Create(options), + new HttpClient(handler), + delayStrategy); + + await Assert.ThrowsAnyAsync(() => sink.PublishAsync(CreateEnvelope(), cancellationTokenSource.Token)); + + handler.AttemptCount.Should().Be(1); + delayStrategy.Delays.Should().BeEmpty(); + } + private static RuntimeEventEnvelope CreateEnvelope() => new( EventType: nameof(UsageUpdatedEvent), @@ -145,4 +169,15 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(new HttpResponseMessage(statusCode)); } } + + private sealed class CancelingMessageHandler(CancellationToken token) : HttpMessageHandler + { + public int AttemptCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + AttemptCount++; + return Task.FromCanceled(token); + } + } } From 78a2a8c81294b6dcec1cb55fb976cde1e0235237 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 20 Apr 2026 22:33:56 -0700 Subject: [PATCH 6/8] test: harden embedded auth server startup --- .../Runtime/ApprovalAuthIntegrationTests.cs | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs index 2cd956f..84cd2ed 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Net.Sockets; @@ -67,7 +68,6 @@ await File.WriteAllTextAsync( BaseAddress = new Uri($"http://127.0.0.1:{port}/"), Timeout = TimeSpan.FromSeconds(10), }; - await WaitForServerAsync(httpClient, CancellationToken.None); using var unauthenticatedAdminResponse = await httpClient.GetAsync("v1/admin/providers", CancellationToken.None); unauthenticatedAdminResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -130,10 +130,18 @@ private static async Task ReadErrorAsync(HttpResponseMessage response) : content; } - private static async Task WaitForServerAsync(HttpClient httpClient, CancellationToken cancellationToken) + private static async Task WaitForServerAsync(HttpClient httpClient, Task serverTask, TimeSpan timeout, CancellationToken cancellationToken) { - for (var attempt = 0; attempt < 30; attempt++) + var timer = Stopwatch.StartNew(); + var deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) { + if (serverTask.IsCompleted) + { + await serverTask.ConfigureAwait(false); + } + try { using var response = await httpClient.GetAsync("v1/status", cancellationToken).ConfigureAwait(false); @@ -145,11 +153,19 @@ private static async Task WaitForServerAsync(HttpClient httpClient, Cancellation catch (HttpRequestException) { } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !serverTask.IsCompleted) + { + } await Task.Delay(100, cancellationToken).ConfigureAwait(false); } - throw new TimeoutException("Embedded workspace HTTP server did not become ready."); + if (serverTask.IsCompleted) + { + await serverTask.ConfigureAwait(false); + } + + throw new TimeoutException($"Embedded workspace HTTP server did not become ready within {timer.ElapsedMilliseconds}ms."); } private static async Task<(int Port, Task ServerTask)> StartServerAsync( @@ -170,24 +186,34 @@ private static async Task WaitForServerAsync(HttpClient httpClient, Cancellation PermissionMode: PermissionMode.WorkspaceWrite, OutputFormat: OutputFormat.Json), cancellationToken); - await Task.Delay(100, CancellationToken.None).ConfigureAwait(false); - if (!serverTask.IsFaulted) + + try { + using var httpClient = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{port}/"), + Timeout = TimeSpan.FromMilliseconds(500), + }; + await WaitForServerAsync(httpClient, serverTask, TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); return (port, serverTask); } - - try + catch (HttpListenerException exception) when (IsAddressInUse(exception)) { - await serverTask.ConfigureAwait(false); } - catch (HttpListenerException exception) when (exception.Message.Contains("Address already in use", StringComparison.OrdinalIgnoreCase)) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + throw; } } throw new InvalidOperationException("The embedded workspace HTTP server could not bind to a free local port."); } + private static bool IsAddressInUse(HttpListenerException exception) + => exception.Message.Contains("Address already in use", StringComparison.OrdinalIgnoreCase) + || exception.ErrorCode == 48 + || exception.ErrorCode == 10048; + private static int FindFreePort() { using var listener = new TcpListener(IPAddress.Loopback, 0); From 370bae32075996e9f587cdb24022827d94da4c21 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 20 Apr 2026 22:40:13 -0700 Subject: [PATCH 7/8] test: harden cross-platform runtime coverage --- .../Runtime/ConversationRuntimeFlowTests.cs | 31 +++++++++++--- .../ParityScenarioTests.cs | 36 ++++++++++++++-- .../Acp/AcpStdioHostTests.cs | 15 ++++--- .../WorkspaceKnowledgeServicesTests.cs | 11 +---- .../Runtime/SharpClawConfigServiceTests.cs | 6 +-- .../Support/TestDirectoryCleanup.cs | 41 +++++++++++++++++++ .../Telemetry/UsageMeteringServiceTests.cs | 11 +---- 7 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs index fd8cb6e..f49606c 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs @@ -116,7 +116,7 @@ public async Task RunPrompt_cancellation_should_persist_failed_session_state() ["model"] = DeterministicMockModelProvider.DefaultModelId, [ParityMetadataKeys.Scenario] = ParityProviderScenario.StreamSlow, }); - var act = async () => await RunPromptWithCancelAfterAsync(runtime, request, TimeSpan.FromMilliseconds(400)); + var act = async () => await RunPromptWithCancelAfterTurnStartAsync(runtime, request, workspacePath, TimeSpan.FromMilliseconds(400)); await act.Should().ThrowAsync(); var latestSession = await runtime.GetLatestSessionAsync(workspacePath, CancellationToken.None); @@ -149,7 +149,7 @@ public async Task RunPrompt_after_failed_turn_should_recover_and_complete_next_t ["model"] = DeterministicMockModelProvider.DefaultModelId, [ParityMetadataKeys.Scenario] = ParityProviderScenario.StreamSlow, }); - var cancelAct = async () => await RunPromptWithCancelAfterAsync(runtime, cancelRequest, TimeSpan.FromMilliseconds(400)); + var cancelAct = async () => await RunPromptWithCancelAfterTurnStartAsync(runtime, cancelRequest, workspacePath, TimeSpan.FromMilliseconds(400)); await cancelAct.Should().ThrowAsync(); var second = await runtime.RunPromptAsync( @@ -180,20 +180,41 @@ private static ServiceProvider CreateServiceProvider(Action? } /// - /// Starts then schedules cancellation so setup work - /// (DI, workspace) does not consume the delay — matches real "cancel during turn" behavior. + /// Starts and waits for an active turn before arming cancellation. + /// This keeps the test focused on recovery from an in-flight turn rather than cancellation during setup. /// - private static async Task RunPromptWithCancelAfterAsync( + private static async Task RunPromptWithCancelAfterTurnStartAsync( IConversationRuntime runtime, RunPromptRequest request, + string workspacePath, TimeSpan cancelAfter) { using var cts = new CancellationTokenSource(); var runTask = runtime.RunPromptAsync(request, cts.Token); + await WaitForActiveTurnAsync(runtime, workspacePath, CancellationToken.None).ConfigureAwait(false); cts.CancelAfter(cancelAfter); await runTask.ConfigureAwait(false); } + private static async Task WaitForActiveTurnAsync( + IConversationRuntime runtime, + string workspacePath, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var latestSession = await runtime.GetLatestSessionAsync(workspacePath, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(latestSession?.ActiveTurnId)) + { + return; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException("The runtime did not activate a turn before cancellation was requested."); + } + private static string CreateTemporaryWorkspace() { var workspacePath = Path.Combine(Path.GetTempPath(), "sharpclaw-tests", Guid.NewGuid().ToString("N")); diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs index a6b4789..05ba6fc 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs @@ -294,11 +294,12 @@ public async Task Recovery_after_timeout_marks_session_failed() using var provider = ParityTestHost.Create(replaceApprovals: null); var runtime = ParityTestHost.GetConversation(provider); var store = provider.GetRequiredService(); - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); var act = async () => { - await runtime.RunPromptAsync( + await RunPromptWithCancelAfterTurnStartAsync( + runtime, + store, new RunPromptRequest( Prompt: "slow", SessionId: null, @@ -309,7 +310,7 @@ await runtime.RunPromptAsync( { [ParityMetadataKeys.Scenario] = ParityProviderScenario.StreamSlow, }), - cts.Token); + TimeSpan.FromMilliseconds(150)); }; await act.Should().ThrowAsync(); @@ -318,6 +319,35 @@ await runtime.RunPromptAsync( session!.State.Should().Be(SessionLifecycleState.Failed); } + private async Task RunPromptWithCancelAfterTurnStartAsync( + IConversationRuntime runtime, + ISessionStore store, + RunPromptRequest request, + TimeSpan cancelAfter) + { + using var cts = new CancellationTokenSource(); + var runTask = runtime.RunPromptAsync(request, cts.Token); + await WaitForActiveTurnAsync(store, CancellationToken.None); + cts.CancelAfter(cancelAfter); + await runTask.ConfigureAwait(false); + } + + private async Task WaitForActiveTurnAsync(ISessionStore store, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var session = await store.GetLatestAsync(_workspace, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(session?.ActiveTurnId)) + { + return; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + throw new TimeoutException("The parity runtime did not activate a turn before cancellation was requested."); + } + [Fact] public async Task Tool_call_roundtrip_executes_loop_and_returns_final_text() { diff --git a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs index 3cb443a..d6e7f91 100644 --- a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs @@ -20,6 +20,8 @@ namespace SharpClaw.Code.UnitTests.Acp; /// public sealed class AcpStdioHostTests { + private static readonly PathService PathService = new(); + [Fact] public async Task RunAsync_should_return_parse_error_for_invalid_json() { @@ -75,7 +77,7 @@ public async Task RunAsync_should_flow_model_and_editor_context_into_prompt_requ runtime.LastRequest.Metadata!["model"].Should().Be("ollama/qwen2.5-coder"); runtime.LastRequest.IsInteractive.Should().BeTrue(); editorBuffer.LastPublished.Should().NotBeNull(); - editorBuffer.LastPublished!.CurrentFilePath.Should().Be("/tmp/workspace/src/App.cs"); + editorBuffer.LastPublished!.CurrentFilePath.Should().Be(NormalizePath("/tmp/workspace/src/App.cs")); } [Fact] @@ -140,8 +142,8 @@ public async Task RunAsync_should_dispatch_workspace_index_and_search_requests() using var searchOutput = new StringWriter(new StringBuilder()); await host.RunAsync(searchInput, searchOutput, CancellationToken.None); - indexService.LastWorkspaceRoot.Should().Be("/tmp/workspace"); - searchService.LastWorkspaceRoot.Should().Be("/tmp/workspace"); + indexService.LastWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); + searchService.LastWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); searchService.LastRequest.Should().Be(new WorkspaceSearchRequest("WidgetService", 5, true, false)); var refresh = JsonSerializer.Deserialize( @@ -174,7 +176,7 @@ public async Task RunAsync_should_round_trip_memory_save_list_and_delete_request saved.Should().NotBeNull(); saved!.Scope.Should().Be(MemoryScope.Project); saved.SourceSessionId.Should().Be("session-1"); - memoryStore.LastSaveWorkspaceRoot.Should().Be("/tmp/workspace"); + memoryStore.LastSaveWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); using var listInput = new StringReader("""{"jsonrpc":"2.0","id":"list","method":"memory/list","params":{"cwd":"/tmp/workspace","scope":"Project","query":"concise","limit":10}}"""); using var listOutput = new StringWriter(new StringBuilder()); @@ -191,7 +193,7 @@ public async Task RunAsync_should_round_trip_memory_save_list_and_delete_request using var deleteOutput = new StringWriter(new StringBuilder()); await host.RunAsync(deleteInput, deleteOutput, CancellationToken.None); - memoryStore.LastDeleteWorkspaceRoot.Should().Be("/tmp/workspace"); + memoryStore.LastDeleteWorkspaceRoot.Should().Be(NormalizePath("/tmp/workspace")); memoryStore.LastDeleteScope.Should().Be(MemoryScope.Project); ReadResponseResult(deleteOutput, "delete")["deleted"]!.GetValue().Should().BeTrue(); } @@ -233,6 +235,9 @@ private static System.Text.Json.Nodes.JsonNode ReadResponseResult(StringWriter o throw new InvalidOperationException($"Could not find JSON-RPC response with id '{id}'."); } + private static string NormalizePath(string path) + => PathService.GetFullPath(path); + private sealed class StubConversationRuntime : IConversationRuntime { public RunPromptRequest? LastRequest { get; private set; } diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs index b140a52..1304d06 100644 --- a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/WorkspaceKnowledgeServicesTests.cs @@ -101,15 +101,8 @@ await memoryStore.SaveAsync( public void Dispose() { - if (Directory.Exists(workspaceRoot)) - { - Directory.Delete(workspaceRoot, recursive: true); - } - - if (Directory.Exists(userRoot)) - { - Directory.Delete(userRoot, recursive: true); - } + TestDirectoryCleanup.DeleteIfExists(workspaceRoot, clearSqlitePools: true); + TestDirectoryCleanup.DeleteIfExists(userRoot, clearSqlitePools: true); } private IWorkspaceKnowledgeStore CreateStore() diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs index e4e5cd3..58a91e2 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs @@ -2,6 +2,7 @@ using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Runtime.Configuration; +using SharpClaw.Code.UnitTests.Support; namespace SharpClaw.Code.UnitTests.Runtime; @@ -93,9 +94,6 @@ public void Dispose() Environment.SetEnvironmentVariable("HOME", originalHome); Environment.SetEnvironmentVariable("USERPROFILE", originalUserProfile); Environment.SetEnvironmentVariable("APPDATA", originalAppData); - if (Directory.Exists(tempRoot)) - { - Directory.Delete(tempRoot, recursive: true); - } + TestDirectoryCleanup.DeleteIfExists(tempRoot); } } diff --git a/tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs b/tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs new file mode 100644 index 0000000..f0b8b24 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Support/TestDirectoryCleanup.cs @@ -0,0 +1,41 @@ +using Microsoft.Data.Sqlite; + +namespace SharpClaw.Code.UnitTests.Support; + +internal static class TestDirectoryCleanup +{ + public static void DeleteIfExists(string path, bool clearSqlitePools = false) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + for (var attempt = 0; attempt < 10; attempt++) + { + if (!Directory.Exists(path)) + { + return; + } + + try + { + if (clearSqlitePools) + { + SqliteConnection.ClearAllPools(); + } + + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (attempt < 9) + { + Thread.Sleep(100); + } + catch (UnauthorizedAccessException) when (attempt < 9) + { + Thread.Sleep(100); + } + } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs index fd42bdb..0253e31 100644 --- a/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Telemetry/UsageMeteringServiceTests.cs @@ -162,14 +162,7 @@ await metering.PublishAsync( public void Dispose() { - if (Directory.Exists(workspaceRoot)) - { - Directory.Delete(workspaceRoot, recursive: true); - } - - if (Directory.Exists(userRoot)) - { - Directory.Delete(userRoot, recursive: true); - } + TestDirectoryCleanup.DeleteIfExists(workspaceRoot, clearSqlitePools: true); + TestDirectoryCleanup.DeleteIfExists(userRoot, clearSqlitePools: true); } } From 3071ac68a8bf1d143c70423355c9f014e15328a7 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 20 Apr 2026 22:46:02 -0700 Subject: [PATCH 8/8] test: relax windows runtime startup expectations --- .../Runtime/ConversationRuntimeFlowTests.cs | 2 +- tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs index f49606c..353016c 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/ConversationRuntimeFlowTests.cs @@ -201,7 +201,7 @@ private static async Task WaitForActiveTurnAsync( string workspacePath, CancellationToken cancellationToken) { - for (var attempt = 0; attempt < 100; attempt++) + for (var attempt = 0; attempt < 400; attempt++) { var latestSession = await runtime.GetLatestSessionAsync(workspacePath, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(latestSession?.ActiveTurnId)) diff --git a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs index d6e7f91..277a4ba 100644 --- a/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Acp/AcpStdioHostTests.cs @@ -77,7 +77,7 @@ public async Task RunAsync_should_flow_model_and_editor_context_into_prompt_requ runtime.LastRequest.Metadata!["model"].Should().Be("ollama/qwen2.5-coder"); runtime.LastRequest.IsInteractive.Should().BeTrue(); editorBuffer.LastPublished.Should().NotBeNull(); - editorBuffer.LastPublished!.CurrentFilePath.Should().Be(NormalizePath("/tmp/workspace/src/App.cs")); + editorBuffer.LastPublished!.CurrentFilePath.Should().Be("/tmp/workspace/src/App.cs"); } [Fact]