From e522f0869390d186db74ae2d6cb7dda641d3e84e Mon Sep 17 00:00:00 2001 From: David Choi Date: Wed, 9 Sep 2026 21:57:57 -0400 Subject: [PATCH 01/17] new go container; go crawlers, script, plugins, converter --- SECURITY.md | 4 +- docker/worker/.dockerignore | 3 + docker/worker/Dockerfile | 31 ++ docs/Design.md | 2 +- .../DBRepositoryTests/DBRepositoryTests.swift | 6 +- .../DerrickBackend/DaemonRuntime.swift | 2 +- .../DerrickBackend/HITLApprovalNotifier.swift | 25 +- .../PluginMessagingIngressAdapterTests.swift | 4 +- .../DockerRunRequestValidator.swift | 66 ++- .../DockerRunnerXPCTests.swift | 21 +- .../FactoryHarness/FactoryHarnessMain.swift | 2 +- .../LiveFactoryModels.swift | 16 +- .../ReferenceSlackConnectorDraft.swift | 344 ++++++++++++---- .../MCPServer/DockerImageInspector.swift | 36 ++ .../DockerProductImagePrewarmer.swift | 51 ++- .../FileExtractorDockerExecutor.swift | 10 +- .../MCPServer/FileExtractorToolModule.swift | 13 +- .../JobOrchestrationToolModule.swift | 2 +- .../MCPServer/PluginFactoryToolModule.swift | 2 +- .../Script/GoGuestDockerExecutor.swift | 266 ++++++++++++ .../GoPluginFactoryDockerExecutor.swift | 61 +++ .../MCPServer/Script/GoScriptVerifier.swift | 17 + .../MCPServer/Script/GuestHopLoop.swift | 2 +- .../MCPServer/Script/GuestPluginRunner.swift | 14 +- .../Script/PythonGuestDockerExecutor.swift | 133 ------ .../PythonPluginFactoryDockerExecutor.swift | 41 -- .../Script/PythonScriptVerifier.swift | 13 - .../Script/ScriptExecutionRuntime.swift | 56 +-- .../Script/ScriptExecutionSupport.swift | 6 +- .../Script/ScriptExecutionToolModule.swift | 4 +- .../MCPServer/WebCrawlerDockerExecutor.swift | 13 +- .../MCPServer/WebCrawlerToolModule.swift | 18 + .../E2EEnvironment.swift | 2 +- .../SlackConnectorInstallReferenceMain.swift | 2 +- .../LiveHarnessEnvironment.swift | 2 +- .../Tests/MCPServerTests/MCPServerTests.swift | 178 +++++--- ...thonPluginFactoryDockerExecutorTests.swift | 41 -- .../Factory/GuestGoSourceValidator.swift | 35 ++ .../Factory/GuestPythonSourceValidator.swift | 44 -- .../Factory/PluginFactoryImplementation.swift | 14 +- .../Plugin/Factory/PluginFactoryRuntime.swift | 5 +- .../PluginTests/PluginFactoryTests.swift | 85 ++-- .../ContainerLifecyclePolicy.swift | 2 +- .../AppServices/ServiceHealth.swift | 9 +- .../MCPService/EffectorAdmissionPolicy.swift | 12 +- .../MCPService/MCPServiceXPC.swift | 2 +- .../PluginFactoryCreateFailureMessage.swift | 4 +- .../MCPService/PluginFactoryCreateInput.swift | 2 +- .../SharedAgentRuntime/PromptResources.swift | 10 +- .../TurnProcessContextTypes.swift | 2 +- .../Sources/Contract/GuestContract.swift | 4 + .../Contract/GuestContractValidation.swift | 8 + .../schemas/file-extractor-result.schema.json | 7 + .../schemas/guest-runtime.schema.json | 29 ++ .../schemas/web-crawler-result.schema.json | 7 + .../schemas/worker-product.schema.json | 97 +++++ .../DockerRunnerXPC/DerrickGoToolchain.swift | 78 ++++ .../DockerRunnerXPC/DockerImageDigest.swift | 47 +++ .../DockerProductImageDigests.generated.swift | 6 + .../DockerProductImagePolicy.swift | 29 +- .../DockerRunnerXPC/DockerWorkerRuntime.swift | 22 + .../MCPServer/MCPServerContractTypes.swift | 8 +- .../MCPServer/ScriptExecutionModels.swift | 4 +- .../MCPToolCatalog/AllowedMCPTool.swift | 4 +- .../Plugin/Envelope/DerrickGuestGo.swift | 106 +++++ .../Plugin/Envelope/DerrickGuestPython.swift | 81 ---- .../Factory/PluginFactoryRuntimeTypes.swift | 8 +- .../Factory/PluginFactoryTestScript.swift | 5 +- .../Plugin/Factory/PluginFactoryTypes.swift | 33 +- .../Sources/Plugin/Manifest/PluginPath.swift | 10 +- .../AppLayerServicesWireTests.swift | 22 +- .../ConnectorContractTests.swift | 2 +- .../DerrickGoToolchainTests.swift | 12 + .../StructureTests/GuestContractTests.swift | 38 ++ readme.md | 16 +- scripts/record-docker-image-digests.sh | 23 ++ scripts/sync-guest-contract-schemas.sh | 11 + .../verify-guest-contract-schemas-in-sync.sh | 15 + .../AgentServiceExportedObject.swift | 2 +- ui/AgentService/AgentServiceTurnHost.swift | 1 - .../MCPServiceDockerHelperRunner.swift | 7 +- ui/MCPService/MCPServiceToolHost.swift | 28 +- .../Conversation/ConversationPipeline.swift | 2 +- .../Job/JobNetworkPreflight.swift | 107 ++--- .../conversation_rag_instructions.md | 5 + .../Resources/mcp_tool_instructions.md | 16 +- .../Resources/script_reviewer_instructions.md | 2 +- .../Resources/web_crawler_skill.md | 6 +- .../Services/DaemonProcessHygiene.swift | 2 +- .../Support/AppBootstrapStatus.swift | 37 ++ .../DockerRunner/XPCDockerRunner.swift | 13 +- .../Egress/EgressAllowlistService.swift | 273 ------------- .../Support/PluginFactoryModels.swift | 24 +- .../TurnProcessContext.swift | 2 +- ui/ui/Jobs/DerrickNotificationService.swift | 12 - ui/ui/Jobs/HITLLiveApprovalHandlers.swift | 50 +-- ui/ui/Views/ContentView.swift | 1 - ui/ui/Views/LLMModelSettingsView.swift | 2 +- ui/uiTests/MessagingNavigationTests.swift | 4 +- ui/uiTests/PromptResourcesTests.swift | 6 +- workers/go/cmd/derrick-crawler/main.go | 103 +++++ workers/go/cmd/derrick-file-extractor/main.go | 77 ++++ workers/go/go.mod | 21 + workers/go/go.sum | 100 +++++ workers/go/internal/contract/contract.go | 120 ++++++ workers/go/internal/contract/contract_test.go | 49 +++ .../schemas/connector-contract.schema.json | 97 +++++ .../schemas/connector-params.schema.json | 20 + .../schemas/connector-result-emit.schema.json | 54 +++ .../schemas/connector-vendor.schema.json | 28 ++ .../schemas/envelope-list.schema.json | 55 +++ .../execution-context-wire.schema.json | 46 +++ .../schemas/file-extractor-result.schema.json | 7 + .../schemas/guest-runtime.schema.json | 29 ++ .../contract/schemas/hop-event.schema.json | 42 ++ .../schemas/web-crawler-result.schema.json | 7 + .../schemas/worker-product.schema.json | 97 +++++ workers/go/internal/crawler/engine.go | 372 +++++++++++++++++ workers/go/internal/crawler/safety.go | 33 ++ workers/go/internal/crawler/types.go | 69 ++++ workers/go/internal/crawler/url.go | 87 ++++ workers/go/internal/crawler/validate.go | 66 +++ workers/go/internal/extractor/engine.go | 383 ++++++++++++++++++ workers/go/internal/extractor/types.go | 48 +++ 124 files changed, 3848 insertions(+), 1231 deletions(-) create mode 100644 docker/worker/.dockerignore create mode 100644 docker/worker/Dockerfile create mode 100644 packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift create mode 100644 packages/MCPServer/Sources/MCPServer/Script/GoGuestDockerExecutor.swift create mode 100644 packages/MCPServer/Sources/MCPServer/Script/GoPluginFactoryDockerExecutor.swift create mode 100644 packages/MCPServer/Sources/MCPServer/Script/GoScriptVerifier.swift delete mode 100644 packages/MCPServer/Sources/MCPServer/Script/PythonGuestDockerExecutor.swift delete mode 100644 packages/MCPServer/Sources/MCPServer/Script/PythonPluginFactoryDockerExecutor.swift delete mode 100644 packages/MCPServer/Sources/MCPServer/Script/PythonScriptVerifier.swift delete mode 100644 packages/MCPServer/Tests/MCPServerTests/PythonPluginFactoryDockerExecutorTests.swift create mode 100644 packages/Plugin/Sources/Plugin/Factory/GuestGoSourceValidator.swift delete mode 100644 packages/Plugin/Sources/Plugin/Factory/GuestPythonSourceValidator.swift create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/file-extractor-result.schema.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/guest-runtime.schema.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/web-crawler-result.schema.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json create mode 100644 packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift create mode 100644 packages/Structure/Sources/DockerRunnerXPC/DockerImageDigest.swift create mode 100644 packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift create mode 100644 packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift create mode 100644 packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift delete mode 100644 packages/Structure/Sources/Plugin/Envelope/DerrickGuestPython.swift create mode 100644 packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift create mode 100755 scripts/record-docker-image-digests.sh create mode 100755 scripts/sync-guest-contract-schemas.sh create mode 100755 scripts/verify-guest-contract-schemas-in-sync.sh delete mode 100644 ui/SharedAgentRuntime/Support/Egress/EgressAllowlistService.swift create mode 100644 workers/go/cmd/derrick-crawler/main.go create mode 100644 workers/go/cmd/derrick-file-extractor/main.go create mode 100644 workers/go/go.mod create mode 100644 workers/go/go.sum create mode 100644 workers/go/internal/contract/contract.go create mode 100644 workers/go/internal/contract/contract_test.go create mode 100644 workers/go/internal/contract/schemas/connector-contract.schema.json create mode 100644 workers/go/internal/contract/schemas/connector-params.schema.json create mode 100644 workers/go/internal/contract/schemas/connector-result-emit.schema.json create mode 100644 workers/go/internal/contract/schemas/connector-vendor.schema.json create mode 100644 workers/go/internal/contract/schemas/envelope-list.schema.json create mode 100644 workers/go/internal/contract/schemas/execution-context-wire.schema.json create mode 100644 workers/go/internal/contract/schemas/file-extractor-result.schema.json create mode 100644 workers/go/internal/contract/schemas/guest-runtime.schema.json create mode 100644 workers/go/internal/contract/schemas/hop-event.schema.json create mode 100644 workers/go/internal/contract/schemas/web-crawler-result.schema.json create mode 100644 workers/go/internal/contract/schemas/worker-product.schema.json create mode 100644 workers/go/internal/crawler/engine.go create mode 100644 workers/go/internal/crawler/safety.go create mode 100644 workers/go/internal/crawler/types.go create mode 100644 workers/go/internal/crawler/url.go create mode 100644 workers/go/internal/crawler/validate.go create mode 100644 workers/go/internal/extractor/engine.go create mode 100644 workers/go/internal/extractor/types.go diff --git a/SECURITY.md b/SECURITY.md index 470cbe9b..02096041 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ We will acknowledge receipt and work on a fix before public disclosure when poss Derrick runs LLM agents with tools on the user's Mac. The design assumes: - **Untrusted model output** — tools and scripts are gated before execution. -- **Untrusted guest code** — Python plugins/scripts run in Docker with no network; the host performs HTTP. +- **Untrusted guest code** — Go plugins/scripts compile and run in Docker with no network; the host performs HTTP. - **Untrusted remote content** — fetched HTML and tool output are sanitized before display. - **Secrets stay on the host** — API keys and connector tokens live in Keychain or local `.env` (dev only); they are not injected into guest containers. @@ -26,7 +26,7 @@ Derrick runs LLM agents with tools on the user's Mac. The design assumes: | Layer | Mechanism | |-------|-----------| -| Script execution | Docker `--network none`, static Python verifier, LLM script reviewer | +| Script execution | Docker `--network none`, static Go verifier, in-container compile, LLM script reviewer | | Network egress | Host HTTP client, egress blacklist, user approval for new destinations | | Plugins | Factory build + review; hop-limited `http.request`; Keychain-attached auth | | Inter-process | Code-signed XPC peers, HMAC-signed service messages (release: Keychain secret) | diff --git a/docker/worker/.dockerignore b/docker/worker/.dockerignore new file mode 100644 index 00000000..b01fe143 --- /dev/null +++ b/docker/worker/.dockerignore @@ -0,0 +1,3 @@ +**/.git +**/.build +**/node_modules diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile new file mode 100644 index 00000000..bab3814a --- /dev/null +++ b/docker/worker/Dockerfile @@ -0,0 +1,31 @@ +# Unified Go worker image: web crawl, file extract (plugin guest binaries are copied per invoke). +# Build: docker build -f docker/worker/Dockerfile -t derrick-worker:go-v1 . +FROM golang:1.27.1 AS build + +WORKDIR /src +COPY workers/go/go.mod workers/go/go.sum ./ +RUN go mod download +COPY workers/go ./ + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-web-crawler ./cmd/derrick-crawler +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-file-extractor ./cmd/derrick-file-extractor + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends poppler-utils ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 worker + +COPY --from=build /usr/local/go /usr/local/go +COPY --from=build /out/derrick-web-crawler /usr/local/bin/derrick-web-crawler +COPY --from=build /out/derrick-file-extractor /usr/local/bin/derrick-file-extractor +RUN mkdir -p /data/in /data/out && chown -R worker:worker /data + +ENV PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ENV GOROOT=/usr/local/go +ENV GOTOOLCHAIN=local +ENV CGO_ENABLED=0 + +USER worker +WORKDIR /home/worker diff --git a/docs/Design.md b/docs/Design.md index 6bcc9bf7..5a9171d9 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -10,4 +10,4 @@ This application is Protocol first. All major features must have a Protocol and ## Plugins - Plugins are using the Agent Plugin standard -- Plugins run in the plugins docker containers as either Python or Go (Go is not yet supported) +- Plugins and `script_exec` run in the unified Go worker Docker image (`derrick-worker:go-v1`) diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift index a546a2c0..9adf82a5 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift @@ -85,7 +85,7 @@ final class DBRepositoryTests: XCTestCase { let files: [String: Data] = [ "plugin.json": Data(#"{"name":"keep-me"}"#.utf8), "app.derrick/runtime.json": Data(#"{"language":"swift"}"#.utf8), - "app.derrick/plugin.py": Data("print(\"[]\")".utf8), + "app.derrick/plugin.go": Data("package main".utf8), "app.derrick/plugin": artifact, ] let release = PluginFactoryRelease( @@ -124,7 +124,7 @@ final class DBRepositoryTests: XCTestCase { let files: [String: Data] = [ "plugin.json": Data(#"{"name":"weather-tool"}"#.utf8), "app.derrick/runtime.json": Data(#"{"language":"swift"}"#.utf8), - "app.derrick/plugin.py": Data("print(\"[]\")".utf8), + "app.derrick/plugin.go": Data("package main".utf8), "app.derrick/plugin": artifact, "skills/weather/SKILL.md": Data("# Weather".utf8), ] @@ -169,7 +169,7 @@ final class DBRepositoryTests: XCTestCase { let files: [String: Data] = [ "plugin.json": Data(#"{"name":"weather-tool"}"#.utf8), "app.derrick/runtime.json": Data(#"{"language":"swift"}"#.utf8), - "app.derrick/plugin.py": Data("print(\"[]\")".utf8), + "app.derrick/plugin.go": Data("package main".utf8), "app.derrick/plugin": artifact, "skills/weather/SKILL.md": Data("# Weather".utf8), ] diff --git a/packages/DerrickBackend/Sources/DerrickBackend/DaemonRuntime.swift b/packages/DerrickBackend/Sources/DerrickBackend/DaemonRuntime.swift index 22131acd..96c27f92 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/DaemonRuntime.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/DaemonRuntime.swift @@ -52,7 +52,7 @@ public actor DaemonRuntime { service: .daemon, status: ok ? .ok : .degraded, detail: ok ? nil : "not bootstrapped", - guestRuntimeImage: DerrickGuestRuntime.pythonGuestDockerImage, + guestRuntimeImage: DerrickGuestRuntime.guestDockerImage, executableFingerprint: DaemonSelfRetirement.launchedFingerprint ) } diff --git a/packages/DerrickBackend/Sources/DerrickBackend/HITLApprovalNotifier.swift b/packages/DerrickBackend/Sources/DerrickBackend/HITLApprovalNotifier.swift index e617f6a0..a74708e3 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/HITLApprovalNotifier.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/HITLApprovalNotifier.swift @@ -31,11 +31,15 @@ public enum HITLApprovalNotifier: Sendable { let isNetwork = isNetworkToolName(row.toolName) let host = host(fromNetworkToolName: row.toolName) - let title = isNetwork ? "Network access needed" : "Approval needed" + let blacklistPattern = blacklistPattern(from: row.argumentsJSON) + let title = isNetwork + ? (blacklistPattern == nil ? "Network access needed" : "Network blacklist") + : "Approval needed" let body: String - if isNetwork, let host { - let suffix = Self.registrableSuffix(for: host) - body = "Allow *.\(suffix)? Tap to approve or deny. Always Allow covers all subdomains." + if isNetwork, let blacklistPattern { + body = "This request matches blacklist \(blacklistPattern). Tap to allow this run, remove from blacklist, or deny." + } else if isNetwork, let host { + body = "Network access to \(host). Tap to approve or deny." } else { let preview = truncated(row.argumentsJSON, limit: 160) body = preview.isEmpty @@ -80,12 +84,13 @@ public enum HITLApprovalNotifier: Sendable { return host.isEmpty ? nil : host } - /// Last two labels — same rule as egress permanent allow (`*.apple.com` ← `securemetrics.apple.com`). - private static func registrableSuffix(for host: String) -> String { - let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let parts = normalized.split(separator: ".").map(String.init) - guard parts.count >= 2 else { return normalized } - return parts.suffix(2).joined(separator: ".") + private static func blacklistPattern(from argumentsJSON: String) -> String? { + guard let data = argumentsJSON.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["kind"] as? String == "blacklist" else { + return nil + } + return object["pattern"] as? String } private static func truncated(_ text: String, limit: Int) -> String { diff --git a/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift b/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift index c2c11edc..717f1f0b 100644 --- a/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift +++ b/packages/DerrickBackend/Tests/DerrickBackendTests/PluginMessagingIngressAdapterTests.swift @@ -542,10 +542,10 @@ import Testing _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["send_message"]}}} """ let guestSource = "import json, sys\njson.dump([], sys.stdout)" - let runtimeJSON = #"{"language":"python","entrypoint":"./app.derrick/plugin.py"}"# + let runtimeJSON = #"{"language":"go","entrypoint":"./app.derrick/plugin.go"}"# let draft = PluginFactoryRelease( pluginID: "slack-connection", version: "1.0.0", diff --git a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift index 478c0adc..ec7cd578 100644 --- a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift +++ b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift @@ -93,11 +93,9 @@ public enum DockerRunRequestValidator: Sendable { return .disallowedDockerSubcommand(subcommand) } - if subcommand == "image" { - guard let second = dockerArgs.dropFirst().first, - DockerHostLaunch.allowedImageSubcommands.contains(second) else { - return .disallowedDockerSubcommand("image \(dockerArgs.dropFirst().first ?? "")") - } + if subcommand == "image", + let error = validateImageArguments(dockerArgs) { + return error } if subcommand == "exec", let error = validateExecArguments(dockerArgs) { @@ -206,20 +204,17 @@ public enum DockerRunRequestValidator: Sendable { case "sh": guard args.count == 3, args[1] == "-c", - args[2] == "cat > /tmp/guest.py" + args[2] == "cat > /tmp/guest && chmod +x /tmp/guest" + || args[2] == DockerWorkerRuntime.guestWriteSourceShell + || args[2] == DockerWorkerRuntime.guestCompileShell + || args[2] == DockerWorkerRuntime.guestReadBinaryShell else { return .disallowedDockerFlag("exec \(command)") } - case "python3": - guard args.count == 2, args[1] == "/tmp/guest.py" else { - return .disallowedDockerFlag("exec \(command)") - } - case "/usr/local/bin/derrick-web-crawler": - guard args == ["/usr/local/bin/derrick-web-crawler"] else { - return .disallowedDockerFlag("exec \(command)") - } - case "/usr/local/bin/derrick-file-extractor": - guard args == ["/usr/local/bin/derrick-file-extractor"] else { + case DockerWorkerRuntime.crawlerBinary, + DockerWorkerRuntime.extractorBinary, + DockerWorkerRuntime.guestBinaryPath: + guard args == [command] else { return .disallowedDockerFlag("exec \(command)") } default: @@ -244,12 +239,45 @@ public enum DockerRunRequestValidator: Sendable { guard args.count == 5 else { return .disallowedDockerFlag("build extra arguments") } - guard DockerProductImagePolicy.isAllowedWebCrawlerBuild( + if DockerProductImagePolicy.isAllowedWorkerBuild( + dockerfilePath: dockerfile, + imageTag: tag, + contextPath: context + ) { + return nil + } + if DockerProductImagePolicy.isAllowedWebCrawlerBuild( dockerfilePath: dockerfile, imageTag: tag, contextPath: context - ) else { - return .disallowedDockerFlag("build product image") + ) { + return nil + } + return .disallowedDockerFlag("build product image") + } + + private static func validateImageArguments( + _ dockerArgs: [String] + ) -> DockerRunRequestValidationError? { + let args = Array(dockerArgs.dropFirst()) + guard let second = args.first, + DockerHostLaunch.allowedImageSubcommands.contains(second) else { + return .disallowedDockerSubcommand("image \(args.first ?? "")") + } + guard second == "inspect" else { return nil } + if args.count == 2 { + return nil + } + guard args.count == 4, + args[1] == "--format", + args[2] == "{{.Id}}" else { + return .disallowedDockerFlag("image inspect") + } + let tag = args[3] + guard tag == DockerWorkerRuntime.image + || tag == DockerProductImagePolicy.webCrawlerImage + || tag == DerrickGuestRuntime.guestDockerImage else { + return .disallowedDockerFlag("image inspect tag") } return nil } diff --git a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift index e806f4ad..7daa176c 100644 --- a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift +++ b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift @@ -211,8 +211,11 @@ struct DockerRunnerXPCTests { ["version"], ["image", "inspect", "img"], ["pull", "img"], - ["exec", "-i", "c", "python3", "/tmp/guest.py"], - ["exec", "-i", "c", "sh", "-c", "cat > /tmp/guest.py"], + ["exec", "-i", "c", DockerWorkerRuntime.guestBinaryPath], + ["exec", "-i", "c", "sh", "-c", "cat > /tmp/guest && chmod +x /tmp/guest"], + ["exec", "-i", "c", "sh", "-c", DockerWorkerRuntime.guestWriteSourceShell], + ["exec", "c", "sh", "-c", DockerWorkerRuntime.guestCompileShell], + ["exec", "c", "sh", "-c", DockerWorkerRuntime.guestReadBinaryShell], ["exec", "-i", "c", "/usr/local/bin/derrick-web-crawler"], ["create", "--label", "app.derrick=runtime", "--entrypoint", "/bin/sleep", "--name", "c", "derrick-web-crawler:swift-6.4-v1", "infinity"], [ @@ -229,7 +232,7 @@ struct DockerRunnerXPCTests { "--memory", "1g", "--security-opt", "no-new-privileges", "--cap-drop", "ALL", - "python:3.14.7", + DockerWorkerRuntime.image, "/bin/sleep", "infinity", ], @@ -256,13 +259,13 @@ struct DockerRunnerXPCTests { } } - @Test func rejectsInvalidPythonGuestExecCommands() { + @Test func rejectsInvalidGoGuestExecCommands() { for args in [ - ["exec", "-i", "c", "python3", "/tmp/other.py"], - ["exec", "-i", "c", "python3", "/tmp/guest.py", "extra"], - ["exec", "-i", "c", "sh", "-c", "cat > /tmp/other.py"], + ["exec", "-i", "c", "python3", "/tmp/guest.py"], + ["exec", "-i", "c", DockerWorkerRuntime.guestBinaryPath, "extra"], + ["exec", "-i", "c", "sh", "-c", "cat > /tmp/other"], ["exec", "-i", "c", "sh", "-c", "rm -rf /"], - ["exec", "-i", "c", "sh", "-c", "cat > /tmp/guest.py", "extra"], + ["exec", "-i", "c", "sh", "-c", "go build /tmp/plugin.go"], ["exec", "-i", "c", "swift", "/tmp/plugin.swift"], ["exec", "-i", "c", "/tmp/plugin"], ["exec", "-i", "c", "swiftc", "-O", "/tmp/plugin.swift", "-o", "/tmp/plugin"], @@ -354,7 +357,7 @@ struct DockerRunnerXPCTests { @Test func rejectsCreateWithoutRuntimeLabel() { let r = request(arguments: DockerHostLaunch.dockerCLIArguments([ - "create", "--name", "c", "python:3.14.7", "/bin/sleep", "infinity", + "create", "--name", "c", DockerWorkerRuntime.image, "/bin/sleep", "infinity", ])) #expect(DockerRunRequestValidator.validate(r) == .disallowedDockerFlag("create missing runtime label")) } diff --git a/packages/MCPServer/Sources/FactoryHarness/FactoryHarnessMain.swift b/packages/MCPServer/Sources/FactoryHarness/FactoryHarnessMain.swift index 0359bf12..dfe1be76 100644 --- a/packages/MCPServer/Sources/FactoryHarness/FactoryHarnessMain.swift +++ b/packages/MCPServer/Sources/FactoryHarness/FactoryHarnessMain.swift @@ -26,7 +26,7 @@ enum FactoryHarnessMain { let goal = input.connectorBuildGoal(crawlSummary: SlackConnectorFactoryInput.defaultCrawlSummary) fputs("FactoryHarness: building slack full-sync connector…\n", stderr) - let executor = PythonPluginFactoryDockerExecutor(executor: DirectShellDocker.executor()) + let executor = GoPluginFactoryDockerExecutor(executor: DirectShellDocker.executor()) let release = try await PluginFactorySession( configuration: PluginFactoryConfiguration(maxBuilderAttempts: 3) ).build( diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift index 889e2aac..af2a46cb 100644 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift @@ -34,7 +34,7 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { """ The host already assigned plugin_id \(host.pluginID) and these secret ids: \ \(host.secrets.map(\.id).joined(separator: ", ")). \ - Return python_source and test_input_json. Do not pick a different plugin_id or secret ids. + Return go_source and test_input_json. Do not pick a different plugin_id or secret ids. """ ) } @@ -76,7 +76,7 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { """ You are the Derrick plugin builder. Convert the user's goal into one complete Agent Plugin draft. Return exactly one JSON object with these keys: - plugin_id (string), version (string), description (string), python_source (string), + plugin_id (string), version (string), description (string), go_source (string), test_input_json (string containing valid JSON — a serialized object, not prose), skill_files (array of objects with path and body), secrets (array of objects with id, label, and kind; required for connector plugins), @@ -87,7 +87,7 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { If the plugin needs a username, password, token, or API key, declare them in secrets. kind must be username, password, token, or api_key. id is a stable Keychain key such as username or bot_token. label is the text shown when the user saves the value. - Never put real credentials in python_source. + Never put real credentials in go_source. Set role to "connector" when the plugin sends and receives messages with an external messaging service (any chat or mail connector). Omit role or use "standard" otherwise. For role connector, include messaging_ops: an array of implemented ops @@ -95,12 +95,12 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { Do not return manifest_json. The host creates the canonical Agent Plugin manifest. If skill_files is not needed, return an empty array. Every skill file path must be exactly skills//SKILL.md. Never use manifest.json or other paths in skill_files. - \(DerrickGuestPython.modelContract) + \(DerrickGuestGo.modelContract) \(ConnectorContractPrompts.builderGuide(forUserGoal: userGoal)) Before returning the draft, self-check the implementation: - Sort every returned collection by an explicit stable key after parsing and de-duplicate it. - Match host responses by the emitted request_id. - - Use only the Python standard library (no pip, requests, urllib, socket, or subprocess). + - Use only the Go standard library (no net/http, os/exec, or filesystem access). - The direct test input must exercise the terminal result path with matching http_results fixtures. For messaging connector plugins (role connector) that call a vendor HTTP API: - Declare secrets in the manifest only. Never hard-code credentials. @@ -120,7 +120,7 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { "plugin_id": AgentSchema(type: .string), "version": AgentSchema(type: .string), "description": AgentSchema(type: .string), - "python_source": AgentSchema(type: .string), + "go_source": AgentSchema(type: .string), "test_input_json": AgentSchema(type: .string), "skill_files": AgentSchema( type: .array, @@ -149,7 +149,7 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { "messaging_ops": AgentSchema(type: .array, items: AgentSchema(type: .string)), ], required: [ - "plugin_id", "version", "description", "python_source", + "plugin_id", "version", "description", "go_source", "test_input_json", "skill_files", ] ) @@ -181,7 +181,7 @@ public actor LiveFactoryReviewer: PluginFactoryReviewer { test_input_json: \(String(decoding: draft.testInput, as: UTF8.self)) - Python source: + Go source: \(draft.guestSource) Direct test output: diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift index 8e102534..54110bc1 100644 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/ReferenceSlackConnectorDraft.swift @@ -41,7 +41,7 @@ public enum ReferenceSlackConnectorDraft { let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ "description":"Slack messaging connector",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","auth_scheme":"bot_token","secrets":[{"id":"bot_token","label":"Bot Token","kind":"token"}],"permissions":["channels:history","channels:read","chat:write","groups:history","groups:read","im:history","im:read","mpim:history","mpim:read","users:read"],"messaging_ops":[\(opsJSON)]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","auth_scheme":"bot_token","secrets":[{"id":"bot_token","label":"Bot Token","kind":"token"}],"permissions":["channels:history","channels:read","chat:write","groups:history","groups:read","im:history","im:read","mpim:history","mpim:read","users:read"],"messaging_ops":[\(opsJSON)]}}} """ return PluginFactoryDraft( manifestJSON: manifestJSON, @@ -52,104 +52,278 @@ public enum ReferenceSlackConnectorDraft { } private static let fullSyncSource = """ - import json, sys - def emit(x): json.dump(x, sys.stdout, separators=(",", ":")) - def sorted_results(results): - seen = set() - out = [] - for item in sorted(results or [], key=lambda r: str(r.get("request_id",""))): - rid = str(item.get("request_id","")) - if rid and rid not in seen: - seen.add(rid) - out.append(item) + package main + + import ( + "encoding/json" + "os" + "sort" + ) + + func emit(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + } + + func sortedResults(results []map[string]any) []map[string]any { + if len(results) == 0 { + return nil + } + sort.Slice(results, func(i, j int) bool { + left, _ := results[i]["request_id"].(string) + right, _ := results[j]["request_id"].(string) + return left < right + }) + seen := map[string]bool{} + out := []map[string]any{} + for _, item := range results { + rid, _ := item["request_id"].(string) + if rid == "" || seen[rid] { + continue + } + seen[rid] = true + out = append(out, item) + } return out - def message_from(msg, default_channel): - if not isinstance(msg, dict): return None - channel = msg.get("channel") or default_channel - ts = msg.get("ts") - if not channel or not ts: return None - thread_ts = msg.get("thread_ts") - parent = None - if thread_ts and str(thread_ts) != str(ts): - parent = str(thread_ts) - reply_count = int(msg.get("reply_count") or 0) - row = {"vendor_thread_id":channel,"vendor_message_id":str(ts),"direction":"inbound","sender":msg.get("user") or "slack","body":msg.get("text") or "","created_at":str(ts),"reply_count":reply_count} - if parent: + } + + func asMap(v any) map[string]any { + if m, ok := v.(map[string]any); ok { + return m + } + return map[string]any{} + } + + func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" + } + + func messageFrom(msg map[string]any, defaultChannel string) map[string]any { + channel := asString(msg["channel"]) + if channel == "" { + channel = defaultChannel + } + ts := asString(msg["ts"]) + if channel == "" || ts == "" { + return nil + } + threadTS := asString(msg["thread_ts"]) + parent := "" + if threadTS != "" && threadTS != ts { + parent = threadTS + } + replyCount := 0 + if n, ok := msg["reply_count"].(float64); ok { + replyCount = int(n) + } + row := map[string]any{ + "vendor_thread_id": channel, + "vendor_message_id": ts, + "direction": "inbound", + "sender": func() string { + if s := asString(msg["user"]); s != "" { + return s + } + return "slack" + }(), + "body": asString(msg["text"]), + "created_at": ts, + "reply_count": replyCount, + } + if parent != "" { row["parent_vendor_message_id"] = parent + } return row - def main(): - event = json.load(sys.stdin) - params = event.get("params") or {} - op = params.get("messaging_op") - if event.get("kind") == "manual" and op == "sync_threads": - emit([{"verb":"http.request","request_id":"sync-1","method":"GET","url":"https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true","headers":{"Authorization":"Bearer {{secret:bot_token}}"}}]) + } + + func main() { + var event map[string]any + if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { return - if event.get("kind") == "manual" and op == "poll_inbox": - channel = params.get("vendor_thread_id") or params.get("channel") - parent = params.get("parent_vendor_message_id") or params.get("thread_ts") - if not channel: - emit([{"verb":"result.emit","messages":[]}]) + } + params := asMap(event["params"]) + op := asString(params["messaging_op"]) + kind := asString(event["kind"]) + + switch { + case kind == "manual" && op == "sync_threads": + emit([]map[string]any{{ + "verb": "http.request", "request_id": "sync-1", "method": "GET", + "url": "https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200&exclude_archived=true", + "headers": map[string]any{"Authorization": "Bearer {{secret:bot_token}}"}, + }}) + return + case kind == "manual" && op == "poll_inbox": + channel := asString(params["vendor_thread_id"]) + if channel == "" { + channel = asString(params["channel"]) + } + parent := asString(params["parent_vendor_message_id"]) + if parent == "" { + parent = asString(params["thread_ts"]) + } + if channel == "" { + emit([]map[string]any{{"verb": "result.emit", "messages": []map[string]any{}}}) return - if parent: - url = "https://slack.com/api/conversations.replies?channel=" + str(channel) + "&ts=" + str(parent) + "&limit=50" - emit([{"verb":"http.request","request_id":"replies-1","method":"GET","url":url,"headers":{"Authorization":"Bearer {{secret:bot_token}}"}}]) + } + if parent != "" { + url := "https://slack.com/api/conversations.replies?channel=" + channel + "&ts=" + parent + "&limit=50" + emit([]map[string]any{{ + "verb": "http.request", "request_id": "replies-1", "method": "GET", "url": url, + "headers": map[string]any{"Authorization": "Bearer {{secret:bot_token}}"}, + }}) return - url = "https://slack.com/api/conversations.history?channel=" + str(channel) + "&limit=50" - emit([{"verb":"http.request","request_id":"poll-1","method":"GET","url":url,"headers":{"Authorization":"Bearer {{secret:bot_token}}"}}]) + } + url := "https://slack.com/api/conversations.history?channel=" + channel + "&limit=50" + emit([]map[string]any{{ + "verb": "http.request", "request_id": "poll-1", "method": "GET", "url": url, + "headers": map[string]any{"Authorization": "Bearer {{secret:bot_token}}"}, + }}) return - if event.get("kind") == "message_in_room" and op == "send_message": - channel = params.get("vendor_thread_id") - text = params.get("text", "") - parent = params.get("parent_vendor_message_id") or params.get("thread_ts") - payload = {"channel":channel,"text":text} - if parent: + case kind == "message_in_room" && op == "send_message": + channel := asString(params["vendor_thread_id"]) + text := asString(params["text"]) + parent := asString(params["parent_vendor_message_id"]) + if parent == "" { + parent = asString(params["thread_ts"]) + } + payload := map[string]any{"channel": channel, "text": text} + if parent != "" { payload["thread_ts"] = parent - emit([{"verb":"http.request","request_id":"send-1","method":"POST","url":"https://slack.com/api/chat.postMessage","headers":{"Authorization":"Bearer {{secret:bot_token}}","Content-Type":"application/json"},"json":payload}]) + } + emit([]map[string]any{{ + "verb": "http.request", "request_id": "send-1", "method": "POST", + "url": "https://slack.com/api/chat.postMessage", + "headers": map[string]any{ + "Authorization": "Bearer {{secret:bot_token}}", + "Content-Type": "application/json", + }, + "json": payload, + }}) return - if event.get("kind") == "http_results" and op == "sync_threads": - threads = [] - for item in sorted_results(event.get("http_results")): - if item.get("request_id") != "sync-1": continue - payload = json.loads(item.get("body") or "{}") - if payload.get("ok") is False: - emit([{"verb":"result.emit","title":"Slack list failed","summary":str(payload.get("error") or "unknown")}]) + case kind == "http_results" && op == "sync_threads": + threads := []map[string]any{} + rawResults, _ := event["http_results"].([]any) + results := []map[string]any{} + for _, item := range rawResults { + results = append(results, asMap(item)) + } + for _, item := range sortedResults(results) { + if asString(item["request_id"]) != "sync-1" { + continue + } + var payload map[string]any + _ = json.Unmarshal([]byte(asString(item["body"])), &payload) + if payload["ok"] == false { + emit([]map[string]any{{ + "verb": "result.emit", + "title": "Slack list failed", + "summary": asString(payload["error"]), + }}) return - for ch in sorted(payload.get("channels") or [], key=lambda c: str(c.get("id",""))): - if ch.get("is_member") is False: + } + channels, _ := payload["channels"].([]any) + sort.Slice(channels, func(i, j int) bool { + left := asMap(channels[i]) + right := asMap(channels[j]) + return asString(left["id"]) < asString(right["id"]) + }) + for _, chAny := range channels { + ch := asMap(chAny) + if ch["is_member"] == false { continue - cid = ch.get("id") - name = ch.get("name") or cid - if cid: - threads.append({"vendor_thread_id":cid,"title":"#" + str(name)}) - emit([{"verb":"result.emit","threads":threads}]) + } + cid := asString(ch["id"]) + name := asString(ch["name"]) + if name == "" { + name = cid + } + if cid != "" { + threads = append(threads, map[string]any{ + "vendor_thread_id": cid, + "title": "#" + name, + }) + } + } + } + emit([]map[string]any{{"verb": "result.emit", "threads": threads}}) return - if event.get("kind") == "http_results" and op == "poll_inbox": - channel = params.get("vendor_thread_id") or params.get("channel") - messages = [] - for item in sorted_results(event.get("http_results")): - if item.get("request_id") not in ("poll-1", "replies-1"): continue - payload = json.loads(item.get("body") or "{}") - if payload.get("ok") is False: - emit([{"verb":"result.emit","title":"Slack blocked this thread","summary":str(payload.get("error") or "unknown")}]) + case kind == "http_results" && op == "poll_inbox": + channel := asString(params["vendor_thread_id"]) + if channel == "" { + channel = asString(params["channel"]) + } + messages := []map[string]any{} + rawResults, _ := event["http_results"].([]any) + results := []map[string]any{} + for _, item := range rawResults { + results = append(results, asMap(item)) + } + for _, item := range sortedResults(results) { + rid := asString(item["request_id"]) + if rid != "poll-1" && rid != "replies-1" { + continue + } + var payload map[string]any + _ = json.Unmarshal([]byte(asString(item["body"])), &payload) + if payload["ok"] == false { + emit([]map[string]any{{ + "verb": "result.emit", + "title": "Slack blocked this thread", + "summary": asString(payload["error"]), + }}) return - for msg in sorted(payload.get("messages") or [], key=lambda m: str(m.get("ts",""))): - parsed = message_from(msg, channel) - if parsed: messages.append(parsed) - emit([{"verb":"result.emit","messages":messages}]) + } + rawMessages, _ := payload["messages"].([]any) + sort.Slice(rawMessages, func(i, j int) bool { + left := asMap(rawMessages[i]) + right := asMap(rawMessages[j]) + return asString(left["ts"]) < asString(right["ts"]) + }) + for _, msgAny := range rawMessages { + parsed := messageFrom(asMap(msgAny), channel) + if parsed != nil { + messages = append(messages, parsed) + } + } + } + emit([]map[string]any{{"verb": "result.emit", "messages": messages}}) return - if event.get("kind") == "http_results" and op == "send_message": - body = {} - for item in sorted_results(event.get("http_results")): - if item.get("request_id") == "send-1": - body = json.loads(item.get("body") or "{}") - if body.get("ok"): - ts = body.get("ts") or (body.get("message") or {}).get("ts") - emit([{"verb":"result.emit","sent_message":{"vendor_message_id":ts,"created_at":ts}}]) - else: - emit([{"verb":"result.emit","summary":"send failed"}]) + case kind == "http_results" && op == "send_message": + body := map[string]any{} + rawResults, _ := event["http_results"].([]any) + results := []map[string]any{} + for _, item := range rawResults { + results = append(results, asMap(item)) + } + for _, item := range sortedResults(results) { + if asString(item["request_id"]) == "send-1" { + _ = json.Unmarshal([]byte(asString(item["body"])), &body) + } + } + if body["ok"] == true { + ts := asString(body["ts"]) + if ts == "" { + ts = asString(asMap(body["message"])["ts"]) + } + emit([]map[string]any{{ + "verb": "result.emit", + "sent_message": map[string]any{ + "vendor_message_id": ts, + "created_at": ts, + }, + }}) + } else { + emit([]map[string]any{{"verb": "result.emit", "summary": "send failed"}}) + } return - emit([{"verb":"result.emit","summary":"unsupported"}]) - if __name__ == "__main__": - main() + default: + emit([]map[string]any{{"verb": "result.emit", "summary": "unsupported"}}) + } + } """ } diff --git a/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift b/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift new file mode 100644 index 00000000..63835ade --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift @@ -0,0 +1,36 @@ +import Foundation +import Structure + +/// Reads and verifies pinned Docker product image digests. +public enum DockerImageInspector: Sendable { + public static func localImageID( + tag: String, + executor: @escaping DockerCLIExecutor + ) async throws -> DockerImageDigest { + let response = try await executor( + ["image", "inspect", "--format", "{{.Id}}", tag], + Data(), + 30 + ) + guard response.exitCode == 0 else { + throw DockerImageDigestError.imageMissing(tag) + } + let raw = String(decoding: response.stdout, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let digest = DockerImageDigest(hexDigest: raw) else { + throw DockerImageDigestError.imageMissing(tag) + } + return digest + } + + public static func verifyPinned( + tag: String, + expected: DockerImageDigest, + executor: @escaping DockerCLIExecutor + ) async throws { + let actual = try await localImageID(tag: tag, executor: executor) + guard actual == expected else { + throw DockerImageDigestError.digestMismatch(tag: tag, expected: expected, actual: actual) + } + } +} diff --git a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift index 3522edc8..fcff866b 100644 --- a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift +++ b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift @@ -2,29 +2,45 @@ import Foundation import DockerRunnerXPC import Structure -/// Ensures trusted product Docker images exist (pull or local build). +/// Ensures trusted product Docker images exist (local build) and match pinned digests. public enum DockerProductImagePrewarmer: Sendable { - public static func ensureWebCrawlerImage( + public static func ensureWorkerImage( executor: @escaping DockerCLIExecutor ) async throws { try await ensureImage( - tag: DockerProductImagePolicy.webCrawlerImage, - dockerfileRelativePath: DockerProductImagePolicy.webCrawlerDockerfileRelativePath, - contextRelativePath: DockerProductImagePolicy.webCrawlerBuildContextRelativePath, + tag: DockerProductImagePolicy.workerImage, + dockerfileRelativePath: DockerProductImagePolicy.workerDockerfileRelativePath, + contextRelativePath: DockerProductImagePolicy.workerBuildContextRelativePath, + pinnedDigest: DockerWorkerRuntime.pinnedDigest, + buildValidator: DockerProductImagePolicy.isAllowedWorkerBuild, executor: executor, buildTimeoutSeconds: 1_200 ) } + /// Legacy alias used by crawler startup paths. + public static func ensureWebCrawlerImage( + executor: @escaping DockerCLIExecutor + ) async throws { + try await ensureWorkerImage(executor: executor) + } + public static func ensureImage( tag: String, dockerfileRelativePath: String, contextRelativePath: String, + pinnedDigest: DockerImageDigest, + buildValidator: @escaping (String, String, String) -> Bool, executor: @escaping DockerCLIExecutor, buildTimeoutSeconds: Int = 1_200 ) async throws { let inspect = try await executor(["image", "inspect", tag], Data(), 30) if inspect.exitCode == 0 { + try await DockerImageInspector.verifyPinned( + tag: tag, + expected: pinnedDigest, + executor: executor + ) return } @@ -39,6 +55,9 @@ public enum DockerProductImagePrewarmer: Sendable { guard FileManager.default.fileExists(atPath: context.path) else { throw DockerProductImagePrewarmerError.dockerfileMissing(context.path) } + guard buildValidator(dockerfile.path, tag, context.path) else { + throw DockerProductImagePrewarmerError.buildFailed(tag, "build policy rejected image build") + } let build = try await executor( [ @@ -58,14 +77,16 @@ public enum DockerProductImagePrewarmer: Sendable { detail.isEmpty ? "exit \(build.exitCode)" : detail ) } + + try await DockerImageInspector.verifyPinned( + tag: tag, + expected: pinnedDigest, + executor: executor + ) } } -/// One in-flight crawler image build per process. -/// -/// Chat and daemon start this in the background. A `web.crawl` that arrives -/// while it is still running waits on the same task and does not start a second -/// `docker build`. +/// One in-flight worker image build per process. public actor WebCrawlerImageGate { public static let shared = WebCrawlerImageGate() @@ -79,7 +100,7 @@ public actor WebCrawlerImageGate { return } let task = Task { - try await DockerProductImagePrewarmer.ensureWebCrawlerImage(executor: executor) + try await DockerProductImagePrewarmer.ensureWorkerImage(executor: executor) } inFlight = task do { @@ -97,8 +118,6 @@ public enum DockerProductImagePrewarmerError: Error, LocalizedError, Equatable, case dockerfileMissing(String) case buildFailed(String, String) - /// First compiler `error:` line, if the docker build log has one. Not shown as the - /// user-facing `errorDescription` (that stays a short human sentence). public var compilerDiagnostic: String? { switch self { case .buildFailed(_, let detail): @@ -111,11 +130,11 @@ public enum DockerProductImagePrewarmerError: Error, LocalizedError, Equatable, public var errorDescription: String? { switch self { case .repositoryRootNotFound: - return "The web crawler image is not installed and Derrick could not find its source to build it." + return "The worker image is not installed and Derrick could not find its source to build it." case .dockerfileMissing: - return "The web crawler image is not installed and Derrick could not find its build files." + return "The worker image is not installed and Derrick could not find its build files." case .buildFailed: - return "Derrick could not build the web crawler image. Make sure Docker Desktop is running, has enough disk space, and can reach the network." + return "Derrick could not build the worker image. Make sure Docker Desktop is running, has enough disk space, and can reach the network." } } diff --git a/packages/MCPServer/Sources/MCPServer/FileExtractorDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/FileExtractorDockerExecutor.swift index 21abfdce..0d85bbf6 100644 --- a/packages/MCPServer/Sources/MCPServer/FileExtractorDockerExecutor.swift +++ b/packages/MCPServer/Sources/MCPServer/FileExtractorDockerExecutor.swift @@ -5,8 +5,9 @@ import Structure /// Own queue (max 1). Job folders are bind-mounted; the image must already /// exist (`docker image inspect` happens outside the permit). public struct FileExtractorDockerExecutor: Sendable { - public static let image = "derrick-file-extractor:swift-6.4-v1" + public static let image = DockerWorkerRuntime.image public static let containerPrefix = "derrick-file-extractor" + public static let binaryPath = DockerWorkerRuntime.extractorBinary public static let maximumTimeoutSeconds = 180 private let executor: DockerCLIExecutor @@ -27,10 +28,7 @@ public struct FileExtractorDockerExecutor: Sendable { timeoutSeconds: Int ) async throws -> DockerCLIResult { let timeout = min(max(timeoutSeconds, 1), Self.maximumTimeoutSeconds) - let imageCheck = try await executor(["image", "inspect", Self.image], Data(), 30) - guard imageCheck.exitCode == 0 else { - throw FileExtractorDockerExecutorError.imageUnavailable(Self.image) - } + try await WorkerImageGate.shared.ensureReady(executor: executor) let executor = self.executor do { return try await queue.withPermit { @@ -48,7 +46,7 @@ public struct FileExtractorDockerExecutor: Sendable { startStep: "start file extractor container", body: { name in try await executor( - ["exec", "-i", name, "/usr/local/bin/derrick-file-extractor"], + ["exec", "-i", name, Self.binaryPath], input, timeout ) diff --git a/packages/MCPServer/Sources/MCPServer/FileExtractorToolModule.swift b/packages/MCPServer/Sources/MCPServer/FileExtractorToolModule.swift index 21b09bae..35edd1f6 100644 --- a/packages/MCPServer/Sources/MCPServer/FileExtractorToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/FileExtractorToolModule.swift @@ -64,8 +64,17 @@ public enum FileExtractorToolModule: MCPToolModule { let payload = try JSONEncoder().encode(workerRequest) let dockerResult = try await run(payload, workspace, parsed.timeoutSeconds) let workerJSON = String(decoding: dockerResult.stdout, as: UTF8.self) - let worker = (try? JSONDecoder().decode(FileExtractorWireResult.self, from: dockerResult.stdout)) - ?? FileExtractorWireResult(ok: false, files: [], diagnostics: [workerJSON]) + let worker: FileExtractorWireResult + if (try? GuestContractValidation.validateFileExtractorResultJSON(dockerResult.stdout)) != nil { + worker = (try? JSONDecoder().decode(FileExtractorWireResult.self, from: dockerResult.stdout)) + ?? FileExtractorWireResult(ok: false, files: [], diagnostics: [workerJSON]) + } else { + worker = FileExtractorWireResult( + ok: false, + files: [], + diagnostics: ["File extractor returned invalid JSON output."] + ) + } let exported = (try? workspace.publishOutputs()) ?? [] if dockerResult.exitCode != 0 && !worker.ok { return try failure( diff --git a/packages/MCPServer/Sources/MCPServer/Orchestration/JobOrchestrationToolModule.swift b/packages/MCPServer/Sources/MCPServer/Orchestration/JobOrchestrationToolModule.swift index 629d0bc6..abdae01c 100644 --- a/packages/MCPServer/Sources/MCPServer/Orchestration/JobOrchestrationToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/Orchestration/JobOrchestrationToolModule.swift @@ -34,7 +34,7 @@ public enum JobOrchestrationToolModule { ]), "tool_arguments": .object([ "type": .string("object"), - "description": .string("Frozen effector args. For web.crawl use {start_url,goal,max_pages,max_depth,timeout_seconds}. For script_exec use {description,reason,script} where script is standalone Python reading JSON from stdin and writing Derrick envelope JSON to stdout.") + "description": .string("Frozen effector args. For web.crawl use {start_url,goal,max_pages,max_depth,timeout_seconds}. For script_exec use {description,reason,script} where script is standalone Go reading JSON from stdin and writing Derrick envelope JSON to stdout.") ]), "wake_after": .object([ "type": .string("boolean"), diff --git a/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift b/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift index baf82d7a..3bcb7363 100644 --- a/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/PluginFactoryToolModule.swift @@ -18,7 +18,7 @@ public enum PluginFactoryToolModule: MCPToolModule { ]), "host_manifest_json": .object([ "type": .string("string"), - "description": .string("Host-owned Agent Plugin plugin.json. When set, the builder only supplies Python and tests.") + "description": .string("Host-owned Agent Plugin plugin.json. When set, the builder only supplies Go source and tests.") ]), ]), "required": .array([.string("goal")]) diff --git a/packages/MCPServer/Sources/MCPServer/Script/GoGuestDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/Script/GoGuestDockerExecutor.swift new file mode 100644 index 00000000..2c230202 --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/Script/GoGuestDockerExecutor.swift @@ -0,0 +1,266 @@ +import Foundation +import Plugin +import Structure + +/// Offline Go guest executor for `script_exec` and `plugin.invoke`. +/// +/// Compiles `package main` source and runs the Linux binary inside the pinned +/// worker image. Untrusted source never touches the host toolchain. +public struct GoGuestDockerExecutor: Sendable { + public static let containerPrefix = "derrick-guest-runtime" + private static let compileTimeoutSeconds = 120 + private static let writeTimeoutSeconds = 60 + + public let image: String + private let executor: DockerCLIExecutor + private let queue: DerrickDockerRunQueue + + public init( + image: String = DockerWorkerRuntime.image, + executor: @escaping DockerCLIExecutor, + queue: DerrickDockerRunQueue = .guest + ) { + self.image = image.trimmingCharacters(in: .whitespacesAndNewlines) + self.executor = executor + self.queue = queue + } + + /// Compile guest source in a one-shot container and return the Linux binary bytes. + public func compileSource(_ source: String) async throws -> Data { + try await WorkerImageGate.shared.ensureReady(executor: executor) + return try await withGuestContainer { name in + try await prepareCompiledGuest(source: source, in: name) + return try await readBinary(from: name) + } + } + + /// Compile once, run once (factory single-hop path). + public func runSource( + source: String, + input: Data, + timeoutSeconds: Int = 300 + ) async throws -> PluginFactoryExecutionResult { + try await withCompiledGuest(source: source) { name in + try await runCompiledGuest( + container: name, + input: input, + timeoutSeconds: timeoutSeconds + ) + } + } + + /// Compile once, then run the body with a live container name (multi-hop loops). + public func withCompiledGuest( + source: String, + _ body: @escaping @Sendable (String) async throws -> T + ) async throws -> T { + try await WorkerImageGate.shared.ensureReady(executor: executor) + return try await withGuestContainer { name in + try await prepareCompiledGuest(source: source, in: name) + return try await body(name) + } + } + + /// Run a previously compiled guest binary in a one-shot container. + public func runArtifact( + artifact: Data, + input: Data, + timeoutSeconds: Int = 300 + ) async throws -> PluginFactoryExecutionResult { + try await WorkerImageGate.shared.ensureReady(executor: executor) + return try await withGuestContainer { name in + try await write(binary: artifact, to: name) + return try await runCompiledGuest( + container: name, + input: input, + timeoutSeconds: timeoutSeconds + ) + } + } + + /// Execute `/tmp/guest` in an existing guest container. + public func runCompiledGuest( + container name: String, + input: Data, + timeoutSeconds: Int = 300 + ) async throws -> PluginFactoryExecutionResult { + result( + from: try await executor( + [ + "exec", "-i", name, + DockerWorkerRuntime.guestBinaryPath, + ], + input, + min(max(timeoutSeconds, 1), GuestRuntimeLimits.maxTimeoutSeconds) + ) + ) + } + + private func prepareCompiledGuest(source: String, in container: String) async throws { + try await writeSource(source, to: container) + try await compileGuest(in: container) + } + + private func withGuestContainer( + _ body: @escaping @Sendable (String) async throws -> T + ) async throws -> T { + try await DockerImageInspector.verifyPinned( + tag: image, + expected: DockerWorkerRuntime.pinnedDigest, + executor: executor + ) + let image = self.image + let executor = self.executor + do { + return try await queue.withPermit { + try await OneshotDockerContainer.run( + executor: executor, + prefix: Self.containerPrefix, + createArguments: { name in + [ + "create", + ] + DerrickDockerRuntimeIdentity.createLabelArguments + [ + "--network", "none", + "--name", name, + "--env", "HOME=/tmp", + "--env", "GOCACHE=/tmp/gocache", + "--env", "GOTMPDIR=/tmp", + "--read-only", + "--tmpfs", "/tmp:rw,exec,nosuid,size=256m", + "--pids-limit", "128", + "--cpus", "2.0", + "--memory", "1g", + "--security-opt", "no-new-privileges", + "--cap-drop", "ALL", + image, + "/bin/sleep", + "infinity", + ] + }, + createStep: "create go guest runtime container", + startStep: "start go guest runtime container", + body: body + ) + } + } catch let error as OneshotDockerContainerError { + throw mappedGuestError(error) + } + } + + private func writeSource(_ source: String, to container: String) async throws { + try check( + try await executor( + ["exec", "-i", container, "sh", "-c", DockerWorkerRuntime.guestWriteSourceShell], + Data(source.utf8), + Self.writeTimeoutSeconds + ), + step: "write Go guest source" + ) + } + + private func write(binary: Data, to container: String) async throws { + try check( + try await executor( + [ + "exec", "-i", container, "sh", "-c", + "cat > \(DockerWorkerRuntime.guestBinaryPath) && chmod +x \(DockerWorkerRuntime.guestBinaryPath)", + ], + binary, + Self.writeTimeoutSeconds + ), + step: "write Go guest binary" + ) + } + + private func compileGuest(in container: String) async throws { + try check( + try await executor( + ["exec", container, "sh", "-c", DockerWorkerRuntime.guestCompileShell], + Data(), + Self.compileTimeoutSeconds + ), + step: "compile Go guest" + ) + } + + private func readBinary(from container: String) async throws -> Data { + let response = try await executor( + ["exec", container, "sh", "-c", DockerWorkerRuntime.guestReadBinaryShell], + Data(), + Self.writeTimeoutSeconds + ) + try check(response, step: "read compiled Go guest") + guard !response.stdout.isEmpty else { + throw GoGuestDockerExecutorError.commandFailed( + "read compiled Go guest", + "compiled binary was empty" + ) + } + return response.stdout + } + + private func result(from response: DockerCLIResult) -> PluginFactoryExecutionResult { + PluginFactoryExecutionResult( + exitCode: response.exitCode, + stdout: response.stdout, + stderr: response.stderr + ) + } + + private func check(_ response: DockerCLIResult, step: String) throws { + guard response.exitCode == 0 else { + throw GoGuestDockerExecutorError.commandFailed(step, detail(from: response)) + } + } + + private func mappedGuestError(_ error: OneshotDockerContainerError) -> Error { + switch error { + case .commandFailed(let step, let detail): + return GoGuestDockerExecutorError.commandFailed(step, detail) + case .imageUnavailable: + return error + } + } + + private func detail(from result: DockerCLIResult) -> String { + let stderr = String(decoding: result.stderr, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return stderr.isEmpty ? "exit \(result.exitCode)" : stderr + } +} + +public enum GoGuestDockerExecutorError: Error, LocalizedError, Equatable, Sendable { + case commandFailed(String, String) + + public var errorDescription: String? { + switch self { + case .commandFailed(let step, let detail): + return "\(step) failed: \(detail)" + } + } +} + +/// Shared gate for worker image readiness (crawl, extract, plugin guest). +public actor WorkerImageGate { + public static let shared = WorkerImageGate() + + private var inFlight: Task? + + public func ensureReady(executor: @escaping DockerCLIExecutor) async throws { + if let inFlight { + try await inFlight.value + return + } + let task = Task { + try await DockerProductImagePrewarmer.ensureWorkerImage(executor: executor) + } + inFlight = task + do { + try await task.value + inFlight = nil + } catch { + inFlight = nil + throw error + } + } +} diff --git a/packages/MCPServer/Sources/MCPServer/Script/GoPluginFactoryDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/Script/GoPluginFactoryDockerExecutor.swift new file mode 100644 index 00000000..cc8c9ab6 --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/Script/GoPluginFactoryDockerExecutor.swift @@ -0,0 +1,61 @@ +import Foundation +import Plugin +import Structure + +/// Production adapter for the Go plugin factory. +public struct GoPluginFactoryDockerExecutor: PluginFactoryExecutor, PluginFactoryCompiledGuestExecutor, Sendable { + private let runtime: GoGuestDockerExecutor + + public var image: String { runtime.image } + + public init( + image: String = DockerWorkerRuntime.image, + executor: @escaping DockerCLIExecutor + ) { + runtime = GoGuestDockerExecutor(image: image, executor: executor) + } + + public func runGuestSource( + source: String, + input: Data + ) async throws -> PluginFactoryExecutionResult { + try await runtime.runSource(source: source, input: input) + } + + public func runGuestSourceHops( + source: String, + testInput: Data + ) async throws -> PluginFactoryHopTestRun { + let script = try PluginFactoryTestScript.parse(testInput) + return try await runtime.withCompiledGuest(source: source) { container in + var hopResults: [PluginFactoryExecutionResult] = [] + var lastResult = PluginFactoryExecutionResult(exitCode: 1) + + for hop in script.hops { + let input = try hop.encodeValidated() + let result = try await runtime.runCompiledGuest( + container: container, + input: input + ) + hopResults.append(result) + lastResult = result + guard result.exitCode == 0 else { + return PluginFactoryHopTestRun(final: result, hopResults: hopResults) + } + } + + return PluginFactoryHopTestRun(final: lastResult, hopResults: hopResults) + } + } + + public func packageGuestSource(source: String) async throws -> Data { + try await runtime.compileSource(source) + } + + public func runPackagedArtifact( + _ artifact: Data, + input: Data + ) async throws -> PluginFactoryExecutionResult { + try await runtime.runArtifact(artifact: artifact, input: input) + } +} diff --git a/packages/MCPServer/Sources/MCPServer/Script/GoScriptVerifier.swift b/packages/MCPServer/Sources/MCPServer/Script/GoScriptVerifier.swift new file mode 100644 index 00000000..7a3acb17 --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/Script/GoScriptVerifier.swift @@ -0,0 +1,17 @@ +import Foundation +import Plugin +import Structure + +/// Conservative source checks for standalone Go guest scripts. +public enum GoScriptVerifier: Sendable { + public static func validate( + source: String, + dependencies: [String: String] = [:] + ) -> [String] { + var findings = GuestGoSourceValidator.validate(source: source) + if !dependencies.isEmpty { + findings.append("Guest script dependencies are not supported.") + } + return findings + } +} diff --git a/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift b/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift index 070b6625..943248ad 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift @@ -2,7 +2,7 @@ import Foundation import Plugin import Structure -/// Shared host hop loop for offline Python guest programs. +/// Shared host hop loop for offline Go guest programs. public enum GuestHopLoop: Sendable { public typealias HTTPResultEventBuilder = @Sendable ( [PluginEnvelope], diff --git a/packages/MCPServer/Sources/MCPServer/Script/GuestPluginRunner.swift b/packages/MCPServer/Sources/MCPServer/Script/GuestPluginRunner.swift index 54dd21d9..207bc7f7 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/GuestPluginRunner.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/GuestPluginRunner.swift @@ -2,7 +2,7 @@ import Foundation import Plugin import Structure -/// Runs an approved factory release through the offline Python guest runtime. +/// Runs an approved factory release through the offline Go guest runtime. public enum GuestPluginRunner: Sendable { public static func run( release: PluginFactoryRelease, @@ -15,14 +15,20 @@ public enum GuestPluginRunner: Sendable { let invokeID = UUID().uuidString let initialEvent = (try? PluginHopEvent.decodeValidated(input)) ?? PluginHopEvent(kind: .manual) - let executor = PythonGuestDockerExecutor(executor: dockerExecutor) + let executor = GoGuestDockerExecutor(executor: dockerExecutor) + guard !release.compiledArtifact.isEmpty else { + throw GoGuestDockerExecutorError.commandFailed( + "load plugin artifact", + "compiled artifact is empty" + ) + } return try await GuestHopLoop.runForPluginInvoke( initialEvent: initialEvent, invokeID: invokeID, timeoutSeconds: timeoutSeconds, execute: { hopInput in - try await executor.runSource( - source: release.guestSource, + try await executor.runArtifact( + artifact: release.compiledArtifact, input: hopInput, timeoutSeconds: timeoutSeconds ) diff --git a/packages/MCPServer/Sources/MCPServer/Script/PythonGuestDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/Script/PythonGuestDockerExecutor.swift deleted file mode 100644 index ad464879..00000000 --- a/packages/MCPServer/Sources/MCPServer/Script/PythonGuestDockerExecutor.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation -import Plugin -import Structure - -/// Offline Python guest executor for script_exec and plugin.invoke. -/// -/// Recreate-on-handoff: one fresh `--network none` container per run, deleted -/// when the hop loop finishes (host done). The image is reused if already pulled. -public struct PythonGuestDockerExecutor: Sendable { - public static let containerPrefix = "derrick-guest-runtime" - - public let image: String - private let executor: DockerCLIExecutor - private let queue: DerrickDockerRunQueue - - public init( - image: String = DerrickGuestRuntime.pythonGuestDockerImage, - executor: @escaping DockerCLIExecutor, - queue: DerrickDockerRunQueue = .guest - ) { - self.image = image.trimmingCharacters(in: .whitespacesAndNewlines) - self.executor = executor - self.queue = queue - } - - public func runSource( - source: String, - input: Data, - timeoutSeconds: Int = 300 - ) async throws -> PluginFactoryExecutionResult { - try await withGuestContainer { name in - try await write(source: Data(source.utf8), to: name) - return result( - from: try await executor( - ["exec", "-i", name, "python3", "/tmp/guest.py"], - input, - min(max(timeoutSeconds, 1), GuestRuntimeLimits.maxTimeoutSeconds) - ) - ) - } - } - - private func withGuestContainer( - _ body: @escaping @Sendable (String) async throws -> T - ) async throws -> T { - try await OneshotDockerContainer.ensurePulledImage(image, executor: executor) - let image = self.image - let executor = self.executor - do { - return try await queue.withPermit { - try await OneshotDockerContainer.run( - executor: executor, - prefix: Self.containerPrefix, - createArguments: { name in - [ - "create", - ] + DerrickDockerRuntimeIdentity.createLabelArguments + [ - "--network", "none", - "--name", name, - "--env", "HOME=/tmp", - "--read-only", - "--tmpfs", "/tmp:rw,exec,nosuid,size=128m", - "--pids-limit", "128", - "--cpus", "2.0", - "--memory", "1g", - "--security-opt", "no-new-privileges", - "--cap-drop", "ALL", - image, - "/bin/sleep", - "infinity", - ] - }, - createStep: "create guest runtime container", - startStep: "start guest runtime container", - body: body - ) - } - } catch let error as OneshotDockerContainerError { - throw mappedGuestError(error) - } - } - - private func write(source: Data, to container: String) async throws { - try check( - try await executor( - ["exec", "-i", container, "sh", "-c", "cat > /tmp/guest.py"], - source, - 60 - ), - step: "write Python source" - ) - } - - private func result(from response: DockerCLIResult) -> PluginFactoryExecutionResult { - PluginFactoryExecutionResult( - exitCode: response.exitCode, - stdout: response.stdout, - stderr: response.stderr - ) - } - - private func check(_ response: DockerCLIResult, step: String) throws { - guard response.exitCode == 0 else { - throw PythonGuestDockerExecutorError.commandFailed(step, detail(from: response)) - } - } - - private func mappedGuestError(_ error: OneshotDockerContainerError) -> Error { - switch error { - case .commandFailed(let step, let detail): - return PythonGuestDockerExecutorError.commandFailed(step, detail) - case .imageUnavailable: - return error - } - } - - private func detail(from result: DockerCLIResult) -> String { - let stderr = String(decoding: result.stderr, as: UTF8.self) - .trimmingCharacters(in: .whitespacesAndNewlines) - return stderr.isEmpty ? "exit \(result.exitCode)" : stderr - } -} - -public enum PythonGuestDockerExecutorError: Error, LocalizedError, Equatable, Sendable { - case commandFailed(String, String) - - public var errorDescription: String? { - switch self { - case .commandFailed(let step, let detail): - return "\(step) failed: \(detail)" - } - } -} diff --git a/packages/MCPServer/Sources/MCPServer/Script/PythonPluginFactoryDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/Script/PythonPluginFactoryDockerExecutor.swift deleted file mode 100644 index d52be34d..00000000 --- a/packages/MCPServer/Sources/MCPServer/Script/PythonPluginFactoryDockerExecutor.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import Plugin -import Structure - -/// Production adapter for the Python factory. -public struct PythonPluginFactoryDockerExecutor: PluginFactoryExecutor, Sendable { - private let runtime: PythonGuestDockerExecutor - - public var image: String { runtime.image } - - public init( - image: String = DerrickGuestRuntime.pythonGuestDockerImage, - executor: @escaping DockerCLIExecutor - ) { - runtime = PythonGuestDockerExecutor(image: image, executor: executor) - } - - public func runGuestSource( - source: String, - input: Data - ) async throws -> PluginFactoryExecutionResult { - try await runtime.runSource(source: source, input: input) - } - - public func packageGuestSource(source: String) async throws -> Data { - Data(source.utf8) - } - - public func runPackagedArtifact( - _ artifact: Data, - input: Data - ) async throws -> PluginFactoryExecutionResult { - guard let source = String(data: artifact, encoding: .utf8) else { - throw PythonGuestDockerExecutorError.commandFailed( - "decode Python artifact", - "artifact is not valid UTF-8" - ) - } - return try await runtime.runSource(source: source, input: input) - } -} diff --git a/packages/MCPServer/Sources/MCPServer/Script/PythonScriptVerifier.swift b/packages/MCPServer/Sources/MCPServer/Script/PythonScriptVerifier.swift deleted file mode 100644 index 8825d2bf..00000000 --- a/packages/MCPServer/Sources/MCPServer/Script/PythonScriptVerifier.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import Plugin -import Structure - -/// Conservative source checks for standalone Python guest scripts. -public enum PythonScriptVerifier: Sendable { - public static func validate( - source: String, - dependencies: [String: String] = [:] - ) -> [String] { - GuestPythonSourceValidator.validate(source: source, dependencies: dependencies) - } -} diff --git a/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionRuntime.swift b/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionRuntime.swift index 5145a427..c1a380d7 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionRuntime.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionRuntime.swift @@ -3,7 +3,7 @@ import MCP import Plugin import Structure -/// Runs standalone Python guest source and dispatches host-owned capability hops. +/// Runs standalone Go guest source and dispatches host-owned capability hops. public enum ScriptExecutionRuntime { public static func run( arguments: [String: Value], @@ -16,12 +16,12 @@ public enum ScriptExecutionRuntime { ) async throws -> String { let started = Date() let parsed = try parse(arguments) - let language = GuestScriptLanguage.python + let language = GuestScriptLanguage.go logger("[script_exec] \(language.rawValue) source chars=\(parsed.script.count)") if GuestScriptLanguage.requestedLanguageIsUnsupported(arguments) { return finish(blocked( - findings: ["script_exec only runs Python. Swift guest scripts are not supported."], + findings: ["script_exec only runs Go. Python and other guest languages are not supported."], stage: .staticValidation, started: started, parsed: parsed, @@ -30,7 +30,7 @@ public enum ScriptExecutionRuntime { } let staticStarted = Date() - let staticFindings = PythonScriptVerifier.validate( + let staticFindings = GoScriptVerifier.validate( source: parsed.script, dependencies: parsed.dependencies ) @@ -113,30 +113,34 @@ public enum ScriptExecutionRuntime { logger("[script_exec] skipping LLM reviewer") } + let compileStarted = Date() let timeout = GuestRuntimeLimits.effectiveScriptTimeoutSeconds( requested: parsed.timeoutSeconds ) let invokeID = UUID().uuidString do { - let executor = PythonGuestDockerExecutor(executor: stdinExecutor) - let result = try await GuestHopLoop.run( - initialEvent: initialEvent, - invokeID: invokeID, - timeoutSeconds: timeout, - verifier: language.verifierID, - execute: { input in - try await executor.runSource( - source: parsed.script, - input: input, - timeoutSeconds: timeout - ) - }, - logger: logger, - hopHandler: hopHandler - ) + let executor = GoGuestDockerExecutor(executor: stdinExecutor) + let result = try await executor.withCompiledGuest(source: parsed.script) { container in + try await GuestHopLoop.run( + initialEvent: initialEvent, + invokeID: invokeID, + timeoutSeconds: timeout, + verifier: language.verifierID, + execute: { input in + try await executor.runCompiledGuest( + container: container, + input: input, + timeoutSeconds: timeout + ) + }, + logger: logger, + hopHandler: hopHandler + ) + } + let compileMS = ScriptPhaseTiming.elapsedMS(from: compileStarted) let metrics = ScriptPhaseTiming.scriptMetrics(parsed.script) var phaseTiming = result.phaseTiming ?? ScriptPhaseTiming() - phaseTiming.staticValidateMS = staticValidateMS + phaseTiming.staticValidateMS = staticValidateMS + compileMS phaseTiming.totalMS = ScriptPhaseTiming.elapsedMS(from: started) phaseTiming.scriptCharCount = metrics.chars phaseTiming.scriptLineCount = metrics.lines @@ -156,11 +160,17 @@ public enum ScriptExecutionRuntime { phaseTiming: phaseTiming ) return finish(decorated, logger: logger) - } catch let error as PythonGuestDockerExecutorError { + } catch let error as GoGuestDockerExecutorError { logger("[script_exec] guest runtime failed: \(error.localizedDescription)") + let stage: ScriptFailureStage + if case .commandFailed(let step, _) = error, step.contains("compile") { + stage = .typecheck + } else { + stage = .execution + } return finish(runtimeFailure( findings: [error.localizedDescription], - stage: .execution, + stage: stage, started: started, parsed: parsed, assessment: reviewerAssessment, diff --git a/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionSupport.swift b/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionSupport.swift index 87272b17..322efb92 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionSupport.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionSupport.swift @@ -144,8 +144,8 @@ public enum ScriptExecutionVerifier { private static func readonlyViolations(in script: String) -> [String] { let patterns: [(String, String)] = [ - (#"(?m)\bopen\s*\("#, "Readonly mode cannot mutate filesystem."), - (#"(?m)\b(subprocess|os\.system|os\.popen|socket|urllib|requests|httpx)\b"#, "Readonly mode cannot execute nested commands or access the network.") + (#"os\.(Open|ReadFile|WriteFile|Remove|Create|Mkdir)"#, "Readonly mode cannot mutate filesystem."), + (#"\"net/http\"|\"net\"|exec\.Command|crypto/tls"#, "Readonly mode cannot execute nested commands or access the network.") ] return patterns.compactMap { pattern, message in script.range(of: pattern, options: .regularExpression) != nil ? message : nil @@ -159,7 +159,7 @@ extension ScriptExecutionResult { durationMS: Int, maxSeconds: Int = GuestRuntimeLimits.containerRunMaxTTLSeconds, phaseTiming: ScriptPhaseTiming? = nil, - verifier: String = "python-check-v1" + verifier: String = "go-check-v1" ) -> ScriptExecutionResult { let explanation = GuestRuntimeLimits.containerLeaseExceededExplanation(maxSeconds: maxSeconds) return ScriptExecutionResult( diff --git a/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionToolModule.swift b/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionToolModule.swift index 12e2e3db..dd3c4741 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/ScriptExecutionToolModule.swift @@ -20,11 +20,11 @@ public enum ScriptExecutionToolModule: MCPToolModule { ]), "script": .object([ "type": .string("string"), - "description": .string("Standalone Python source. Reads one JSON event from standard input and writes a JSON array of Derrick envelopes to standard output. Use http.request envelopes for host HTTP and result.emit/message.post for terminal output.") + "description": .string("Standalone Go `package main` source. Reads one hop-event JSON object from standard input and writes an envelope-list JSON array to standard output. Use http.request envelopes for host HTTP and result.emit/message.post for terminal output.") ]), "language": .object([ "type": .string("string"), - "description": .string("Must be python when set. Swift guest scripts are not supported.") + "description": .string("Must be go when set. Python and Swift guest scripts are not supported.") ]), "user_prompt": .object([ "type": .string("string"), diff --git a/packages/MCPServer/Sources/MCPServer/WebCrawlerDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/WebCrawlerDockerExecutor.swift index c53322be..1b8ca9e0 100644 --- a/packages/MCPServer/Sources/MCPServer/WebCrawlerDockerExecutor.swift +++ b/packages/MCPServer/Sources/MCPServer/WebCrawlerDockerExecutor.swift @@ -7,23 +7,20 @@ import Structure /// The image is trusted product code. User input is passed only as JSON on /// stdin; it is never interpolated into a shell command. public struct WebCrawlerDockerExecutor: Sendable { - public static let image = "derrick-web-crawler:swift-6.4-v1" + public static let image = DockerWorkerRuntime.image public static let containerPrefix = "derrick-web-crawler" + public static let binaryPath = DockerWorkerRuntime.crawlerBinary public static let maximumTimeoutSeconds = 900 public static let dockerNetwork = "bridge" private let executor: DockerCLIExecutor private let queue: DerrickDockerRunQueue - private let imageGate: WebCrawlerImageGate - public init( executor: @escaping DockerCLIExecutor, - queue: DerrickDockerRunQueue = .crawler, - imageGate: WebCrawlerImageGate = .shared + queue: DerrickDockerRunQueue = .crawler ) { self.executor = executor self.queue = queue - self.imageGate = imageGate } public func run( @@ -31,7 +28,7 @@ public struct WebCrawlerDockerExecutor: Sendable { timeoutSeconds: Int ) async throws -> DockerCLIResult { let timeout = min(max(timeoutSeconds, 1), Self.maximumTimeoutSeconds) - try await imageGate.ensureReady(executor: executor) + try await WorkerImageGate.shared.ensureReady(executor: executor) let prepared = try await WebCrawlerDockerInputPreparer.enrich(input) let executor = self.executor do { @@ -53,7 +50,7 @@ public struct WebCrawlerDockerExecutor: Sendable { startStep: "start crawler container", body: { name in try await executor( - ["exec", "-i", name, "/usr/local/bin/derrick-web-crawler"], + ["exec", "-i", name, Self.binaryPath], prepared.data, timeout ) diff --git a/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift b/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift index 2c6bc083..29d8d176 100644 --- a/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift @@ -63,6 +63,17 @@ public enum WebCrawlerToolModule: MCPToolModule { ).encodedJSON() } + do { + try GuestContractValidation.validateWebCrawlerResultJSON(dockerResult.stdout) + } catch { + return try failure( + status: .failed, + stage: .execution, + code: "web_crawl_invalid_output", + message: "Crawler returned invalid JSON output." + ).encodedJSON() + } + guard let result = try? JSONDecoder().decode( WebCrawlerWireResult.self, from: dockerResult.stdout @@ -274,6 +285,13 @@ private struct WebCrawlerWireResult: Decodable, Sendable { case stopReason = "stop_reason" case diagnostics } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ok = try container.decode(Bool.self, forKey: .ok) + stopReason = try container.decode(String.self, forKey: .stopReason) + diagnostics = try container.decodeIfPresent([String].self, forKey: .diagnostics) ?? [] + } } private enum WebCrawlerToolError: Error, LocalizedError, Sendable { diff --git a/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift b/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift index f26bde73..dac39f40 100644 --- a/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift +++ b/packages/MCPServer/Sources/SlackConnectorE2EHarness/E2EEnvironment.swift @@ -271,7 +271,7 @@ struct E2EEnvironment { let goal = input.connectorBuildGoal(crawlSummary: SlackConnectorFactoryInput.defaultCrawlSummary) fputs("[E2E] factory build scope=\(scope.rawValue)…\n", stderr) - let executor = PythonPluginFactoryDockerExecutor(executor: dockerExecutor) + let executor = GoPluginFactoryDockerExecutor(executor: dockerExecutor) let release = try await PluginFactorySession( configuration: PluginFactoryConfiguration(maxBuilderAttempts: 5) ).build( diff --git a/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift b/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift index 7c72d075..9fd283b6 100644 --- a/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift +++ b/packages/MCPServer/Sources/SlackConnectorInstallReference/SlackConnectorInstallReferenceMain.swift @@ -83,7 +83,7 @@ enum SlackConnectorInstallReference { userGoal: goal, hostManifest: input.hostManifest, builder: E2EFactoryBuilder(scope: .fullSync), - executor: PythonPluginFactoryDockerExecutor(executor: dockerExecutor), + executor: GoPluginFactoryDockerExecutor(executor: dockerExecutor), reviewer: E2EHarnessReviewer(), logger: { fputs("\($0)\n", stderr) } ) diff --git a/packages/MCPServer/Sources/SlackConnectorLiveHarness/LiveHarnessEnvironment.swift b/packages/MCPServer/Sources/SlackConnectorLiveHarness/LiveHarnessEnvironment.swift index 06aa406e..202ed356 100644 --- a/packages/MCPServer/Sources/SlackConnectorLiveHarness/LiveHarnessEnvironment.swift +++ b/packages/MCPServer/Sources/SlackConnectorLiveHarness/LiveHarnessEnvironment.swift @@ -79,7 +79,7 @@ struct LiveHarnessEnvironment { let goal = input.connectorBuildGoal(crawlSummary: SlackConnectorFactoryInput.defaultCrawlSummary) fputs("[live] building full-sync connector via LLM factory…\n", stderr) - let executor = PythonPluginFactoryDockerExecutor(executor: dockerExecutor) + let executor = GoPluginFactoryDockerExecutor(executor: dockerExecutor) let release = try await PluginFactorySession( configuration: PluginFactoryConfiguration(maxBuilderAttempts: 3) ).build( diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index 1896241f..1ce0f0aa 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -8,20 +8,68 @@ import WebCrawler @testable import MCPServer @Suite struct MCPServerTests { - private static let dummyPythonScript = """ - import json, sys - _ = sys.stdin.read() - json.dump([{"verb":"result.emit","summary":"ok"}], sys.stdout) + private static let dummyGoScript = """ + package main + + import ( + "encoding/json" + "os" + ) + + func main() { + var event map[string]any + _ = json.NewDecoder(os.Stdin).Decode(&event) + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode([]map[string]any{{"verb": "result.emit", "summary": "ok"}}) + } """ - private static let dummyStdin: @Sendable ([String], Data, Int) async throws -> DockerCLIResult = { arguments, _, _ in - if arguments.contains("python3"), arguments.contains("/tmp/guest.py") { + private static func isGuestBinaryExec(_ arguments: [String]) -> Bool { + arguments.contains(DockerWorkerRuntime.guestBinaryPath) + && !arguments.contains("cat >") + } + + private static func mockWorkerImageInspect(_ arguments: [String]) -> DockerCLIResult? { + guard arguments.first == "image", arguments.contains("inspect") else { + return nil + } + if arguments.contains("{{.Id}}") { + let digest = DockerWorkerRuntime.pinnedDigest.rawValue + "\n" + return DockerCLIResult(exitCode: 0, stdout: Data(digest.utf8), stderr: Data()) + } + return DockerCLIResult(exitCode: 0, stdout: Data("[]".utf8), stderr: Data()) + } + + private static let dummyCompiledGuest = Data([0x7f, 0x45, 0x4c, 0x46, 0x02]) + + private static func mockGuestDocker(_ arguments: [String]) -> DockerCLIResult? { + if arguments.contains("cat > /tmp/guest") { + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if arguments.contains(DockerWorkerRuntime.guestWriteSourceShell) { + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if arguments.contains(DockerWorkerRuntime.guestCompileShell) { + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if arguments.contains(DockerWorkerRuntime.guestReadBinaryShell) { + return DockerCLIResult(exitCode: 0, stdout: dummyCompiledGuest, stderr: Data()) + } + if isGuestBinaryExec(arguments) { return DockerCLIResult( exitCode: 0, stdout: Data(#"[{"verb":"result.emit","summary":"ok"}]"#.utf8), stderr: Data() ) } + return mockWorkerImageInspect(arguments) + } + + private static let dummyStdin: @Sendable ([String], Data, Int) async throws -> DockerCLIResult = { arguments, _, _ in + if let mocked = mockGuestDocker(arguments) { + return mocked + } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) } @@ -825,9 +873,9 @@ import WebCrawler #expect(results?.first?["json"] == nil) } - @Test func pythonGuestRuntimeUsesPinnedImage() { - #expect(DerrickGuestRuntime.pythonGuestDockerImage == "python:3.14.7") - #expect(PythonGuestDockerExecutor.containerPrefix == "derrick-guest-runtime") + @Test func goGuestRuntimeUsesWorkerImage() { + #expect(DerrickGuestRuntime.guestDockerImage == DockerWorkerRuntime.image) + #expect(GoGuestDockerExecutor.containerPrefix == "derrick-guest-runtime") #expect(DerrickDockerRunQueue.guest.maxConcurrentContainers == 1) #expect(DerrickDockerRunQueue.crawler.maxConcurrentContainers == 2) #expect(DerrickDockerRunQueue.extractor.maxConcurrentContainers == 1) @@ -931,40 +979,35 @@ import WebCrawler } } - @Test func pythonSourceVerifierRejectsNetworkAndDependencies() { - let findings = PythonScriptVerifier.validate( - source: "import sys\nimport requests", + @Test func goSourceVerifierRejectsNetworkAndDependencies() { + let findings = GoScriptVerifier.validate( + source: "package main\nimport \"net/http\"", dependencies: ["example": "1.0.0"] ) - #expect(findings.contains("Direct network access is not allowed; emit http.request envelopes.")) - #expect(findings.contains("Guest plugin dependencies are not supported; use the standard library.")) + #expect(findings.contains("Go guest must not import \"net/http\".")) + #expect(findings.contains("Guest script dependencies are not supported.")) } - @Test func pythonSourceVerifierRequiresStdin() { - let findings = PythonScriptVerifier.validate(source: "print('[]')") - #expect(findings.contains("Python source must read its JSON event from standard input.")) + @Test func goSourceVerifierRequiresPackageMain() { + let findings = GoScriptVerifier.validate(source: "package plugin") + #expect(findings.contains("Go guest source must declare package main.")) } - @Test func pythonExecutorUsesReadOnlyOfflineContainer() async throws { + @Test func goExecutorUsesReadOnlyOfflineContainer() async throws { let recorder = DockerCallRecorder() - let runner = PythonGuestDockerExecutor( - image: "python:3.14.7", + let runner = GoGuestDockerExecutor( + image: DockerWorkerRuntime.image, executor: { arguments, _, _ in await recorder.append(arguments) - if arguments.contains("/tmp/guest.py"), - !arguments.contains("cat") { - return DockerCLIResult( - exitCode: 0, - stdout: Data(#"[{"verb":"result.emit","summary":"ok"}]"#.utf8), - stderr: Data() - ) + if let mocked = Self.mockGuestDocker(arguments) { + return mocked } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) }, queue: DerrickDockerRunQueue(maxConcurrentContainers: 1) ) _ = try await runner.runSource( - source: "import json, sys\njson.dump([], sys.stdout)", + source: Self.dummyGoScript, input: Data(#"{"kind":"script"}"#.utf8) ) @@ -975,15 +1018,17 @@ import WebCrawler #expect(create.contains("--read-only")) #expect(create.contains("--label")) #expect(create.contains(DerrickDockerRuntimeIdentity.labelAssignment)) - let exec = calls.first(where: { $0.contains("python3") }) ?? [] - #expect(exec.contains("/tmp/guest.py")) + #expect(calls.contains { $0.contains(DockerWorkerRuntime.guestWriteSourceShell) }) + #expect(calls.contains { $0.contains(DockerWorkerRuntime.guestCompileShell) }) + let exec = calls.first(where: { $0.contains(DockerWorkerRuntime.guestBinaryPath) }) ?? [] + #expect(exec.contains(DockerWorkerRuntime.guestBinaryPath)) #expect(calls.contains { $0.first == "rm" && $0.contains("-f") }) } - @Test func pythonGuestDockerCommandsPassXPCValidation() async throws { + @Test func goGuestDockerCommandsPassXPCValidation() async throws { let recorder = DockerCallRecorder() - let runner = PythonGuestDockerExecutor( - image: "python:3.14.7", + let runner = GoGuestDockerExecutor( + image: DockerWorkerRuntime.image, executor: { arguments, _, _ in await recorder.append(arguments) if let error = DockerRunRequestValidator.validate( @@ -995,26 +1040,22 @@ import WebCrawler stderr: Data(error.launchErrorMessage.utf8) ) } - if arguments.contains("/tmp/guest.py"), - !arguments.contains("cat") { - return DockerCLIResult( - exitCode: 0, - stdout: Data(#"[{"verb":"result.emit","summary":"ok"}]"#.utf8), - stderr: Data() - ) + if let mocked = Self.mockGuestDocker(arguments) { + return mocked } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) }, queue: DerrickDockerRunQueue(maxConcurrentContainers: 1) ) let result = try await runner.runSource( - source: "import json, sys\njson.dump([], sys.stdout)", + source: Self.dummyGoScript, input: Data(#"{"kind":"script"}"#.utf8) ) #expect(result.exitCode == 0) let calls = await recorder.calls - #expect(calls.contains { $0.contains("sh") && $0.contains("cat > /tmp/guest.py") }) - #expect(calls.contains { $0.contains("python3") && $0.contains("/tmp/guest.py") }) + #expect(calls.contains { $0.contains(DockerWorkerRuntime.guestWriteSourceShell) }) + #expect(calls.contains { $0.contains(DockerWorkerRuntime.guestCompileShell) }) + #expect(calls.contains { $0.contains(DockerWorkerRuntime.guestBinaryPath) }) } @Test func leftoverSwiftRuntimePrefixIsStillSwept() { @@ -1022,15 +1063,15 @@ import WebCrawler #expect(GuestRuntimeLimits.maxTimeoutSeconds == 300) } - @Test func pythonScriptCanReturnHTMLResult() async throws { + @Test func goScriptCanReturnHTMLResult() async throws { let resultText = try await ScriptExecutionRuntime.run( arguments: [ "description": .string("render a safe card"), "reason": .string("manual HTML output check"), - "script": .string(Self.dummyPythonScript) + "script": .string(Self.dummyGoScript) ], stdinExecutor: { arguments, _, _ in - if arguments.contains("python3"), arguments.contains("/tmp/guest.py") { + if Self.isGuestBinaryExec(arguments) { return DockerCLIResult( exitCode: 0, stdout: Data( @@ -1039,6 +1080,9 @@ import WebCrawler stderr: Data() ) } + if let mocked = Self.mockGuestDocker(arguments) { + return mocked + } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) }, reviewer: StubReviewer( @@ -1058,13 +1102,13 @@ import WebCrawler #expect(result.output?.value == "

Safe

") } - @Test func scriptExecRejectsSwiftLanguage() async throws { + @Test func scriptExecRejectsUnsupportedLanguage() async throws { let resultText = try await ScriptExecutionRuntime.run( arguments: [ - "description": .string("legacy swift"), + "description": .string("legacy python"), "reason": .string("should be blocked"), - "script": .string(Self.dummyPythonScript), - "language": .string("swift") + "script": .string(Self.dummyGoScript), + "language": .string("python") ], stdinExecutor: Self.dummyStdin, reviewer: nil, @@ -1074,10 +1118,10 @@ import WebCrawler let result = try #require(ToolExecutionOutcome.decode(from: resultText)) #expect(result.status == .blocked) #expect(result.stage == .validation) - #expect(resultText.contains("only runs Python")) + #expect(resultText.contains("only runs Go")) } - @Test func pythonScriptToolBlocksFilesystemAccess() async throws { + @Test func goScriptToolBlocksFilesystemAccess() async throws { let bridge = try await MCPLocalBridge.make { server in await server.registerScriptExecutionTool( stdinExecutor: Self.dummyStdin, @@ -1098,7 +1142,7 @@ import WebCrawler arguments: [ "description": .string("attempt write"), "reason": .string("test"), - "script": .string("import sys\n_ = sys.stdin.read()\nopen('/tmp/a','w')") + "script": .string("package main\nimport \"os\"\nfunc main() { _, _ = os.Open(\"/tmp/a\") }") ] ) @@ -1108,18 +1152,18 @@ import WebCrawler @Test func leftoverSwiftGuestImageIsTreatedAsStaleHygieneTag() { #expect(DerrickGuestRuntime.swiftPluginDockerImage.contains("swift")) - #expect(DerrickGuestRuntime.pythonGuestDockerImage == "python:3.14.7") + #expect(DerrickGuestRuntime.guestDockerImage == DockerWorkerRuntime.image) } - @Test func guestPluginRunnerRunsPythonRelease() async throws { + @Test func guestPluginRunnerRunsGoRelease() async throws { let recorder = DockerCallRecorder() let release = PluginFactoryRelease( pluginID: "slack-connection", version: "1.0.0", manifestJSON: "{}", - runtimeJSON: #"{"language":"python","entrypoint":"./app.derrick/plugin.py"}"#, - guestSource: "import json, sys\njson.dump([{\"verb\":\"result.emit\",\"summary\":\"ok\"}], sys.stdout)", - compiledArtifact: Data(), + runtimeJSON: #"{"language":"go","entrypoint":"./app.derrick/plugin.go"}"#, + guestSource: Self.dummyGoScript, + compiledArtifact: Self.dummyCompiledGuest, skillFiles: [:], contentHash: try PluginContentHash(hex: String(repeating: "c", count: 64)), reviewSummary: "ok" @@ -1129,21 +1173,23 @@ import WebCrawler input: Data(#"{"kind":"manual"}"#.utf8), dockerExecutor: { arguments, _, _ in await recorder.append(arguments) - if arguments.contains("/tmp/guest.py"), - !arguments.contains("cat") { + if Self.isGuestBinaryExec(arguments) { return DockerCLIResult( exitCode: 0, stdout: Data(#"[{"verb":"result.emit","summary":"ok"}]"#.utf8), stderr: Data() ) } + if let mocked = Self.mockGuestDocker(arguments) { + return mocked + } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) } ) #expect(result.exitCode == 0) #expect(String(decoding: result.stdout, as: UTF8.self).contains("result.emit")) let calls = await recorder.calls - #expect(calls.contains { $0.contains("python3") }) + #expect(calls.contains { $0.contains(DockerWorkerRuntime.guestBinaryPath) }) #expect(!calls.contains { $0.contains("swift") }) } @@ -1274,12 +1320,12 @@ import WebCrawler #expect(hops == PluginContract.maxPluginInvokeHops) } - @Test func pythonGuestContainerArgumentsStayNetworkIsolated() { + @Test func goGuestContainerArgumentsStayNetworkIsolated() { let name = "derrick-guest-runtime-test" let args = [ "create", "--network", "none", "--name", name, "--read-only", "--tmpfs", "/tmp:rw,exec,nosuid,size=128m", - DerrickGuestRuntime.pythonGuestDockerImage, "/bin/sleep", "infinity", + DerrickGuestRuntime.guestDockerImage, "/bin/sleep", "infinity", ] #expect(args.contains("--name")) #expect(args.contains(name)) @@ -1301,7 +1347,7 @@ import WebCrawler "mode": .string("write"), "description": .string("create report file"), "reason": .string("user asked for file output"), - "script": .string(Self.dummyPythonScript), + "script": .string(Self.dummyGoScript), "expected_effects": .array([.string("write /tmp/report.txt")]), "allow_network": .bool(true) ] @@ -1332,7 +1378,7 @@ import WebCrawler "mode": .string("readonly"), "description": .string("inspect csv"), "reason": .string("analyze user-provided data"), - "script": .string(Self.dummyPythonScript), + "script": .string(Self.dummyGoScript), "user_prompt": .string("summarize this csv"), "allow_network": .bool(true) ] @@ -1448,7 +1494,7 @@ import WebCrawler "mode": .string("readonly"), "description": .string("fetch page"), "reason": .string("test"), - "script": .string(Self.dummyPythonScript), + "script": .string(Self.dummyGoScript), "allow_network": .bool(true) ] ) diff --git a/packages/MCPServer/Tests/MCPServerTests/PythonPluginFactoryDockerExecutorTests.swift b/packages/MCPServer/Tests/MCPServerTests/PythonPluginFactoryDockerExecutorTests.swift deleted file mode 100644 index b95215d0..00000000 --- a/packages/MCPServer/Tests/MCPServerTests/PythonPluginFactoryDockerExecutorTests.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import MCPServer -import Plugin -import Testing -import Structure - -@Suite struct PythonPluginFactoryDockerExecutorTests { - @Test func packagesAndRunsPythonSource() async throws { - let recorder = DockerCallRecorder() - let runner = PythonPluginFactoryDockerExecutor( - image: "python:3.14.7", - executor: { arguments, _, _ in - await recorder.append(arguments) - if arguments.contains("/tmp/guest.py"), - !arguments.contains("cat") { - return DockerCLIResult( - exitCode: 0, - stdout: Data(#"[{"verb":"result.emit","summary":"ok"}]"#.utf8), - stderr: Data() - ) - } - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - ) - let draft = try await runner.runGuestSource( - source: "import json, sys\njson.dump([], sys.stdout)", - input: Data(#"{"kind":"manual"}"#.utf8) - ) - #expect(draft.exitCode == 0) - let packaged = try await runner.packageGuestSource( - source: "import json, sys\njson.dump([], sys.stdout)" - ) - let released = try await runner.runPackagedArtifact( - packaged, - input: Data(#"{"kind":"manual"}"#.utf8) - ) - #expect(released.exitCode == 0) - let calls = await recorder.calls - #expect(calls.contains { $0.contains("python3") && $0.contains("/tmp/guest.py") }) - } -} diff --git a/packages/Plugin/Sources/Plugin/Factory/GuestGoSourceValidator.swift b/packages/Plugin/Sources/Plugin/Factory/GuestGoSourceValidator.swift new file mode 100644 index 00000000..e4bd5293 --- /dev/null +++ b/packages/Plugin/Sources/Plugin/Factory/GuestGoSourceValidator.swift @@ -0,0 +1,35 @@ +import Foundation +import Structure + +public enum GuestGoSourceValidator: Sendable { + private static let forbiddenImports = [ + "\"net/http\"", + "\"net\"", + "\"os/exec\"", + "\"crypto/tls\"", + ] + + private static let forbiddenCalls = [ + "os.Open", + "os.ReadFile", + "os.WriteFile", + "exec.Command", + "http.Get", + "http.Post", + "http.Client", + ] + + public static func validate(source: String) -> [String] { + var findings: [String] = [] + for token in forbiddenImports where source.contains(token) { + findings.append("Go guest must not import \(token).") + } + for token in forbiddenCalls where source.contains(token) { + findings.append("Go guest must not call \(token).") + } + if !source.contains("package main") { + findings.append("Go guest source must declare package main.") + } + return findings + } +} diff --git a/packages/Plugin/Sources/Plugin/Factory/GuestPythonSourceValidator.swift b/packages/Plugin/Sources/Plugin/Factory/GuestPythonSourceValidator.swift deleted file mode 100644 index 2bc56739..00000000 --- a/packages/Plugin/Sources/Plugin/Factory/GuestPythonSourceValidator.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import Structure - -/// Conservative source checks for Python guest plugins and factory drafts. -public enum GuestPythonSourceValidator: Sendable { - public static func validate( - source: String, - dependencies: [String: String] = [:] - ) -> [String] { - var findings: [String] = [] - let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - findings.append("Python source is empty.") - } - - let forbiddenTokens: [(String, String)] = [ - ("import socket", "Direct socket access is not allowed; emit http.request envelopes."), - ("from socket", "Direct socket access is not allowed; emit http.request envelopes."), - ("import urllib", "Direct network access is not allowed; emit http.request envelopes."), - ("from urllib", "Direct network access is not allowed; emit http.request envelopes."), - ("import requests", "Direct network access is not allowed; emit http.request envelopes."), - ("import httpx", "Direct network access is not allowed; emit http.request envelopes."), - ("import subprocess", "Process execution is not allowed."), - ("os.system(", "Process execution is not allowed."), - ("os.popen(", "Process execution is not allowed."), - ("open(", "Filesystem access is not allowed in guest plugins."), - ] - for (token, message) in forbiddenTokens where source.contains(token) { - findings.append(message) - } - - let readsStdin = source.contains("sys.stdin") - || source.contains("input(") - || source.contains("stdin.read") - if !readsStdin { - findings.append("Python source must read its JSON event from standard input.") - } - if !dependencies.isEmpty { - findings.append("Guest plugin dependencies are not supported; use the standard library.") - } - return findings - } -} diff --git a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift index b5883093..fb7cdfd7 100644 --- a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift +++ b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift @@ -105,7 +105,7 @@ public struct PluginFactorySession: Sendable { {"kind":"http_results","http_results":[{"request_id":"...","status":200,"body":"..."}],\ "params":{...}}]} Include http_results fixtures for every messaging_op you implement. Match request_id values \ - in fixtures to the http.request envelopes your python_source emits. De-duplicate http_results \ + in fixtures to the http.request envelopes your go_source emits. De-duplicate http_results \ by request_id using stable sorting — do not overwrite duplicates by response order. """ ) @@ -262,7 +262,7 @@ public struct PluginFactory: Sendable { var files: [String: Data] = [ "plugin.json": Data(draft.manifestJSON.utf8), "app.derrick/runtime.json": Data(runtimeJSON.utf8), - "app.derrick/plugin.py": Data(draft.guestSource.utf8), + "app.derrick/plugin.go": Data(draft.guestSource.utf8), "app.derrick/plugin": artifact, ] for (path, body) in draft.skillFiles { @@ -293,9 +293,9 @@ public struct PluginFactory: Sendable { do { let manifest = try AgentPluginManifest.decode(data) guard let entrypoint = manifest.derrick?.entrypoint, - entrypoint.hasSuffix(".py") else { + entrypoint.hasSuffix(".go") else { throw PluginFactoryError.invalidManifest( - "extensions.app.derrick.entrypoint must point to a Python file." + "extensions.app.derrick.entrypoint must point to a Go file." ) } guard !["create-plugin", "edit-plugin"].contains(manifest.name.rawValue) else { @@ -323,7 +323,7 @@ public struct PluginFactory: Sendable { } private func validateSource(_ source: String) throws { - let findings = GuestPythonSourceValidator.validate(source: source) + let findings = GuestGoSourceValidator.validate(source: source) if let first = findings.first { throw PluginFactoryError.invalidSource(first) } @@ -331,10 +331,10 @@ public struct PluginFactory: Sendable { private func runtimeJSON(for manifest: AgentPluginManifest) throws -> String { guard let entrypoint = manifest.derrick?.entrypoint else { - throw PluginFactoryError.invalidManifest("A Python entrypoint is required.") + throw PluginFactoryError.invalidManifest("A Go entrypoint is required.") } let object: [String: String] = [ - "language": "python", + "language": "go", "entrypoint": entrypoint, ] let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) diff --git a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryRuntime.swift b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryRuntime.swift index b5e45a75..2583c1e7 100644 --- a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryRuntime.swift +++ b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryRuntime.swift @@ -2,6 +2,7 @@ import Foundation import Structure public extension PluginFactoryRelease { - /// All approved releases run as Python guests. - var guestLanguage: PluginGuestLanguage { .python } + var guestLanguage: PluginGuestLanguage { + PluginFactoryRuntime.decode(from: runtimeJSON)?.language ?? .go + } } diff --git a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift index e84fea76..a8e3b7e1 100644 --- a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift +++ b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift @@ -4,35 +4,46 @@ import Testing @testable import Plugin @Suite struct PluginFactoryTests { - private func guestPythonSource(emit: String = #"[]"#) -> String { + private func guestGoSource(summary: String = "ok") -> String { """ - import json, sys - _ = json.load(sys.stdin) - json.dump(\(emit), sys.stdout) + package main + + import ( + "encoding/json" + "os" + ) + + func main() { + var event map[string]any + _ = json.NewDecoder(os.Stdin).Decode(&event) + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode([]map[string]any{{"verb": "result.emit", "summary": "\(summary)"}}) + } """ } - @Test func guestLanguageIsAlwaysPython() { + @Test func guestLanguageIsGoFromRuntimeJSON() { let release = PluginFactoryRelease( pluginID: "slack-connection", version: "1.0.0", manifestJSON: "{}", - runtimeJSON: #"{"language":"python","entrypoint":"./app.derrick/plugin.py"}"#, - guestSource: guestPythonSource(), + runtimeJSON: #"{"language":"go","entrypoint":"./app.derrick/plugin.go"}"#, + guestSource: guestGoSource(), compiledArtifact: Data(), skillFiles: [:], contentHash: try! PluginContentHash(hex: String(repeating: "b", count: 64)), reviewSummary: "ok" ) - #expect(release.guestLanguage == .python) + #expect(release.guestLanguage == .go) } - @Test func pluginFactoryRuntimeDecodesPythonEntrypoint() { + @Test func pluginFactoryRuntimeDecodesGoEntrypoint() { let runtime = PluginFactoryRuntime.decode( - from: #"{"language":"python","entrypoint":"./app.derrick/plugin.py"}"# + from: #"{"language":"go","entrypoint":"./app.derrick/plugin.go"}"# ) - #expect(runtime?.language == .python) - #expect(runtime?.entrypoint.hasSuffix(".py") == true) + #expect(runtime?.language == .go) + #expect(runtime?.entrypoint.hasSuffix(".go") == true) } @Test func envelopeDecoderRejectsNestedResultAliases() { @@ -47,7 +58,7 @@ import Testing let reviewer = RecordingFactoryReviewer(result: PluginFactoryReview(approved: true, summary: "safe")) let draft = PluginFactoryDraft( manifestJSON: manifestJSON(), - guestSource: guestPythonSource(), + guestSource: guestGoSource(), testInput: Data(#"{"kind":"manual"}"#.utf8), skillFiles: ["skills/weather/SKILL.md": "# Weather\n\nReturn weather."] ) @@ -60,12 +71,12 @@ import Testing #expect(release.pluginID == "weather-tool") #expect(release.version == "1.2.3") - #expect(release.runtimeJSON.contains("\"language\":\"python\"")) - #expect(release.runtimeJSON.contains("plugin.py")) + #expect(release.runtimeJSON.contains("\"language\":\"go\"")) + #expect(release.runtimeJSON.contains("plugin.go")) #expect(!release.contentHash.rawValue.isEmpty) #expect(release.verifyIntegrity()) var tampered = release.packageFiles() - tampered["app.derrick/plugin.py"] = Data("changed".utf8) + tampered["app.derrick/plugin.go"] = Data("changed".utf8) #expect(!PluginFactoryRelease.verifyIntegrity(files: tampered, expected: release.contentHash)) #expect(await executor.draftRunCount == 1) #expect(await executor.packageCount == 1) @@ -83,7 +94,7 @@ import Testing _ = try await PluginFactory().build( draft: PluginFactoryDraft( manifestJSON: manifestJSON(), - guestSource: guestPythonSource() + guestSource: guestGoSource() ), executor: executor, reviewer: reviewer @@ -222,7 +233,7 @@ import Testing @Test func reservedPluginIDsCannotBeCreated() async { let draft = PluginFactoryDraft( manifestJSON: manifestJSON().replacingOccurrences(of: "weather-tool", with: "create-plugin"), - guestSource: guestPythonSource(), + guestSource: guestGoSource(), ) do { _ = try await PluginFactory().build( @@ -242,13 +253,13 @@ import Testing @Test func missingSchemaIsRejectedAtTheFactoryBoundary() async { let manifest = """ - {"name":"weather-tool","version":"1.0.0","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py"}}} + {"name":"weather-tool","version":"1.0.0","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go"}}} """ do { _ = try await PluginFactory().build( draft: PluginFactoryDraft( manifestJSON: manifest, - guestSource: guestPythonSource() + guestSource: guestGoSource() ), executor: RecordingFactoryExecutor(), reviewer: RecordingFactoryReviewer( @@ -268,12 +279,12 @@ import Testing pluginID: "weather-tool", version: "1.0.0", description: "Weather summaries.", - guestSource: guestPythonSource(), + guestSource: guestGoSource(), ) let draft = try response.draft() let manifest = try AgentPluginManifest.decode(Data(draft.manifestJSON.utf8)) #expect(manifest.schema == PluginContract.agentPluginSchema) - #expect(manifest.derrick?.entrypoint == "./app.derrick/plugin.py") + #expect(manifest.derrick?.entrypoint == "./app.derrick/plugin.go") } @Test func builderNormalizesUnderscorePluginIDAndWritesSecretLabels() throws { @@ -281,7 +292,7 @@ import Testing pluginID: "slack_connection", version: "1.0.0", description: "Slack send and receive.", - guestSource: guestPythonSource(), + guestSource: guestGoSource(), secrets: [ try PluginSecretField(id: "username", label: "Slack username", kind: .username), try PluginSecretField(id: "password", label: "Slack password", kind: .password), @@ -300,7 +311,7 @@ import Testing pluginID: "slack-connection", version: "1.0.0", description: "Slack send and receive.", - guestSource: guestPythonSource(), + guestSource: guestGoSource(), role: .connector, messagingOps: ["send_message"] ) @@ -319,7 +330,7 @@ import Testing pluginID: "slack-connection", version: "1.0.0", description: "Slack full sync.", - guestSource: guestPythonSource(), + guestSource: guestGoSource(), role: .connector ) let draft = try response.draft() @@ -329,7 +340,7 @@ import Testing @Test func connectorTestScriptRequiresHopsAndFixtures() throws { let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} """ let manifest = try AgentPluginManifest.decode(Data(manifestJSON.utf8)) #expect(throws: PluginFactoryError.self) { @@ -337,7 +348,7 @@ import Testing } let draft = PluginFactoryDraft( manifestJSON: manifestJSON, - guestSource: guestPythonSource(), + guestSource: guestGoSource(), testInput: Data( #"{"kind":"message_in_room","params":{"messaging_op":"send_message"}}"#.utf8 ), @@ -374,7 +385,7 @@ import Testing @Test func fullSyncTestScriptRequiresReplyThreadPollHop() throws { let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} """ let manifest = try AgentPluginManifest.decode(Data(manifestJSON.utf8)) let channelOnly = Data( @@ -391,7 +402,7 @@ import Testing ) let missingReplies = PluginFactoryDraft( manifestJSON: manifestJSON, - guestSource: guestPythonSource(), + guestSource: guestGoSource(), testInput: channelOnly, userGoal: fullSyncGoal() ) @@ -401,7 +412,7 @@ import Testing let withReplies = PluginFactoryDraft( manifestJSON: manifestJSON, - guestSource: guestPythonSource(), + guestSource: guestGoSource(), testInput: Data( """ {"hops":[ @@ -424,7 +435,7 @@ import Testing @Test func dualFixtureAllowsUnsortedHttpResultsLoop() throws { let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} """ let manifest = try AgentPluginManifest.decode(Data(manifestJSON.utf8)) let testInput = Data( @@ -519,7 +530,7 @@ import Testing @Test func missingRoleDefaultsToStandard() throws { let json = """ - {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.0.0","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py"}}} + {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.0.0","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go"}}} """ let manifest = try AgentPluginManifest.decode(Data(json.utf8)) #expect(manifest.derrick?.role == .standard) @@ -528,7 +539,7 @@ import Testing @Test func invalidRoleIsRejected() { let json = """ - {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.0.0","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"slack"}}} + {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.0.0","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"slack"}}} """ do { _ = try AgentPluginManifest.decode(Data(json.utf8)) @@ -555,7 +566,7 @@ import Testing pluginID: "weather-tool", version: "1.0.0", description: "Weather summaries.", - guestSource: guestPythonSource(), + guestSource: guestGoSource(), skillFiles: [ PluginFactorySkillFile(path: "SKILL.md", body: "Invalid layout.") ] @@ -575,14 +586,14 @@ import Testing private func draft() -> PluginFactoryDraft { PluginFactoryDraft( manifestJSON: manifestJSON(), - guestSource: guestPythonSource(), + guestSource: guestGoSource(), testInput: Data(#"{"kind":"manual"}"#.utf8) ) } private func manifestJSON() -> String { """ - {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.2.3","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py"}}} + {"$schema":"\(PluginContract.agentPluginSchema)","name":"weather-tool","version":"1.2.3","extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go"}}} """ } } @@ -615,7 +626,7 @@ private func validConnectorTestInput() -> Data { private func connectorDraft(testInput: Data) -> PluginFactoryDraft { let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","auth_scheme":"bot_token","secrets":[{"id":"bot_token","label":"Bot Token","kind":"token"}],"messaging_ops":["sync_threads","poll_inbox","send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","auth_scheme":"bot_token","secrets":[{"id":"bot_token","label":"Bot Token","kind":"token"}],"messaging_ops":["sync_threads","poll_inbox","send_message"]}}} """ return PluginFactoryDraft( manifestJSON: manifestJSON, diff --git a/packages/Structure/Sources/AppLayerServices/AppServices/ContainerLifecyclePolicy.swift b/packages/Structure/Sources/AppLayerServices/AppServices/ContainerLifecyclePolicy.swift index 4ac3d26d..9f35e742 100644 --- a/packages/Structure/Sources/AppLayerServices/AppServices/ContainerLifecyclePolicy.swift +++ b/packages/Structure/Sources/AppLayerServices/AppServices/ContainerLifecyclePolicy.swift @@ -9,7 +9,7 @@ import Foundation public struct ContainerLifecyclePolicy: Sendable, Hashable { /// Maximum crawler containers at once (oneshot; own queue). public let maxNetworkContainers: Int - /// Maximum offline Python guest containers at once (`script_exec` / `plugin.invoke`). + /// Maximum offline Go guest containers at once (`script_exec` / `plugin.invoke`). public let maxOfflineContainers: Int /// Maximum file-extractor containers at once (oneshot; own queue). public let maxFileExtractContainers: Int diff --git a/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift b/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift index 3cd83418..d7296f4e 100644 --- a/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift +++ b/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift @@ -12,12 +12,11 @@ public enum DerrickGuestRuntime: Sendable { /// Leftover Swift guest image tag reported by older daemons. Hygiene retires a mismatch. public static let swiftPluginDockerImage = "swiftlang/swift:nightly-6.4.x-noble" - /// Pullable Python image for offline guests (script_exec primary). - public static let pythonGuestDockerImage = "python:3.14.7" + /// Unified Go worker image for offline guests (`script_exec` / `plugin.invoke`). + public static let guestDockerImage = DockerWorkerRuntime.image - /// Custom image with uv for packaged connector plugins. - /// Build: `docker build -f docker/guest-runtime/Dockerfile -t derrick-guest-runtime:python-v1 .` - public static let pythonGuestDockerImageWithUV = "derrick-guest-runtime:python-v1" + /// Legacy Python image reported by older daemons. Hygiene retires a mismatch. + public static let legacyPythonGuestDockerImage = "python:3.14.7" } public struct ServiceHealthReport: Codable, Sendable, Hashable { diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/EffectorAdmissionPolicy.swift b/packages/Structure/Sources/AppLayerServices/MCPService/EffectorAdmissionPolicy.swift index 0a69752b..5604b30b 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/EffectorAdmissionPolicy.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/EffectorAdmissionPolicy.swift @@ -6,10 +6,14 @@ public enum EffectorAdmissionPolicy: Sendable { context: ExecutionContextWire?, principal: ServicePrincipal ) -> Bool { - if case .job = principal { return true } - guard let context else { return false } - if context.capabilities.contains(.syncWebCrawl) { return true } - if context.workflow?.kind == .pluginFactoryCreate { return true } + switch principal { + case .job, .agent: + return true + default: + break + } + if let context, context.capabilities.contains(.syncWebCrawl) { return true } + if let context, context.workflow?.kind == .pluginFactoryCreate { return true } return false } diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift b/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift index 1acf6952..5d3808f0 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift @@ -45,7 +45,7 @@ public struct MCPToolCallRequest: Codable, Sendable, Hashable { /// JSON `HelperModelWire` for script security reviewer model selection. /// When nil, MCPService uses the default helper model. public let helperReviewerModelJSON: String? - /// When true, MCPService allows synchronous `web.crawl` (interactive `/create-plugin` turns). + /// When true, plugin factory creation is active for this call. /// Deprecated: use `executionContextJSON` (ExecutionContextWire). public let pluginFactoryCreationActive: Bool /// JSON `ExecutionContextWire` for cross-boundary policy and effector admission. diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift index 0a72e146..10ef8ffb 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift @@ -92,8 +92,8 @@ public enum PluginFactoryCreateFailureMessage: Sendable { if message.count > 160 { return true } let prefixes = [ "Invalid Agent Plugin manifest", - "Invalid Python guest source", - "Python draft test failed", + "Invalid Go guest source", + "Go draft test failed", "Plugin review rejected", "Draft validation failed:", ] diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift index 001ae388..6452b0ee 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift @@ -248,7 +248,7 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { } } extra.append( - "The host writes plugin.json. Return python_source and test_input_json only. Do not invent a plugin_id or secrets list." + "The host writes plugin.json. Return go_source and test_input_json only. Do not invent a plugin_id or secrets list." ) return try ConnectorContractPrompts.factoryGoal( vendorLabel: vendorLabel, diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift index 4a24d9bc..9f832978 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift @@ -43,22 +43,22 @@ public enum PromptResources { try DerrickBundledText.load("user_facing_spawn_overlay.md", from: resourceRoot) } - /// Python guest contract wrapped for a model prompt. + /// Go guest contract wrapped for a model prompt. public static func guestSDKForModel( from resourceRoot: URL? = nil, spec: PluginSpec? = nil ) throws -> String { _ = resourceRoot return DerrickBundledText.formatCodeForModel( - try DerrickGuestPython.source(for: spec), - heading: "standalone Python guest contract", - language: "python" + try DerrickGuestGo.source(for: spec), + heading: "standalone Go guest contract", + language: "go" ) } public static func guestSDKSource(from resourceRoot: URL? = nil) throws -> String { _ = resourceRoot - return try DerrickGuestPython.source() + return try DerrickGuestGo.source() } private static func load(named name: String, from resourceRoot: URL?, prefixTxt: String? = nil) throws -> String { diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/TurnProcessContextTypes.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/TurnProcessContextTypes.swift index f5f57d3b..1350aed8 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/TurnProcessContextTypes.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/TurnProcessContextTypes.swift @@ -13,7 +13,7 @@ public struct ExecutionContextSlots: Sendable { public var networkAccessPrompt: TurnProcessContextTypes.NetworkPrompt? public var policyDecisionPrompt: TurnProcessContextTypes.PolicyDecisionPrompt? public var policyNoticePublisher: TurnProcessContextTypes.PolicyNoticePublisher? - /// When true, `web.crawl` may run synchronously (plugin factory turns). + /// When true, plugin factory creation is active for this turn. public var pluginFactoryCreationActive: Bool public init( diff --git a/packages/Structure/Sources/Contract/GuestContract.swift b/packages/Structure/Sources/Contract/GuestContract.swift index 0464da30..25568ca1 100644 --- a/packages/Structure/Sources/Contract/GuestContract.swift +++ b/packages/Structure/Sources/Contract/GuestContract.swift @@ -11,6 +11,10 @@ public enum GuestContract: Sendable { case connectorParams = "connector-params.schema.json" case connectorResultEmit = "connector-result-emit.schema.json" case connectorVendor = "connector-vendor.schema.json" + case guestRuntime = "guest-runtime.schema.json" + case workerProduct = "worker-product.schema.json" + case webCrawlerResult = "web-crawler-result.schema.json" + case fileExtractorResult = "file-extractor-result.schema.json" } public static func loadSchemaText(_ schema: Schema) throws -> String { diff --git a/packages/Structure/Sources/Contract/GuestContractValidation.swift b/packages/Structure/Sources/Contract/GuestContractValidation.swift index 83fcb430..1da6e55f 100644 --- a/packages/Structure/Sources/Contract/GuestContractValidation.swift +++ b/packages/Structure/Sources/Contract/GuestContractValidation.swift @@ -9,4 +9,12 @@ public enum GuestContractValidation: Sendable { public static func validateHopEventJSON(_ data: Data) throws { try GuestContract.validate(json: data, against: .hopEvent) } + + public static func validateWebCrawlerResultJSON(_ data: Data) throws { + try GuestContract.validate(json: data, against: .webCrawlerResult) + } + + public static func validateFileExtractorResultJSON(_ data: Data) throws { + try GuestContract.validate(json: data, against: .fileExtractorResult) + } } diff --git a/packages/Structure/Sources/Contract/Resources/schemas/file-extractor-result.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/file-extractor-result.schema.json new file mode 100644 index 00000000..c4e12bc4 --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/schemas/file-extractor-result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/file-extractor-result.json", + "title": "File extractor worker stdout", + "description": "JSON object written to stdout by derrick-file-extractor.", + "$ref": "worker-product.schema.json#/$defs/file_extractor_result" +} diff --git a/packages/Structure/Sources/Contract/Resources/schemas/guest-runtime.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/guest-runtime.schema.json new file mode 100644 index 00000000..03f4f622 --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/schemas/guest-runtime.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/guest-runtime.json", + "title": "Derrick offline guest runtime", + "description": "Canonical I/O contract for script_exec and plugin.invoke guests. Trusted worker stdout (crawler, extractor) is defined in worker-product.schema.json. Swift host and Go workers must match these schemas.", + "type": "object", + "required": ["language", "stdin", "stdout"], + "additionalProperties": false, + "properties": { + "language": { + "type": "string", + "const": "go", + "description": "Guest implementation language." + }, + "stdin": { + "description": "One hop event JSON object read from standard input.", + "$ref": "hop-event.schema.json" + }, + "stdout": { + "description": "Envelope list JSON array written to standard output.", + "$ref": "envelope-list.schema.json" + }, + "binary": { + "type": "string", + "const": "/tmp/guest", + "description": "Linux guest binary path inside the worker container." + } + } +} diff --git a/packages/Structure/Sources/Contract/Resources/schemas/web-crawler-result.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/web-crawler-result.schema.json new file mode 100644 index 00000000..9af39755 --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/schemas/web-crawler-result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/web-crawler-result.json", + "title": "Web crawler worker stdout", + "description": "JSON object written to stdout by derrick-web-crawler.", + "$ref": "worker-product.schema.json#/$defs/web_crawler_result" +} diff --git a/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json new file mode 100644 index 00000000..874f6bfb --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/worker-product.json", + "title": "Derrick trusted worker product contracts", + "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler and file extractor). Swift host and Go workers must match these schemas.", + "$defs": { + "string_list": { + "type": "array", + "items": { "type": "string" } + }, + "web_crawler_stop_reason": { + "type": "string", + "enum": [ + "completed", + "max_pages", + "max_depth", + "timeout", + "total_bytes", + "queue_limit", + "cancelled", + "blocked" + ] + }, + "web_crawler_page": { + "type": "object", + "required": ["url", "depth", "status_code", "title", "text", "links_found"], + "additionalProperties": false, + "properties": { + "url": { "type": "string" }, + "depth": { "type": "integer" }, + "status_code": { "type": "integer" }, + "content_type": { "type": "string" }, + "title": { "type": "string" }, + "text": { "type": "string" }, + "links_found": { "type": "integer" } + } + }, + "web_crawler_result": { + "type": "object", + "required": [ + "ok", + "start_url", + "pages", + "stop_reason", + "requests_made", + "bytes_read", + "truncated", + "diagnostics" + ], + "additionalProperties": false, + "properties": { + "ok": { "type": "boolean" }, + "start_url": { "type": "string" }, + "pages": { + "type": "array", + "items": { "$ref": "#/$defs/web_crawler_page" } + }, + "stop_reason": { "$ref": "#/$defs/web_crawler_stop_reason" }, + "requests_made": { "type": "integer" }, + "bytes_read": { "type": "integer" }, + "truncated": { "type": "boolean" }, + "diagnostics": { "$ref": "#/$defs/string_list" } + } + }, + "file_extractor_operation": { + "type": "string", + "enum": ["extract", "convert"] + }, + "file_extractor_file_result": { + "type": "object", + "required": ["input_name", "kind", "byte_count"], + "additionalProperties": false, + "properties": { + "input_name": { "type": "string" }, + "output_name": { "type": "string" }, + "kind": { "type": "string" }, + "byte_count": { "type": "integer" }, + "preview": { "type": "string" }, + "error": { "type": "string" } + } + }, + "file_extractor_result": { + "type": "object", + "required": ["ok", "operation", "files", "diagnostics"], + "additionalProperties": false, + "properties": { + "ok": { "type": "boolean" }, + "operation": { "$ref": "#/$defs/file_extractor_operation" }, + "files": { + "type": "array", + "items": { "$ref": "#/$defs/file_extractor_file_result" } + }, + "diagnostics": { "$ref": "#/$defs/string_list" } + } + } + } +} diff --git a/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift b/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift new file mode 100644 index 00000000..dc08137b --- /dev/null +++ b/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Optional host Go toolchain probe (development diagnostics). Guest compile runs in Docker. +public enum DerrickGoToolchain: Sendable { + public static let minimumVersion = "1.27.1" + + public static func ensureInstalled() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["go", "version"] + process.environment = [ + "PATH": "/opt/homebrew/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin", + ] + let stdout = Pipe() + process.standardOutput = stdout + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw DerrickGoToolchainError.missing + } + let text = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + guard let version = parseVersion(text) else { + throw DerrickGoToolchainError.unparseable(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + guard versionSatisfies(version, minimum: minimumVersion) else { + throw DerrickGoToolchainError.tooOld(found: version, required: minimumVersion) + } + } + + private static func parseVersion(_ text: String) -> String? { + // go version go1.27.1 darwin/arm64 + for part in text.split(separator: " ") { + let token = String(part) + if token.hasPrefix("go"), token.count > 2 { + return String(token.dropFirst(2)) + } + } + return nil + } + + private static func versionSatisfies(_ found: String, minimum: String) -> Bool { + compareVersions(found, minimum) != .orderedAscending + } + + private enum Ordering { + case orderedAscending, orderedSame, orderedDescending + } + + private static func compareVersions(_ lhs: String, _ rhs: String) -> Ordering { + let left = lhs.split(separator: ".").map { Int($0) ?? 0 } + let right = rhs.split(separator: ".").map { Int($0) ?? 0 } + let count = max(left.count, right.count) + for index in 0.. r { return .orderedDescending } + } + return .orderedSame + } +} + +public enum DerrickGoToolchainError: Error, LocalizedError, Sendable { + case missing + case unparseable(String) + case tooOld(found: String, required: String) + + public var errorDescription: String? { + switch self { + case .missing: + return "Go \(DerrickGoToolchain.minimumVersion) or later was expected for diagnostics. Guest compile runs in Docker." + case .unparseable(let detail): + return "Could not read the installed Go version (\(detail))." + case .tooOld(let found, let required): + return "Go \(required) or later is required to build plugins (found \(found))." + } + } +} diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerImageDigest.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerImageDigest.swift new file mode 100644 index 00000000..baa9c04e --- /dev/null +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerImageDigest.swift @@ -0,0 +1,47 @@ +import Foundation + +/// SHA-256 image ID from `docker image inspect --format '{{.Id}}'`. +public struct DockerImageDigest: RawRepresentable, Sendable, Hashable, Codable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = Self.normalize(rawValue) + } + + public init?(hexDigest: String) { + let normalized = Self.normalize(hexDigest) + guard Self.isValid(normalized) else { return nil } + rawValue = normalized + } + + public static func normalize(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("sha256:") { + return trimmed + } + if trimmed.count == 64 { + return "sha256:\(trimmed)" + } + return trimmed + } + + public static func isValid(_ value: String) -> Bool { + let hex = value.hasPrefix("sha256:") ? String(value.dropFirst(7)) : value + guard hex.count == 64 else { return false } + return hex.unicodeScalars.allSatisfy { CharacterSet(charactersIn: "0123456789abcdef").contains($0) } + } +} + +public enum DockerImageDigestError: Error, LocalizedError, Sendable, Equatable { + case imageMissing(String) + case digestMismatch(tag: String, expected: DockerImageDigest, actual: DockerImageDigest) + + public var errorDescription: String? { + switch self { + case .imageMissing(let tag): + return "The worker image \(tag) is not installed." + case .digestMismatch(let tag, _, _): + return "The worker image \(tag) does not match the version shipped with Derrick. Rebuild or reinstall product images." + } + } +} diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift new file mode 100644 index 00000000..cd1299f4 --- /dev/null +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift @@ -0,0 +1,6 @@ +import Foundation + +/// Generated by scripts/record-docker-image-digests.sh — do not edit. +public enum DockerProductImageDigests: Sendable { + public static let worker = DockerImageDigest(rawValue: "sha256:d686d16d5fb8fe0a54a1eb9bb9c8a27763b5d80145ad330c5205536d73afdda6") +} diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerProductImagePolicy.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerProductImagePolicy.swift index bfbbfd37..9f323fea 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerProductImagePolicy.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerProductImagePolicy.swift @@ -1,16 +1,39 @@ import Foundation -/// Trusted product images built from in-repo Dockerfiles (not pulled from a registry). +/// Trusted product Docker images built from in-repo Dockerfiles (not pulled from a registry). public enum DockerProductImagePolicy: Sendable { + public static let workerImage = DockerWorkerRuntime.image + public static let workerDockerfileRelativePath = DockerWorkerRuntime.dockerfileRelativePath + public static let workerBuildContextRelativePath = DockerWorkerRuntime.buildContextRelativePath + + /// Legacy crawler tag — retained for orphan sweeps only. public static let webCrawlerImage = "derrick-web-crawler:swift-6.4-v1" public static let webCrawlerDockerfileRelativePath = "docker/web-crawler/Dockerfile" - /// Sibling Swift packages only — not the whole git checkout. public static let webCrawlerBuildContextRelativePath = "packages" public static let allowedBuildImageTags: Set = [ - webCrawlerImage, + workerImage, ] + public static func workerBuildContext(repoRoot: URL) -> URL { + repoRoot.standardizedFileURL + } + + public static func isAllowedWorkerBuild( + dockerfilePath: String, + imageTag: String, + contextPath: String + ) -> Bool { + guard imageTag == workerImage else { return false } + let dockerfileURL = URL(fileURLWithPath: dockerfilePath).standardizedFileURL + let contextURL = URL(fileURLWithPath: contextPath).standardizedFileURL + let repoRoot = contextURL.standardizedFileURL + let expectedDockerfile = repoRoot + .appendingPathComponent(workerDockerfileRelativePath) + .standardizedFileURL + return dockerfileURL.path == expectedDockerfile.path + } + public static func webCrawlerBuildContext(repoRoot: URL) -> URL { repoRoot.appendingPathComponent(webCrawlerBuildContextRelativePath).standardizedFileURL } diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift new file mode 100644 index 00000000..dca7e126 --- /dev/null +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Unified Go worker image shared by crawl, extract, and plugin guest execution. +public enum DockerWorkerRuntime: Sendable { + public static let image = "derrick-worker:go-v1" + public static let dockerfileRelativePath = "docker/worker/Dockerfile" + public static let buildContextRelativePath = "." + + public static let crawlerBinary = "/usr/local/bin/derrick-web-crawler" + public static let extractorBinary = "/usr/local/bin/derrick-file-extractor" + public static let guestBinaryPath = "/tmp/guest" + public static let guestSourcePath = "/tmp/plugin.go" + public static let goBinaryPath = "/usr/local/go/bin/go" + + /// Allowed `docker exec … sh -c` payloads for guest source I/O and in-container compile. + public static let guestWriteSourceShell = "cat > /tmp/plugin.go" + public static let guestCompileShell = + "cd /tmp && /usr/local/go/bin/go build -trimpath -ldflags=-s -w -o guest plugin.go && chmod +x guest" + public static let guestReadBinaryShell = "cat /tmp/guest" + + public static let pinnedDigest = DockerProductImageDigests.worker +} diff --git a/packages/Structure/Sources/MCPServer/MCPServerContractTypes.swift b/packages/Structure/Sources/MCPServer/MCPServerContractTypes.swift index 5fb77b5d..80d556d8 100644 --- a/packages/Structure/Sources/MCPServer/MCPServerContractTypes.swift +++ b/packages/Structure/Sources/MCPServer/MCPServerContractTypes.swift @@ -2,11 +2,11 @@ import Foundation import MCP public enum GuestScriptLanguage: String, Sendable, Equatable { - case python + case go - public var verifierID: String { "python-check-v1" } + public var verifierID: String { "go-check-v1" } - /// `language` is optional and must be Python when set. + /// `language` is optional and must be Go when set. public static func requestedLanguageIsUnsupported(_ arguments: [String: Value]) -> Bool { guard let raw = arguments["language"]?.stringValue? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -15,7 +15,7 @@ public enum GuestScriptLanguage: String, Sendable, Equatable { else { return false } - return raw != "python" && raw != "py" + return raw != "go" && raw != "golang" } } diff --git a/packages/Structure/Sources/MCPServer/ScriptExecutionModels.swift b/packages/Structure/Sources/MCPServer/ScriptExecutionModels.swift index 97481bae..3e59478a 100644 --- a/packages/Structure/Sources/MCPServer/ScriptExecutionModels.swift +++ b/packages/Structure/Sources/MCPServer/ScriptExecutionModels.swift @@ -59,7 +59,7 @@ public enum ScriptFailureStage: String, Codable, Sendable, Equatable { case none /// Static verifier rejected the request before run. case staticValidation - /// Leftover Swift compiler stage. Guest scripts are Python; this case remains for stored outcomes. + /// Go compile stage before container execution. case typecheck /// LLM security reviewer rejected (or could not complete when required). case llmReview @@ -253,7 +253,7 @@ public struct ScriptExecutionResult: Sendable { stderr: String, durationMS: Int, phaseTiming: ScriptPhaseTiming?, - verifier: String = "python-check-v1" + verifier: String = "go-check-v1" ) -> ScriptExecutionResult { let combined = stdout + "\n" + stderr let looksLikeEgress = combined.localizedCaseInsensitiveContains("UNAUTHORIZED_EGRESS") diff --git a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift index 2976abe7..a2ff70bd 100644 --- a/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift +++ b/packages/Structure/Sources/MCPToolCatalog/AllowedMCPTool.swift @@ -43,7 +43,7 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { public var defaultDescription: String { switch self { case .scriptExec: - return "Run declared standalone Python in a constrained Docker container after verification. Emit HTTP request envelopes; the host performs the request." + return "Run declared standalone Go in a constrained Docker container after verification. Emit HTTP request envelopes; the host performs the request." case .sessionMemorySearch: return "Search prior session memory entries with optional query and paging." case .agentsSpawn: @@ -69,7 +69,7 @@ public enum AllowedMCPTool: String, CaseIterable, Sendable, Codable, Hashable { case .pluginInvoke: return "Run one approved compiled Agent Plugin by id with a JSON input object." case .webCrawl: - return "Crawl a bounded same-origin website in an isolated Swift container and return structured page summaries." + return "Crawl a bounded same-origin website in an isolated container and return structured page summaries. Call directly in chat; use jobs_create only for crawls likely to exceed about one minute." case .filesExtract: return "Extract text or convert attached chat files (PDF, DOCX, XLSX, CSV, HTML) in an isolated Swift container. Call this tool directly; do not submit it through jobs_create." } diff --git a/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift b/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift new file mode 100644 index 00000000..175d3142 --- /dev/null +++ b/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Go contract shown to models that generate Derrick plugin guest programs. +public enum DerrickGuestGo: Sendable { + public static let modelContract = """ + Go guest contract: + - The program is a standalone `package main` built to a Linux binary and run as `/tmp/guest`. + - Read one JSON object from standard input matching the hop-event schema. + - Write one JSON array of envelope objects to standard output matching the envelope-list schema. + - Every envelope object needs `verb` from the envelope-list schema. + - POST bodies go in `json`. The host decodes `http.request` as HostHTTPRequest and sends `json` as the HTTP body. + - On the first event, emit `http.request` envelopes for host HTTP. + - On an `http_results` event, emit `result.emit` or `message.post`. + - The host, not the guest container, performs HTTP and supplies response bodies. + - Do not import net/http, net, os/exec, os (except os.Stdin/os.Stdout), or perform filesystem access. + - Use only the Go standard library. + - For repeatable output, match HTTP responses by request_id, sort and de-duplicate collections + by stable keys, and never use current time, randomness, UUIDs, response arrival order, or + map iteration order for user-visible output. + - Later http_results events include earlier responses plus the newest ones. Match by request_id. + + Minimal output pattern: + ```go + package main + + import ( + "encoding/json" + "os" + ) + + func main() { + var event map[string]any + if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { + return + } + emit([]map[string]any{{"verb": "result.emit", "title": "Result", "summary": "done"}}) + } + + func emit(envelopes []map[string]any) { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode(envelopes) + } + ``` + Inspect `event["kind"]` and `event["http_results"]` to choose the next envelopes. + """ + + public static func source(for spec: PluginSpec? = nil) throws -> String { + var sections = [modelContract] + sections.append( + """ + Canonical JSON schemas (Swift host and Go guest must match exactly): + --- \(GuestContract.Schema.guestRuntime.rawValue) --- + \(try GuestContract.loadSchemaText(.guestRuntime)) + + --- \(GuestContract.Schema.hopEvent.rawValue) --- + \(try GuestContract.loadSchemaText(.hopEvent)) + + --- \(GuestContract.Schema.envelopeList.rawValue) --- + \(try GuestContract.loadSchemaText(.envelopeList)) + """ + ) + if let spec { + sections.append( + """ + Plugin parameters are delivered in the input object's `params` object. + The parameter contract is: + \(try spec.goParameterDeclaration()) + + --- \(GuestContract.Schema.connectorParams.rawValue) --- + \(try GuestContract.loadSchemaText(.connectorParams)) + """ + ) + } + return sections.joined(separator: "\n\n") + } +} + +private extension PluginSpec { + func goParameterDeclaration() throws -> String { + _ = try validated() + let fields = parameters.map { parameter in + " \(parameter.name) \(parameterType(parameter.type))" + } + return """ + type PluginParams struct { + \(fields.joined(separator: "\n")) + } + """ + } + + func parameterType(_ type: PluginParameterType) -> String { + switch type { + case .string: + return "string" + case .number: + return "float64" + case .boolean: + return "bool" + case .stringList: + return "[]string" + case .numberList: + return "[]float64" + } + } +} diff --git a/packages/Structure/Sources/Plugin/Envelope/DerrickGuestPython.swift b/packages/Structure/Sources/Plugin/Envelope/DerrickGuestPython.swift deleted file mode 100644 index 3ef8d5e7..00000000 --- a/packages/Structure/Sources/Plugin/Envelope/DerrickGuestPython.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation - -/// Python contract shown to models that generate standalone Derrick guest programs. -public enum DerrickGuestPython: Sendable { - public static let modelContract = """ - Python guest contract: - - The program is a standalone Python script run as `python3 /tmp/guest.py`. - - Read one JSON object from standard input (`sys.stdin`). - - Write one JSON array of envelope objects to standard output. Every object needs `verb` from the envelope-list schema. - - POST bodies go in `json`. The host decodes `http.request` as HostHTTPRequest and sends `json` as the HTTP body. - - On the first event, emit `http.request` envelopes for host HTTP. - - On an `http_results` event, emit `result.emit` or `message.post`. - - The host, not the Python container, performs HTTP and supplies response bodies. - - Do not use socket, urllib, requests, httpx, subprocess, or filesystem access. - - Use only the Python standard library (no pip/uv dependencies in script_exec). - - For repeatable output, match HTTP responses by request_id, sort and de-duplicate collections - by stable keys, and never use current time, randomness, UUIDs, response arrival order, or - dict/set iteration order for user-visible output. - - Later http_results events include earlier responses plus the newest ones. Match by request_id. - - Minimal output pattern: - ```python - import json, sys - event = json.load(sys.stdin) - def emit(envelopes): - json.dump(envelopes, sys.stdout, separators=(",", ":")) - ``` - Inspect `event.get("kind")` and `event.get("http_results")` to choose the next envelopes. - - Example request envelope: - {"verb":"http.request","request_id":"news-1","method":"GET","url":"https://example.com/feed.xml"} - - POST bodies: put the JSON value in `json`. The host deserializes `http.request` into HostHTTPRequest and sends `json` as the HTTP body. - {"verb":"http.request","request_id":"send-1","method":"POST","url":"https://example.com/api","headers":{"Content-Type":"application/json"},"json":{"channel":"C1","text":"hello"}} - - Example result envelope: - {"verb":"result.emit","title":"Result","summary":"User-readable output"} - """ - - public static func source(for spec: PluginSpec? = nil) throws -> String { - var sections = [modelContract] - if let spec { - sections.append( - """ - Plugin parameters are delivered in the input object's `params` object. - The parameter contract is: - \(try spec.pythonParameterDeclaration()) - """ - ) - } - return sections.joined(separator: "\n\n") - } -} - -private extension PluginSpec { - func pythonParameterDeclaration() throws -> String { - _ = try validated() - let fields = parameters.map { parameter in - " \(parameter.name): \(parameterType(parameter.type))" - } - return """ - class PluginParams(TypedDict): - \(fields.joined(separator: "\n")) - """ - } - - func parameterType(_ type: PluginParameterType) -> String { - switch type { - case .string: - return "str" - case .number: - return "float" - case .boolean: - return "bool" - case .stringList: - return "list[str]" - case .numberList: - return "list[float]" - } - } -} diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift index 5548c6a3..4a812e69 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift @@ -1,7 +1,7 @@ import Foundation public enum PluginGuestLanguage: String, Sendable, Equatable, Codable { - case python + case go } /// Parsed `app.derrick/runtime.json` from an approved factory release. @@ -9,7 +9,7 @@ public struct PluginFactoryRuntime: Sendable, Equatable { public let language: PluginGuestLanguage public let entrypoint: String - public init(language: PluginGuestLanguage = .python, entrypoint: String) { + public init(language: PluginGuestLanguage = .go, entrypoint: String) { self.language = language self.entrypoint = entrypoint } @@ -22,6 +22,8 @@ public struct PluginFactoryRuntime: Sendable, Equatable { else { return nil } - return PluginFactoryRuntime(entrypoint: entrypoint) + let languageRaw = (object["language"] as? String) ?? PluginGuestLanguage.go.rawValue + let language = PluginGuestLanguage(rawValue: languageRaw) ?? .go + return PluginFactoryRuntime(language: language, entrypoint: entrypoint) } } diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTestScript.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTestScript.swift index aaed1995..74169873 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTestScript.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTestScript.swift @@ -77,7 +77,10 @@ public enum PluginFactoryHopTestRunner: Sendable { testInput: Data, executor: any PluginFactoryExecutor ) async throws -> PluginFactoryHopTestRun { - try await run(testInput: testInput) { input in + if let compiled = executor as? any PluginFactoryCompiledGuestExecutor { + return try await compiled.runGuestSourceHops(source: source, testInput: testInput) + } + return try await run(testInput: testInput) { input in try await executor.runGuestSource(source: source, input: input) } } diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift index bea96cf2..2b37ff71 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift @@ -2,9 +2,9 @@ import Foundation public typealias PluginFactoryLogger = @Sendable (String) async -> Void -/// The factory creates Agent Plugin packages whose Derrick entrypoint is Python. -/// A draft is a standalone file: the container runs it with `python3 /tmp/guest.py`. -/// A released version stores UTF-8 source as the packaged artifact. +/// The factory creates Agent Plugin packages whose Derrick entrypoint is Go. +/// A draft is compiled to a Linux binary and run as `/tmp/guest` in the worker image. +/// A released version stores the compiled binary as the packaged artifact. public struct PluginFactoryDraft: Sendable, Hashable { public let manifestJSON: String public let guestSource: String @@ -146,7 +146,7 @@ public struct PluginFactoryManifestInput: Sendable, Hashable { throw PluginFactoryError.invalidManifest("Version is required.") } var derrick: [String: Any] = [ - "entrypoint": "./app.derrick/plugin.py", + "entrypoint": "./app.derrick/plugin.go", ] if !secrets.isEmpty { derrick["secrets"] = secrets.map(\.jsonObject) @@ -204,7 +204,7 @@ public struct PluginFactoryBuilderRequest: Sendable, Hashable { public let userGoal: String public let previousDraft: PluginFactoryDraft? public let feedback: String? - /// When set, the host writes `plugin.json`. The builder only supplies Python and tests. + /// When set, the host writes `plugin.json`. The builder only supplies Go source and tests. public let hostManifest: PluginFactoryManifestInput? public init( @@ -288,7 +288,8 @@ public struct PluginFactoryBuilderResponse: Codable, Sendable, Hashable { enum CodingKeys: String, CodingKey { case pluginID = "plugin_id" case version, description - case guestSource = "python_source" + case guestSource = "go_source" + case legacyPythonSource = "python_source" case legacySwiftSource = "swift_source" case testInputJSON = "test_input_json" case skillFiles = "skill_files" @@ -303,6 +304,7 @@ public struct PluginFactoryBuilderResponse: Codable, Sendable, Hashable { version = try container.decode(String.self, forKey: .version) description = try container.decode(String.self, forKey: .description) guestSource = try container.decodeIfPresent(String.self, forKey: .guestSource) + ?? container.decodeIfPresent(String.self, forKey: .legacyPythonSource) ?? container.decode(String.self, forKey: .legacySwiftSource) testInputJSON = try container.decode(String.self, forKey: .testInputJSON) skillFiles = try container.decodeIfPresent([PluginFactorySkillFile].self, forKey: .skillFiles) ?? [] @@ -380,14 +382,19 @@ public struct PluginFactoryExecutionResult: Sendable, Hashable { } } -/// The host supplies this adapter. Its production implementation runs these -/// commands inside the restricted Linux Swift Docker container. +/// The host supplies this adapter. Its production implementation compiles and +/// runs guests inside the pinned Go worker Docker container. public protocol PluginFactoryExecutor: Sendable { func runGuestSource(source: String, input: Data) async throws -> PluginFactoryExecutionResult func packageGuestSource(source: String) async throws -> Data func runPackagedArtifact(_ artifact: Data, input: Data) async throws -> PluginFactoryExecutionResult } +/// Optional compile-once hop replay for factory direct tests. +public protocol PluginFactoryCompiledGuestExecutor: PluginFactoryExecutor { + func runGuestSourceHops(source: String, testInput: Data) async throws -> PluginFactoryHopTestRun +} + public enum PluginReviewDecision: String, Sendable, Hashable { case approved case rejected @@ -529,7 +536,7 @@ public struct PluginFactoryRelease: Sendable, Hashable { var files: [String: Data] = [ "plugin.json": Data(manifestJSON.utf8), "app.derrick/runtime.json": Data(runtimeJSON.utf8), - "app.derrick/plugin.py": Data(guestSource.utf8), + "app.derrick/plugin.go": Data(guestSource.utf8), "app.derrick/plugin": compiledArtifact, ] for (path, body) in skillFiles { @@ -571,15 +578,15 @@ public enum PluginFactoryError: Error, LocalizedError, Equatable, Sendable { case .invalidSkillPath(let path): return "Invalid skill path '\(path)'. Skill path must be skills//SKILL.md." case .reservedPluginID(let id): return "The plugin id '\(id)' is reserved by Derrick." - case .invalidSource(let message): return "Invalid Python guest source: \(message)" - case .directRunFailed(let message): return "Python draft test failed: \(message)" - case .invalidDirectOutput(let message): return "Python draft returned invalid plugin output: \(message)" + case .invalidSource(let message): return "Invalid Go guest source: \(message)" + case .directRunFailed(let message): return "Go draft test failed: \(message)" + case .invalidDirectOutput(let message): return "Go draft returned invalid plugin output: \(message)" case .reviewRejected(let summary, let findings): let detail = findings.isEmpty ? summary : "\(summary) \(findings.joined(separator: " "))" return "Plugin review rejected the draft: \(detail)" - case .packageFailed(let message): return "Python plugin packaging failed: \(message)" + case .packageFailed(let message): return "Go plugin packaging failed: \(message)" case .packagedRunFailed(let message): return "Packaged plugin test failed: \(message)" case .invalidPackagedOutput(let message): return "Packaged plugin returned invalid output: \(message)" case .draftValidationFailed(let findings): diff --git a/packages/Structure/Sources/Plugin/Manifest/PluginPath.swift b/packages/Structure/Sources/Plugin/Manifest/PluginPath.swift index f5cf9141..fd5df8c3 100644 --- a/packages/Structure/Sources/Plugin/Manifest/PluginPath.swift +++ b/packages/Structure/Sources/Plugin/Manifest/PluginPath.swift @@ -19,18 +19,18 @@ public enum PluginPath { return trimmed } - /// Python factory entrypoints are standalone files run by `python3`. - public static func validatePythonEntrypoint(_ raw: String) throws -> String { + /// Go factory entrypoints are standalone `package main` files compiled to `/tmp/guest`. + public static func validateGoEntrypoint(_ raw: String) throws -> String { let path = try validateRelative(raw) - guard path.hasSuffix(".py") else { + guard path.hasSuffix(".go") else { throw PluginManifestError.invalidEntrypoint(raw) } return path } - /// Accepts the supported Derrick guest runtime source file (.py). + /// Accepts the supported Derrick guest runtime source file (.go). public static func validateRuntimeEntrypoint(_ raw: String) throws -> String { - try validatePythonEntrypoint(raw) + try validateGoEntrypoint(raw) } public static func resolve(root: URL, relative: String) throws -> URL { diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index 5d8b2588..e594bbfc 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -88,7 +88,7 @@ import Testing let scriptReviewer = try DerrickBundledText.load("script_reviewer_instructions.md") #expect(scriptReviewer.contains("intent alignment")) #expect(scriptReviewer.contains("secret literals")) - #expect(scriptReviewer.contains("Python verifier")) + #expect(scriptReviewer.contains("Go verifier")) } @Test func healthDecodesLegacyPayloadWithoutGuestRuntime() throws { @@ -369,7 +369,7 @@ import Testing @Test func slackConnectorFallsBackToBotTokenWhenManifestOmitsSecrets() { let json = """ {"$schema":"https://example.invalid/agent-plugin.json","name":"slack-connector","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["sync_threads"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["sync_threads"]}}} """ let descriptors = PluginSecretField.resolvedDescriptors( pluginID: "slack-connector", @@ -1022,15 +1022,15 @@ import Testing reportedFingerprint: "a", expectedFingerprint: "a", reportedGuestRuntime: DerrickGuestRuntime.swiftPluginDockerImage, - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) ) #expect( !DerrickDaemonHygiene.shouldRetireConnectedDaemon( reportedFingerprint: "a", expectedFingerprint: "a", - reportedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage, - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + reportedGuestRuntime: DerrickGuestRuntime.guestDockerImage, + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) ) #expect( @@ -1038,7 +1038,7 @@ import Testing reportedFingerprint: "old", expectedFingerprint: "new", reportedGuestRuntime: DerrickGuestRuntime.swiftPluginDockerImage, - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) ) #expect( @@ -1046,7 +1046,7 @@ import Testing reportedFingerprint: "a", expectedFingerprint: "a", reportedGuestRuntime: "stale-guest:old", - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) ) #expect( @@ -1054,7 +1054,7 @@ import Testing reportedFingerprint: nil, expectedFingerprint: "a", reportedGuestRuntime: DerrickGuestRuntime.swiftPluginDockerImage, - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) ) #expect( @@ -1062,7 +1062,7 @@ import Testing reportedFingerprint: "a", expectedFingerprint: nil, reportedGuestRuntime: DerrickGuestRuntime.swiftPluginDockerImage, - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) ) } @@ -1317,12 +1317,12 @@ import Testing ) } - @Test func effectorAdmissionDeniesLiveChatWithoutContext() { + @Test func effectorAdmissionAllowsLiveChatWithoutContext() { #expect( EffectorAdmissionPolicy.allowsSyncWebCrawl( context: nil, principal: .agent(sessionID: "s1", agentID: "a1") - ) == false + ) ) } diff --git a/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift b/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift index 8bde0814..6ab15dec 100644 --- a/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift +++ b/packages/Structure/Tests/StructureTests/ConnectorContractTests.swift @@ -204,7 +204,7 @@ private func slackFullSyncGoal() -> String { private func slackFullSyncManifestJSON() -> String { """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connection","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} """ } diff --git a/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift b/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift new file mode 100644 index 00000000..6587b5d2 --- /dev/null +++ b/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift @@ -0,0 +1,12 @@ +import Structure +import Testing + +@Suite struct DerrickGoToolchainTests { + @Test func minimumVersionIsPinned() { + #expect(DerrickGoToolchain.minimumVersion == "1.27.1") + } + + @Test func ensureInstalledAcceptsCurrentGo() throws { + try DerrickGoToolchain.ensureInstalled() + } +} diff --git a/packages/Structure/Tests/StructureTests/GuestContractTests.swift b/packages/Structure/Tests/StructureTests/GuestContractTests.swift index d8c3d834..430d1346 100644 --- a/packages/Structure/Tests/StructureTests/GuestContractTests.swift +++ b/packages/Structure/Tests/StructureTests/GuestContractTests.swift @@ -10,6 +10,12 @@ import Testing } } + @Test func guestRuntimeSchemaRequiresGoLanguage() throws { + let schema = try GuestContract.loadSchemaObject(.guestRuntime) + let language = (schema["properties"] as? [String: Any])?["language"] as? [String: Any] + #expect(language?["const"] as? String == "go") + } + @Test func executionContextSchemaExposesWorkflowKinds() throws { let kinds = try GuestContract.officialWorkflowKinds() #expect(kinds.contains("plugin_factory_create")) @@ -104,4 +110,36 @@ import Testing try GuestContract.validate(json: Data(json.utf8), against: .envelopeList) } } + + @Test func webCrawlerResultValidationAcceptsMinimalSuccess() throws { + let json = """ + {"ok":true,"start_url":"https://example.com/","pages":[],"stop_reason":"completed","requests_made":0,"bytes_read":0,"truncated":false,"diagnostics":[]} + """ + try GuestContractValidation.validateWebCrawlerResultJSON(Data(json.utf8)) + } + + @Test func webCrawlerResultValidationRejectsNullDiagnostics() { + let json = """ + {"ok":true,"start_url":"https://example.com/","pages":[],"stop_reason":"completed","requests_made":0,"bytes_read":0,"truncated":false,"diagnostics":null} + """ + #expect(throws: GuestContractError.self) { + try GuestContractValidation.validateWebCrawlerResultJSON(Data(json.utf8)) + } + } + + @Test func fileExtractorResultValidationAcceptsMinimalSuccess() throws { + let json = """ + {"ok":true,"operation":"extract","files":[],"diagnostics":[]} + """ + try GuestContractValidation.validateFileExtractorResultJSON(Data(json.utf8)) + } + + @Test func fileExtractorResultValidationRejectsNullFiles() { + let json = """ + {"ok":false,"operation":"extract","files":null,"diagnostics":[]} + """ + #expect(throws: GuestContractError.self) { + try GuestContractValidation.validateFileExtractorResultJSON(Data(json.utf8)) + } + } } diff --git a/readme.md b/readme.md index e02a8bc4..dbf7d9f9 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ A native Swift macOS 27 desktop agent harness: chat with LLM providers, run isol |------|-------------| | **Chat** | Multi-tab conversations with OpenAI, Gemini, and other configured models | | **Tools (MCP)** | Model Context Protocol tool host inside the headless daemon | -| **Scripts** | Agent-generated Python executed in isolated Docker containers. Includes a secondary agent code reviewer and approvals flow. | +| **Scripts** | Agent-generated Go executed in isolated Docker containers. Includes a secondary agent code reviewer and approvals flow. | | **Plugin factory** | LLM-assisted creation of versioned, reviewed connector plugins | | **Jobs** | Scheduled and deferred tool/agent runs that survive app quit | | **Messaging** | Connector plugins (e.g. Slack) with threads, history, and live sync | @@ -35,14 +35,14 @@ A native Swift macOS 27 desktop agent harness: chat with LLM providers, run isol └────────────────┘ └───────────┬────────────┘ │ ┌───────────▼────────────┐ - │ Python guest containers │ + │ Go worker containers │ │ --network none │ └────────────────────────┘ ``` - **UI** is a client: it does not own agent turns or MCP when the daemon is up. - **Daemon** (`derrickd`) is the single owner of OS notifications and in-process Agent/Job/MCP modules. -- **Docker** runs untrusted Python for `script_exec`, plugin factory builds, and approved plugin invocations. +- **Docker** runs untrusted Go for `script_exec`, plugin factory builds, and approved plugin invocations. See [docs/adr-headless-backend.md](docs/adr-headless-backend.md) and [docs/services-plan.md](docs/services-plan.md). @@ -50,10 +50,12 @@ See [docs/adr-headless-backend.md](docs/adr-headless-backend.md) and [docs/servi Derrick treats model output and guest code as untrusted. -### Docker sandbox (Python guest runtime) +### Docker sandbox (Go worker runtime) -- Guest programs run in `python:3.14.7` containers with **`--network none`**. -- No sockets, urllib/requests, subprocess, or credentials inside the guest. +- **`script_exec`, plugins, web crawl, and file extract** share one Go worker image `derrick-worker:go-v1` (digest-pinned, `--network none` for guests). +- Plugin and script sources are compiled **inside the worker container** (Go 1.27.1 in the image). Users only need Docker Desktop. +- Canonical I/O types live in `packages/Structure/Sources/Contract/Resources/schemas/` and are mirrored to `workers/go/internal/contract/schemas/`. +- No net/http, subprocess, filesystem access, or credentials inside the guest. - The host dispatches `http.request` envelopes, attaches secrets, and enforces egress policy. - Historical Swift guest notes: [docs/adr-swift-script-runtime.md](docs/adr-swift-script-runtime.md). @@ -65,7 +67,7 @@ Before `script_exec` writes to disk, a **configured LLM reviewer** checks: - No secret literals in source - Safe handling of fetched content (no raw HTML leakage unless requested) -Instructions live in `ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md`. A static **Python verifier** also rejects forbidden APIs. +Instructions live in `ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md`. A static **Go verifier** also rejects forbidden APIs. ### Egress & network diff --git a/scripts/record-docker-image-digests.sh b/scripts/record-docker-image-digests.sh new file mode 100755 index 00000000..4642aa41 --- /dev/null +++ b/scripts/record-docker-image-digests.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Builds the unified Go worker image and updates the pinned digest in Structure. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +IMAGE="derrick-worker:go-v1" +docker build -f docker/worker/Dockerfile -t "$IMAGE" . + +DIGEST="$(docker image inspect --format '{{.Id}}' "$IMAGE")" +OUT="packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift" + +cat >"$OUT" </dev/null; then + echo "Guest contract schemas are out of sync. Run: scripts/sync-guest-contract-schemas.sh" >&2 + diff -qr "$SRC" "$DST" >&2 || true + exit 1 +fi + +echo "Guest contract schemas are in sync." diff --git a/ui/AgentService/AgentServiceExportedObject.swift b/ui/AgentService/AgentServiceExportedObject.swift index 456bfa46..6336a224 100644 --- a/ui/AgentService/AgentServiceExportedObject.swift +++ b/ui/AgentService/AgentServiceExportedObject.swift @@ -67,7 +67,7 @@ final class AgentServiceExportedObject: NSObject, AgentServiceXPC { service: .agent, status: .ok, detail: "AgentService ready (DB+\(leaf))", - guestRuntimeImage: DerrickProcessRole.isDaemon ? DerrickGuestRuntime.pythonGuestDockerImage : nil + guestRuntimeImage: DerrickProcessRole.isDaemon ? DerrickGuestRuntime.guestDockerImage : nil ) let data = (try? AgentServiceXPCCodec.encodeHealth(report)) ?? Data("{}".utf8) reply(data as NSData) diff --git a/ui/AgentService/AgentServiceTurnHost.swift b/ui/AgentService/AgentServiceTurnHost.swift index 84e9efbc..e4578ea0 100644 --- a/ui/AgentService/AgentServiceTurnHost.swift +++ b/ui/AgentService/AgentServiceTurnHost.swift @@ -223,7 +223,6 @@ actor AgentServiceTurnHost { LLMModelSettings(repository: repo) } helperModelSettings = settings - await EgressAllowlistService.shared.configure(repository: repo) await ContentSensitivityGrantService.shared.configure(repository: repo) await UsageLimitsService.shared.configure(repository: repo) await ContainerLifecycleSettingsService.shared.configure(repository: repo) diff --git a/ui/MCPService/MCPServiceDockerHelperRunner.swift b/ui/MCPService/MCPServiceDockerHelperRunner.swift index 5ed26f9c..2106122b 100644 --- a/ui/MCPService/MCPServiceDockerHelperRunner.swift +++ b/ui/MCPService/MCPServiceDockerHelperRunner.swift @@ -63,12 +63,9 @@ final class MCPServiceDockerHelperRunner: @unchecked Sendable { await DerrickDockerOrphanSweeper.sweep(executor: makeStdinCLIExecutor()) } - /// Prewarm the shared offline guest runtime image. + /// Prewarm the shared Go worker image used by script_exec and plugin.invoke. func prewarmGuestRuntime() async throws { - try await OneshotDockerContainer.ensurePulledImage( - DerrickGuestRuntime.pythonGuestDockerImage, - executor: makeStdinCLIExecutor() - ) + try await WorkerImageGate.shared.ensureReady(executor: makeStdinCLIExecutor()) } /// Build the crawler image in the background. Joins an in-flight build if one exists. diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index b44967f9..8808e4cb 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -42,7 +42,7 @@ actor MCPServiceToolHost { LLMModelThinkingSettings(repository: repo) } await factoryThinkingSettings.loadSettings() - let factoryExecutor = PythonPluginFactoryDockerExecutor( + let factoryExecutor = GoPluginFactoryDockerExecutor( executor: MCPServiceDockerHelperRunner.shared.makeStdinCLIExecutor() ) let webCrawlerExecutor = WebCrawlerDockerExecutor( @@ -233,32 +233,6 @@ actor MCPServiceToolHost { message: "Tool \(toolName) is owned by AgentService, not MCPService." ) } - if toolName == AllowedMCPTool.webCrawl.rawValue, - !EffectorAdmissionPolicy.allowsSyncWebCrawl( - context: EffectorAdmissionPolicy.parseContextJSON(request.executionContextJSON) - ?? legacyExecutionContext(from: request), - principal: request.principal - ) { - let outcome = ToolExecutionOutcome.failure( - status: .blocked, - stage: .validation, - diagnostics: [ - ToolExecutionOutcome.Diagnostic( - code: "web_crawl_requires_notification", - message: "web.crawl must be submitted through jobs_create so the result can arrive in a notification banner." - ) - ], - retry: ToolExecutionOutcome.Retry(allowed: false) - ) - return MCPToolCallResultDTO( - requestID: request.requestID, - ok: true, - isError: true, - text: (try? outcome.encodedJSON()) ?? "", - message: "Submit web.crawl through jobs_create for notification delivery." - ) - } - let sessionKey: MemorySessionKey switch request.principal { case .agent(let sessionID, let agentID): diff --git a/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift b/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift index edd1d542..de55fe91 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift @@ -122,7 +122,7 @@ struct ConversationPipeline: Sen if !toolInstructions.isEmpty { sections.append(toolInstructions) } - if !toolInstructions.contains("Python guest contract:") { + if !toolInstructions.contains("Go guest contract:") { if let sdk = try? PromptResources.guestSDKForModel() { sections.append(sdk) } diff --git a/ui/SharedAgentRuntime/Job/JobNetworkPreflight.swift b/ui/SharedAgentRuntime/Job/JobNetworkPreflight.swift index 540ea804..04547a72 100644 --- a/ui/SharedAgentRuntime/Job/JobNetworkPreflight.swift +++ b/ui/SharedAgentRuntime/Job/JobNetworkPreflight.swift @@ -5,9 +5,8 @@ import Plugin import PolicyUserInteraction import Structure -/// Before a scheduled network tool runs, ensure hosts are allowlisted. -/// Uncovered hosts use the HITL **banner** path (not live chat modals / schedule preflight). -/// +/// Before a scheduled network tool runs, prompt only on blacklist hits. +/// Public HTTPS is allowed by default; hard-blocked SSRF targets are denied without a prompt. public enum JobNetworkPreflight { public static func approveScriptNetworkIfNeeded( toolName: String, @@ -30,6 +29,13 @@ public enum JobNetworkPreflight { } guard !hosts.isEmpty else { return } + let hardBlockPolicy = DefaultDestinationPolicy(allowedDomainSuffixes: []) + for host in hosts { + if hardBlockPolicy.isHardBlockedHostname(host) { + throw JobNetworkPreflightError.hardBlocked(host: host) + } + } + let blacklist = try await repository.listEgressBlacklist() let exceptions = try await repository.listEgressBlacklistExceptions() for host in hosts { @@ -40,13 +46,15 @@ public enum JobNetworkPreflight { ) else { continue } + let argumentsJSON = blacklistArgumentsJSON(host: host, entry: entry, toolName: toolName) let decision = await HITLOfflineNetworkService.awaitDecision( host: host, toolName: toolName, turnID: "job-\(jobID)", isJobContext: true, repository: repository, - timeoutNanoseconds: 300_000_000_000 + timeoutNanoseconds: 300_000_000_000, + argumentsJSON: argumentsJSON ) switch decision { case .approved, .approvedOnce: @@ -62,83 +70,28 @@ public enum JobNetworkPreflight { } } - let suffixes = try await loadEnabledSuffixes(repository: repository) - - let policy = DefaultDestinationPolicy(allowedDomainSuffixes: suffixes) - var uncovered: [String] = [] - for host in hosts { - if policy.isHardBlockedHostname(host) { - throw JobNetworkPreflightError.hardBlocked(host: host) - } - if policy.isHostCoveredByAllowlist(host) { - continue - } - uncovered.append(host) - } - guard !uncovered.isEmpty else { - fputs( - "[JobNetworkPreflight] job=\(jobID) hosts covered count=\(hosts.count)\n", - stderr - ) - return - } - fputs( - "[JobNetworkPreflight] job=\(jobID) banner approval needed hosts=\(uncovered.joined(separator: ","))\n", + "[JobNetworkPreflight] job=\(jobID) ok hosts=\(hosts.count)\n", stderr ) - - var sessionGrants: [String] = [] - var allowedSuffixes = suffixes - for host in uncovered { - let coverage = DefaultDestinationPolicy(allowedDomainSuffixes: allowedSuffixes) - if coverage.isHostCoveredByAllowlist(host) { - continue - } - // Session grants from earlier Allow Once in this preflight (suffix-scoped). - let sessionPolicy = DefaultDestinationPolicy(allowedDomainSuffixes: []) - sessionPolicy.grantSessionHosts(sessionGrants) - if sessionPolicy.isHostCoveredByAllowlist(host) { - continue - } - let decision = await HITLOfflineNetworkService.awaitDecision( - host: host, - toolName: toolName, - turnID: "job-\(jobID)", - isJobContext: true, - repository: repository, - timeoutNanoseconds: 300_000_000_000 - ) - switch decision { - case .approvedPermanently(let actor): - let suffix = EgressHostExtractor.permanentSuffix(for: host) - try await repository.saveEgressAllowedDomainSuffix( - EgressAllowedDomainSuffix(suffix: suffix, source: actor ?? "job-banner", enabled: true) - ) - allowedSuffixes = try await loadEnabledSuffixes(repository: repository) - fputs( - "[JobNetworkPreflight] always host=\(host) suffix=\(suffix) actor=\(actor ?? "?")\n", - stderr - ) - case .approved(let actor), .approvedOnce(let actor): - sessionGrants.append(host) - fputs( - "[JobNetworkPreflight] once host=\(host) actor=\(actor ?? "?")\n", - stderr - ) - case .denied(let actor): - throw JobNetworkPreflightError.denied(host: host, actor: actor) - case .dismissed: - throw JobNetworkPreflightError.denied(host: host, actor: "system-dismissed") - case .timedOut: - throw JobNetworkPreflightError.denied(host: host, actor: "system-timeout") - } - } - } - private static func loadEnabledSuffixes(repository: DBRepository) async throws -> [String] { - let rows = try await repository.loadEgressAllowedDomainSuffixes(includeDisabled: false) - return rows.filter(\.enabled).map(\.suffix) + private static func blacklistArgumentsJSON( + host: String, + entry: BlacklistEntry, + toolName: String + ) -> String { + let payload: [String: String] = [ + "host": host, + "url": "https://\(host)", + "toolName": toolName, + "kind": "blacklist", + "pattern": entry.displayPattern, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { + return #"{"host":"\#(host)","toolName":"\#(toolName)","kind":"blacklist","pattern":"\#(entry.displayPattern)"}"# + } + return json } } diff --git a/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md b/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md index 1c488a09..2e186cfb 100644 --- a/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md +++ b/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md @@ -28,6 +28,11 @@ Always respond using the required JSON schema (`thinking` / `tool_call` / `tool_ When presenting a list of choices, options, steps, items, or alternative paths to the user, ALWAYS format them as a clean Markdown bulleted list (using `-` or `*`) or a numbered list (using `1.`, `2.`), instead of writing them as plain paragraphs. +## Website crawling (`web.crawl`) + +- Call `web.crawl` **directly** in live chat for typical same-origin crawls; wait for the result in the same turn. +- Use `jobs_create` with `tool_name` `web.crawl` only when you judge the crawl is likely to take **more than about one minute** (large `max_pages`, deep `max_depth`, or long `timeout_seconds`). Set `wake_after: true` and a short `wake_prompt` so the user gets a notification when it finishes. + ## Scheduled jobs (`jobs_create`) - `wake_after` (default **true**): when **true**, the agent is woken after a **successful** run to summarize the tool result and the user gets a notification + result panel. When **false**, success is silent (no wake, no notification). diff --git a/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md b/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md index 8e015391..6fdc0364 100644 --- a/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md +++ b/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md @@ -7,10 +7,10 @@ - Set `status` to "tool_call" when you need to execute a single tool, and populate the `tool_call` object with your target `tool_name` and a stringified, JSON-formatted string of tool arguments under the `arguments` key. - Set `status` to "tool_batch" when you need to execute multiple tools in parallel, and populate the `tool_batch` object with your list of `invocations`. - Set `status` to "complete" when you have finished and are responding directly to the user, and populate the `assistant_response` field with your Markdown reply. - - Pass tool `arguments` as a **stringified JSON object** under the `arguments` key (schema requirement). Prefer short Python source and avoid embedding unescaped double quotes in the script body. + - Pass tool `arguments` as a **stringified JSON object** under the `arguments` key (schema requirement). Prefer short Go source and avoid embedding unescaped double quotes in the script body. 6. Users should not have to name tools. Choose tools autonomously from intent. 7. Use `files.extract` for attached PDFs, Office documents, HTML, CSV, and Excel. Use `script_exec` for other scripting/automation. Use `web.crawl` for live website access. - 1. For `script_exec`, use standalone **Python** only. Read one JSON event from standard input and write a JSON **array** of envelopes to standard output. Do not use sockets, urllib/requests, subprocess, shell commands, credentials, or package dependencies. + 1. For `script_exec`, use standalone **Go** (`package main`) only. Read one hop-event JSON object from standard input and write an envelope-list JSON **array** to standard output. Do not import net/http, net, os/exec, or perform filesystem access. No third-party modules. 2. The container has no network. Emit `http.request` envelopes; the host performs HTTP and invokes the guest program again with an `http_results` event. 3. On the first hop emit `{"verb":"http.request","request_id":"…","method":"GET","url":"…"}`. On `http_results`, parse the supplied UTF-8 body and emit `result.emit` or `message.post`. 4. The script must complete the user's requested extraction or summary, not only prove that a fetch happened. Never emit `repr(http_results)` or copy an entire fetched body into `content` unless the user explicitly requested the raw source. For HTML/XML, remove scripts and styles, extract the relevant visible fields, normalize the text, and cap the result. If raw HTML is explicitly requested, emit it in `html`; the host sanitizes that field. @@ -21,11 +21,13 @@ findings as correction feedback and make at most one corrected `script_exec` call before answering. Do not repeat the same script unchanged. If the reviewer identifies a security refusal or the corrected call also fails, report the exact finding instead of claiming success. -9. Use `web.crawl` for website crawling instead of generating a crawler script. Because a crawl - can run for a long time, submit it through `jobs_create` with `tool_name` set to `web.crawl`, - `wake_after` set to true, and a short `wake_prompt` that tells the agent to present the crawl - result to the user. The immediate response must say the crawl was submitted and that the - result will arrive in a notification banner. Never request more than 900 seconds. +9. Use `web.crawl` for website crawling instead of generating a crawler script. Call it directly + in live chat so the result returns in the same turn. Only submit a crawl through `jobs_create` + when you judge it is likely to take more than about one minute (for example a large `max_pages`, + deep `max_depth`, many start URLs, or a long `timeout_seconds`). For background crawls use + `tool_name` `web.crawl`, `wake_after` true, and a short `wake_prompt`; tell the user the crawl + was submitted and that the result will arrive in a notification banner. Never request more than + 900 seconds. 10. A web crawl goal must describe the requested result. Never use it for DDoS, flooding, load/stress testing, port scanning, brute force, or other high-volume behavior. Keep the crawl same-origin and rely on the tool's page, depth, byte, rate, and timeout limits. diff --git a/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md b/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md index 1c7b9c8e..10b3640d 100644 --- a/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md +++ b/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md @@ -25,7 +25,7 @@ Checks (if any fail return failure JSON immediately): markup and validate generated links as http or https. Do not reject ordinary safe HTML in the `html` field solely because the host performs the final sanitization. -Do not deny for Python style, envelope construction, destination URLs, or the absence of dependencies. The static Python verifier enforces direct network and process restrictions. The guest has no network; the host performs HTTP and applies SSRF there. +Do not deny for Go style, envelope construction, destination URLs, or the absence of dependencies. The static Go verifier enforces direct network and process restrictions. The guest has no network; the host performs HTTP and applies SSRF there. Return only valid JSON with this exact schema: { diff --git a/ui/SharedAgentRuntime/Resources/web_crawler_skill.md b/ui/SharedAgentRuntime/Resources/web_crawler_skill.md index f9d7be7e..4dc1eb69 100644 --- a/ui/SharedAgentRuntime/Resources/web_crawler_skill.md +++ b/ui/SharedAgentRuntime/Resources/web_crawler_skill.md @@ -3,8 +3,10 @@ Use the `web.crawl` MCP tool for website crawling. Do not generate a crawler script with `script_exec`. -Submit every crawl through `jobs_create` so the user receives the result in a -notification banner. +Call `web.crawl` directly in live chat for typical crawls. Submit through +`jobs_create` only when the crawl is likely to take more than about one minute +(large page budget, deep site, or long timeout). Background crawls should use +`wake_after: true` so the user gets a notification banner when they finish. Required `web.crawl` arguments: diff --git a/ui/SharedAgentRuntime/Services/DaemonProcessHygiene.swift b/ui/SharedAgentRuntime/Services/DaemonProcessHygiene.swift index 4b60200c..fd909ce4 100644 --- a/ui/SharedAgentRuntime/Services/DaemonProcessHygiene.swift +++ b/ui/SharedAgentRuntime/Services/DaemonProcessHygiene.swift @@ -22,7 +22,7 @@ public enum DaemonProcessHygiene { reportedFingerprint: health.executableFingerprint, expectedFingerprint: expectedFingerprint(), reportedGuestRuntime: health.guestRuntimeImage, - expectedGuestRuntime: DerrickGuestRuntime.pythonGuestDockerImage + expectedGuestRuntime: DerrickGuestRuntime.guestDockerImage ) } diff --git a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift index 543dcda0..5be15662 100644 --- a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift +++ b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift @@ -240,6 +240,9 @@ final class AppBootstrapStatus: ObservableObject { /// Maps prewarm / Docker errors into a short title and user-facing explanation. static func classifyError(_ error: Error) -> ClassifiedFailure { + if let goError = error as? DerrickGoToolchainError { + return classifyGoToolchainError(goError) + } if let agentError = error as? JobServiceLoginAgent.AgentError { return classifyDaemonAgentError(agentError) } @@ -338,6 +341,40 @@ final class AppBootstrapStatus: ObservableObject { ) } + private static func classifyGoToolchainError( + _ error: DerrickGoToolchainError + ) -> ClassifiedFailure { + switch error { + case .missing: + return ClassifiedFailure( + title: "Go Toolchain Required", + message: """ + Derrick could not find a Go toolchain for development diagnostics. Guest compile runs in Docker; this error is unexpected. + + Quit and reopen Derrick. If it persists, reinstall Docker Desktop. + """ + ) + case .unparseable: + return ClassifiedFailure( + title: "Go Toolchain Unreadable", + message: """ + Derrick could not read the installed Go version. Guest compile runs in Docker; this error is unexpected. + + \(error.localizedDescription) + """ + ) + case .tooOld(_, let required): + return ClassifiedFailure( + title: "Go Toolchain Too Old", + message: """ + Derrick found an older Go toolchain than \(required). Guest compile runs in Docker; this error is unexpected. + + Quit and reopen Derrick. + """ + ) + } + } + private static func classifyDaemonAgentError( _ error: JobServiceLoginAgent.AgentError ) -> ClassifiedFailure { diff --git a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift index f667eef2..6c5f93e7 100644 --- a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift +++ b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift @@ -280,18 +280,11 @@ public final class XPCDockerRunner: @unchecked Sendable { ] ) } - await reportBootstrap(phase: .preparingImage, message: "Preparing guest runtime…") + await reportBootstrap(phase: .preparingImage, message: "Preparing worker image…") let executor = makeDockerExecutor() - let image = DerrickGuestRuntime.pythonGuestDockerImage - let inspect = try await executor(["image", "inspect", image], Data(), 30) - if inspect.exitCode != 0 { - await MainActor.run { - AppBootstrapStatus.shared.revealModalIfStillInitializing() - } - try await OneshotDockerContainer.ensurePulledImage(image, executor: executor) - } + try await WorkerImageGate.shared.ensureReady(executor: executor) prewarmState.markCompleted() - await reportBootstrap(phase: .verifyingEnvironment, message: "Guest runtime ready.") + await reportBootstrap(phase: .verifyingEnvironment, message: "Worker image ready.") } catch { debugLog("Guest runtime prewarming failed: \(error.localizedDescription)") prewarmState.markFailed(error) diff --git a/ui/SharedAgentRuntime/Support/Egress/EgressAllowlistService.swift b/ui/SharedAgentRuntime/Support/Egress/EgressAllowlistService.swift deleted file mode 100644 index 03c3ef13..00000000 --- a/ui/SharedAgentRuntime/Support/Egress/EgressAllowlistService.swift +++ /dev/null @@ -1,273 +0,0 @@ -import Foundation -import Combine -import DBRepository -import EgressProxy -import AppEvents -import PolicyUserInteraction -import Structure - -/// App-owned egress allowlist: DB persistence + host HTTP preflight prompts. -/// Not exposed as MCP. Not part of tool/content policy rules. -@MainActor -final class EgressAllowlistService: ObservableObject { - static let shared = EgressAllowlistService() - - @Published private(set) var suffixes: [EgressAllowedDomainSuffix] = [] - - private var repository: DBRepository? - private let username = "ui" - private let password = "ui" - private let localPolicy = DefaultDestinationPolicy(allowedDomainSuffixes: []) - - private init() {} - - func configure(repository: DBRepository) async { - self.repository = repository - do { - let inserted = try await repository.seedEgressAllowedDomainSuffixesIfNeeded( - EgressProxyConfiguration.defaultSeedDomainSuffixes, - source: "seed" - ) - if inserted > 0 { - debugLog("Egress allowlist seed inserted \(inserted) suffix(es).") - } else { - debugLog("Egress allowlist seed skipped (suffixes already present).") - } - try await reload() - } catch { - debugLog("Egress allowlist configure failed: \(error.localizedDescription)") - } - } - - func reload(clearSessionHosts: Bool = false) async throws { - guard let repository else { - suffixes = [] - return - } - let rows = try await repository.loadEgressAllowedDomainSuffixes(includeDisabled: true) - suffixes = rows - localPolicy.setAllowedDomainSuffixes(rows.filter(\.enabled).map(\.suffix)) - if clearSessionHosts { - // Settings edits must not be shadowed by prior Allow-once / mid-flight grants. - localPolicy.clearSessionHosts() - } - } - - private var isAgentServiceProcess: Bool { - let bid = Bundle.main.bundleIdentifier ?? "" - return bid == DerrickServiceID.agent.rawValue || bid.hasSuffix(".AgentService") - } - - func addSuffix(_ raw: String, source: String = "user") async throws { - let suffix = EgressHostExtractor.permanentSuffix(for: raw) - guard EgressHostExtractor.isPlausibleHostname(suffix) || suffix.contains(".") else { - throw NSError( - domain: "EgressAllowlist", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Invalid domain suffix: \(raw)"] - ) - } - guard let repository else { return } - try await repository.saveEgressAllowedDomainSuffix( - EgressAllowedDomainSuffix(suffix: suffix, source: source, enabled: true) - ) - try await reload() - } - - func removeSuffix(id: String) async throws { - guard let repository else { return } - try await repository.deleteEgressAllowedDomainSuffix(id: id) - try await reload(clearSessionHosts: true) - debugLog("Egress allowlist removed id=\(id)") - } - - /// Preflight network hosts before script execution. - /// - Returns: nil if allowed to proceed; blocked tool-result JSON if user denied or hard-blocked. - func preflightScriptNetwork( - script: String, - allowNetwork: Bool, - toolName: String = "script_exec" - ) async -> String? { - guard allowNetwork else { return nil } - - let preflightStarted = Date() - let hosts = EgressHostExtractor.extractHosts(from: script) - guard !hosts.isEmpty else { - PipelineTiming.log("egress_preflight hosts=0 total_ms=0 modal_ms=0") - return nil - } - - var sessionGrants: [String] = [] - var modalMS = 0 - var needsPrompt: [String] = [] - - for host in hosts { - if localPolicy.isHardBlockedHostname(host) { - let message = "Network access to “\(host)” is permanently blocked (private/metadata host)." - PipelineTiming.log( - "egress_preflight blocked_hard host=\(host) total_ms=\(PipelineTiming.elapsedMS(from: preflightStarted)) modal_ms=\(modalMS)" - ) - // Modal is published by the pipeline from the common network outcome. - return Self.blockedResultJSON(findings: [message]) - } - - if localPolicy.isHostCoveredByAllowlist(host) { - continue - } - needsPrompt.append(host) - } - - if !needsPrompt.isEmpty { - let modalStarted = Date() - let decision = await promptForHosts(needsPrompt, toolName: toolName) - modalMS += PipelineTiming.elapsedMS(from: modalStarted) - switch decision { - case .approved(let actor), .approvedOnce(let actor): - debugLog("Egress allow once for \(needsPrompt.count) host(s) by \(actor ?? "user")") - sessionGrants.append(contentsOf: needsPrompt) - localPolicy.grantSessionHosts(needsPrompt) - case .approvedPermanently(let actor): - debugLog("Egress allow always for \(needsPrompt.count) host(s) by \(actor ?? "user")") - for host in needsPrompt { - let suffix = EgressHostExtractor.permanentSuffix(for: host) - do { - try await addSuffix(suffix, source: "user") - sessionGrants.append(host) - localPolicy.grantSessionHosts([host]) - } catch { - debugLog("Failed to persist egress suffix \(suffix): \(error.localizedDescription)") - PipelineTiming.log( - "egress_preflight persist_failed host=\(host) total_ms=\(PipelineTiming.elapsedMS(from: preflightStarted)) modal_ms=\(modalMS)" - ) - return Self.blockedResultJSON( - findings: ["Failed to save permanent allow for \(host): \(error.localizedDescription)"] - ) - } - } - case .denied(let actor): - debugLog("Egress deny for \(needsPrompt.count) host(s) by \(actor ?? "user") — aborting entire script run") - let listed = needsPrompt.joined(separator: ", ") - let message = "User denied network access to “\(listed)”. The script was not run." - PipelineTiming.log( - "egress_preflight user_denied hosts=\(needsPrompt.count) total_ms=\(PipelineTiming.elapsedMS(from: preflightStarted)) modal_ms=\(modalMS) prompted_hosts=\(needsPrompt.count)" - ) - return Self.blockedResultJSON(findings: [message]) - case .dismissed, .timedOut: - let listed = needsPrompt.joined(separator: ", ") - let message = "Network access to “\(listed)” was not approved. The script was not run." - PipelineTiming.log( - "egress_preflight dismissed_or_timeout hosts=\(needsPrompt.count) total_ms=\(PipelineTiming.elapsedMS(from: preflightStarted)) modal_ms=\(modalMS)" - ) - return Self.blockedResultJSON(findings: [message]) - } - } - - PipelineTiming.log( - "egress_preflight ok hosts=\(hosts.count) prompted=\(needsPrompt.count) session_grants=\(sessionGrants.count) total_ms=\(PipelineTiming.elapsedMS(from: preflightStarted)) modal_ms=\(modalMS)" - ) - return nil - } - - private func promptForHosts(_ hosts: [String], toolName: String) async -> PolicyUserDecision { - let unique = Self.uniqueHosts(hosts) - guard !unique.isEmpty else { - return .denied(actor: "system") - } - - if let remote = TurnProcessContext.effectiveNetworkAccessPrompt { - var last: PolicyUserDecision = .denied(actor: "system") - for host in unique { - debugLog("Egress prompt via AgentService path host=\(host)") - last = await remote(host, toolName) - switch last { - case .denied, .dismissed, .timedOut: - return last - case .approved, .approvedOnce, .approvedPermanently: - continue - } - } - return last - } - - if !isAgentServiceProcess { - debugLog("Egress prompt via UI modal hosts=\(unique.count)") - let event = PolicyUserEventFactory.egressAccessRequest( - hosts: unique, - toolName: toolName - ) - return await AppEventBus.shared.initDecision(event) - } - - guard let repository else { - return .denied(actor: "system-no-repository") - } - var last: PolicyUserDecision = .denied(actor: "system") - for host in unique { - debugLog("Egress prompt via notification path host=\(host)") - last = await HITLOfflineNetworkService.awaitDecision( - host: host, - toolName: toolName, - turnID: "egress-agent", - isJobContext: false, - repository: repository, - timeoutNanoseconds: 300_000_000_000 - ) - switch last { - case .denied, .dismissed, .timedOut: - return last - case .approved, .approvedOnce, .approvedPermanently: - continue - } - } - return last - } - - private static func uniqueHosts(_ hosts: [String]) -> [String] { - var seen = Set() - var ordered: [String] = [] - for raw in hosts { - let host = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !host.isEmpty, seen.insert(host).inserted else { continue } - ordered.append(host) - } - return ordered - } - - /// Apply a user egress decision in this process. - /// Call when the UI answers a network prompt so later requests do not re-prompt. - func applyUserNetworkDecision(host: String, decision: PolicyUserDecision) async { - let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !normalized.isEmpty else { return } - - switch decision { - case .approved(let actor), .approvedOnce(let actor): - debugLog("Egress UI apply once host=\(normalized) actor=\(actor ?? "?")") - localPolicy.grantSessionHosts([normalized]) - case .approvedPermanently(let actor): - debugLog("Egress UI apply always host=\(normalized) actor=\(actor ?? "?")") - let suffix = EgressHostExtractor.permanentSuffix(for: normalized) - do { - try await addSuffix(suffix, source: "user") - } catch { - debugLog("Egress UI apply always persist failed: \(error.localizedDescription)") - } - localPolicy.grantSessionHosts([normalized]) - case .denied, .dismissed, .timedOut: - break - } - } - - private static func blockedResultJSON(findings: [String]) -> String { - let diagnostics = findings.isEmpty - ? [ToolExecutionOutcome.Diagnostic(code: "egress_denied", message: "Network access was denied.")] - : findings.map { - ToolExecutionOutcome.Diagnostic(code: "egress_denied", message: $0) - } - return (try? ToolExecutionOutcome.failure( - status: .blocked, - stage: .network, - diagnostics: diagnostics, - retry: ToolExecutionOutcome.Retry(allowed: false) - ).encodedJSON()) ?? #"{"status":"blocked","stage":"network","diagnostics":[]}"# - } -} diff --git a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift index 30ef4955..e487ee76 100644 --- a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift +++ b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift @@ -149,7 +149,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { """ You are the Derrick plugin builder. Convert the user's goal into one complete Agent Plugin draft. Return exactly one JSON object with these keys: - plugin_id (string), version (string), description (string), python_source (string), + plugin_id (string), version (string), description (string), go_source (string), test_input_json (string containing valid JSON — a serialized object, not prose), skill_files (array of objects with path and body), secrets (array of objects with id, label, and kind; required for connector plugins), @@ -159,7 +159,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { If the plugin needs a username, password, token, or API key, declare them in secrets. kind must be username, password, token, or api_key. id is a stable Keychain key such as username or bot_token. label is the text shown when the user saves the value. - Never put real credentials in python_source. + Never put real credentials in go_source. Set role to "connector" when the plugin sends and receives messages with an external messaging service (any chat or mail connector). Omit role or use "standard" otherwise. For role connector, include messaging_ops: an array of implemented ops @@ -168,13 +168,13 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { The host lists connector plugins under Messaging. Do not guess this from the plugin_id. Do not return manifest_json. The host creates the canonical Agent Plugin manifest, including the exact `$schema` field for Agent Plugin 1.0 and the fixed - extensions.app.derrick.entrypoint ./app.derrick/plugin.py. - \(DerrickGuestPython.modelContract) + extensions.app.derrick.entrypoint ./app.derrick/plugin.go. + \(DerrickGuestGo.modelContract) \(ConnectorContractPrompts.builderGuide(forUserGoal: userGoal)) Before returning the draft, self-check the implementation: - Sort every returned collection by an explicit stable key after parsing and de-duplicate it. - Match host responses by the emitted request_id. - - Use only the Python standard library (no pip, requests, urllib, socket, or subprocess). + - Use only the Go standard library (no third-party modules, raw sockets, or subprocess). - The direct test input must exercise the terminal result path with matching http_results fixtures. If skill_files is not needed, return an empty array. Every skill file path must be exactly skills//SKILL.md. @@ -189,7 +189,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { {"kind":"http_results","http_results":[{"request_id":"...","status":200,"body":"..."}],\ "params":{...}}]} Repeat additional hop pairs for each messaging_op in scope. request_id values in fixtures must \ - match the http.request envelopes your python_source emits. + match the http.request envelopes your go_source emits. - Match http_results by request_id and de-duplicate with stable sorting; never depend on response order. When vendor documentation is supplied in the user prompt, use it only to fill may_call HTTP details. """ @@ -201,7 +201,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { "plugin_id": AgentSchema(type: .string), "version": AgentSchema(type: .string), "description": AgentSchema(type: .string), - "python_source": AgentSchema(type: .string), + "go_source": AgentSchema(type: .string), "test_input_json": AgentSchema(type: .string), "skill_files": AgentSchema( type: .array, @@ -233,7 +233,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { ), ], required: [ - "plugin_id", "version", "description", "python_source", + "plugin_id", "version", "description", "go_source", "test_input_json", "skill_files", ] ) @@ -248,7 +248,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { """ The host already assigned plugin_id \(host.pluginID) and these secret ids: \ \(host.secrets.map(\.id).joined(separator: ", ")). \ - Return python_source and test_input_json. Do not pick a different plugin_id or secret ids. \ + Return go_source and test_input_json. Do not pick a different plugin_id or secret ids. \ Use {{secret:\(host.secrets.first?.id ?? "bot_token")}} in HTTP headers. """ ) @@ -395,7 +395,7 @@ actor ConfiguredPluginSafetyReviewer: PluginFactoryReviewer { private static func reviewerSystemPrompt(for userGoal: String?) -> String { """ You are Derrick's independent plugin alignment and safety reviewer. - Review the user's goal, manifest, test_input_json, exact Python source, and direct test output. + Review the user's goal, manifest, test_input_json, exact Go source, and direct test output. Return exactly one JSON object: {"decision":"approved|rejected","summary":"...","findings":[ {"severity":"info|warning|blocking","category":"alignment|safety|correctness|privacy|supplyChain","message":"..."} @@ -409,7 +409,7 @@ actor ConfiguredPluginSafetyReviewer: PluginFactoryReviewer { - For connector plugins, obey the connector protocol JSON below. If a rule is not in that JSON, do not require it. \(ConnectorContractPrompts.reviewerGuide(forUserGoal: userGoal)) Compilation success is not approval. Do not rewrite the code or approve a draft that fails these checks. - Reject Swift source, socket/urllib/requests usage, or missing stdin reads. + Reject non-Go source, raw network usage outside http.request envelopes, or missing stdin reads. """ } @@ -449,7 +449,7 @@ actor ConfiguredPluginSafetyReviewer: PluginFactoryReviewer { test_input_json: \(String(decoding: draft.testInput, as: UTF8.self)) - Python source: + Go source: \(draft.guestSource) Direct test output: diff --git a/ui/SharedAgentRuntime/TurnProcessContext.swift b/ui/SharedAgentRuntime/TurnProcessContext.swift index ab0f2532..5e5759c7 100644 --- a/ui/SharedAgentRuntime/TurnProcessContext.swift +++ b/ui/SharedAgentRuntime/TurnProcessContext.swift @@ -31,7 +31,7 @@ public enum TurnProcessContext { /// Active agent profile handle for the current user-facing turn (orchestrator-only tools). @TaskLocal public static var activeProfileHandle: String? - /// Active `/create-plugin` or `/edit-plugin` factory turn (enables sync `web.crawl`). + /// Active `/create-plugin` or `/edit-plugin` factory turn. @TaskLocal public static var pluginFactoryCreationActive: Bool = false public static func install( diff --git a/ui/ui/Jobs/DerrickNotificationService.swift b/ui/ui/Jobs/DerrickNotificationService.swift index 74b1c9a2..e0c1d92e 100644 --- a/ui/ui/Jobs/DerrickNotificationService.swift +++ b/ui/ui/Jobs/DerrickNotificationService.swift @@ -170,15 +170,6 @@ final class DerrickNotificationService { fputs("[HumanDecision] resolve skip id=\(approvalID)\n", stderr) return } - if approved, HITLOfflineNetworkService.isNetworkToolName(row.toolName), - let host = HITLOfflineNetworkService.host(fromNetworkToolName: row.toolName) { - await EgressAllowlistService.shared.applyUserNetworkDecision( - host: host, - decision: always - ? .approvedPermanently(actor: actor) - : .approvedOnce(actor: actor) - ) - } let status: PendingHITLApprovalStatus = approved ? .approved : .cancelled let edited = approved ? row.argumentsJSON : nil try? await repository.resolveHITLApproval( @@ -264,9 +255,6 @@ final class DerrickNotificationService { databaseDirectoryURL: directory ) repository = repo - if !JobResultPanelSession.isPanelOnlyLaunch { - await EgressAllowlistService.shared.configure(repository: repo) - } return repo } catch { fputs("[HumanDecision] ensureRepository failed: \(error.localizedDescription)\n", stderr) diff --git a/ui/ui/Jobs/HITLLiveApprovalHandlers.swift b/ui/ui/Jobs/HITLLiveApprovalHandlers.swift index cd52c2b2..8f70c351 100644 --- a/ui/ui/Jobs/HITLLiveApprovalHandlers.swift +++ b/ui/ui/Jobs/HITLLiveApprovalHandlers.swift @@ -166,51 +166,11 @@ enum HITLLiveApprovalHandlers { } private static func presentNetworkAccess(_ request: AgentNetworkAccessRequestDTO) async -> AgentNetworkAccessDecisionDTO { - let event = PolicyUserEventFactory.egressAccessRequest( - host: request.host, - toolName: request.toolName, - correlationId: request.requestID + // Blacklist-only egress: public hosts are allowed by default. Blacklist hits use PolicyEventBus. + AgentNetworkAccessDecisionDTO( + requestID: request.requestID, + decision: "once", + actor: "blacklist-only-auto" ) - let decision = await AppEventBus.shared.initDecision(event) - switch decision { - case .approvedOnce(let actor): - await EgressAllowlistService.shared.applyUserNetworkDecision( - host: request.host, - decision: .approvedOnce(actor: actor) - ) - return AgentNetworkAccessDecisionDTO( - requestID: request.requestID, - decision: "once", - actor: actor ?? "ui-modal-once" - ) - case .approvedPermanently(let actor): - await EgressAllowlistService.shared.applyUserNetworkDecision( - host: request.host, - decision: .approvedPermanently(actor: actor) - ) - return AgentNetworkAccessDecisionDTO( - requestID: request.requestID, - decision: "always", - actor: actor ?? "ui-modal-always" - ) - case .timedOut: - return AgentNetworkAccessDecisionDTO( - requestID: request.requestID, - decision: "timeout", - actor: "ui-modal-timeout" - ) - case .dismissed: - return AgentNetworkAccessDecisionDTO( - requestID: request.requestID, - decision: "dismissed", - actor: "ui-modal-dismissed" - ) - case .approved, .denied: - return AgentNetworkAccessDecisionDTO( - requestID: request.requestID, - decision: "deny", - actor: "ui-modal-deny" - ) - } } } diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 34845a8d..617a6bd9 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -838,7 +838,6 @@ struct ContentView: View { @MainActor private func configureClientRepositoryServices(repository: DBRepository) async { await ServiceLogRecorder.shared.configure(repository: repository) - await EgressAllowlistService.shared.configure(repository: repository) await ContentSensitivityGrantService.shared.configure(repository: repository) await UsageLimitsService.shared.configure(repository: repository) await ContainerLifecycleSettingsService.shared.configure(repository: repository) diff --git a/ui/ui/Views/LLMModelSettingsView.swift b/ui/ui/Views/LLMModelSettingsView.swift index 2321a609..d3209fc9 100644 --- a/ui/ui/Views/LLMModelSettingsView.swift +++ b/ui/ui/Views/LLMModelSettingsView.swift @@ -349,7 +349,7 @@ struct LLMModelSettingsView: View { Text("Multi-agent") .font(.system(size: 26, weight: .semibold, design: .rounded)) - Text("Caps for agents_spawn and worker turns in a chat session. New tabs use saved values; open tabs keep the limits they started with. Parallel script_exec runs wait in line (one Python guest container at a time). Crawls and file conversions have their own lines.") + Text("Caps for agents_spawn and worker turns in a chat session. New tabs use saved values; open tabs keep the limits they started with. Parallel script_exec runs wait in line (one Go guest container at a time). Crawls and file conversions have their own lines.") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/ui/uiTests/MessagingNavigationTests.swift b/ui/uiTests/MessagingNavigationTests.swift index 426c103e..88476aea 100644 --- a/ui/uiTests/MessagingNavigationTests.swift +++ b/ui/uiTests/MessagingNavigationTests.swift @@ -343,9 +343,9 @@ import Testing _ = try await repository.createEmptyDatabaseIfNeeded(username: "ui", password: "ui") let manifestJSON = """ {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-bot","version":"1.0.0",\ - "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.py","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector","messaging_ops":["sync_threads","poll_inbox","send_message"]}}} """ - let runtimeJSON = #"{"language":"python","entrypoint":"./app.derrick/plugin.py"}"# + let runtimeJSON = #"{"language":"go","entrypoint":"./app.derrick/plugin.go"}"# let guestSource = "print([])" var release = PluginFactoryRelease( pluginID: "slack-bot", diff --git a/ui/uiTests/PromptResourcesTests.swift b/ui/uiTests/PromptResourcesTests.swift index 66950af9..c63e47a5 100644 --- a/ui/uiTests/PromptResourcesTests.swift +++ b/ui/uiTests/PromptResourcesTests.swift @@ -74,10 +74,10 @@ import Testing @Test func loadsGuestSDKFromResourcesDirectory() throws { let source = try PromptResources.guestSDKSource() - #expect(source.contains("standalone Python script")) + #expect(source.contains("package main")) let wrapped = try PromptResources.guestSDKForModel() - #expect(wrapped.contains("```python")) - #expect(wrapped.contains("Python guest contract")) + #expect(wrapped.contains("```go")) + #expect(wrapped.contains("Go guest contract")) } @Test func throwsWhenConversationRAGInstructionsAreMissing() throws { diff --git a/workers/go/cmd/derrick-crawler/main.go b/workers/go/cmd/derrick-crawler/main.go new file mode 100644 index 00000000..4ea3160b --- /dev/null +++ b/workers/go/cmd/derrick-crawler/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "strconv" + + "github.com/jsoneaday/derrick/workers/internal/contract" + "github.com/jsoneaday/derrick/workers/internal/crawler" +) + +func main() { + input, err := io.ReadAll(os.Stdin) + if err != nil { + writeResult(blockedResult("", "Crawler input must be a valid JSON object.")) + return + } + + var req crawler.Request + if err := json.Unmarshal(input, &req); err != nil { + writeResult(blockedResult("", "Crawler input must be a valid JSON object.")) + return + } + + validated, err := crawler.Validate(req) + if err != nil { + writeResult(blockedResult(req.StartURL, err.Error())) + return + } + + proxy := readProxy() + ctx := context.Background() + result := crawler.Run(ctx, validated, proxy) + if len(result.Pages) == 0 && result.StopReason == crawler.StopCompleted { + result.OK = false + } + if len(result.Pages) > 0 { + result.OK = true + } + writeResult(result) +} + +func readProxy() *crawler.ProxyConfig { + host := os.Getenv("DERRICK_EGRESS_PROXY_HOST") + portStr := os.Getenv("DERRICK_EGRESS_PROXY_PORT") + token := os.Getenv("DERRICK_EGRESS_PROXY_TOKEN") + if host == "" && portStr == "" && token == "" { + return nil + } + port, _ := strconv.Atoi(portStr) + return &crawler.ProxyConfig{Host: host, Port: port, Token: token} +} + +func blockedResult(startURL string, message string) crawler.Result { + return crawler.Result{ + OK: false, + StartURL: startURL, + Pages: []crawler.Page{}, + StopReason: crawler.StopBlocked, + Diagnostics: []string{message}, + } +} + +func writeResult(result crawler.Result) { + payload, err := encodedCrawlerResult(result) + if err != nil { + writeEncodedResult(blockedResult(result.StartURL, "Crawler output violated worker contract.")) + return + } + _, _ = os.Stdout.Write(payload) +} + +func encodedCrawlerResult(result crawler.Result) ([]byte, error) { + if result.Pages == nil { + result.Pages = []crawler.Page{} + } + if result.Diagnostics == nil { + result.Diagnostics = []string{} + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(result); err != nil { + return nil, err + } + data := bytes.TrimSpace(buf.Bytes()) + if err := contract.ValidateWebCrawlerResultJSON(data); err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func writeEncodedResult(result crawler.Result) { + payload, err := encodedCrawlerResult(result) + if err != nil { + fallback := blockedResult("", "Crawler failed to produce schema-compliant output.") + payload, _ = encodedCrawlerResult(fallback) + } + _, _ = os.Stdout.Write(payload) +} diff --git a/workers/go/cmd/derrick-file-extractor/main.go b/workers/go/cmd/derrick-file-extractor/main.go new file mode 100644 index 00000000..8a93b092 --- /dev/null +++ b/workers/go/cmd/derrick-file-extractor/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "os" + + "github.com/jsoneaday/derrick/workers/internal/contract" + "github.com/jsoneaday/derrick/workers/internal/extractor" +) + +func main() { + input, err := io.ReadAll(os.Stdin) + if err != nil { + writeResult(extractor.Result{ + OK: false, + Operation: extractor.OperationExtract, + Files: []extractor.FileResult{}, + Diagnostics: []string{"File extractor input must be a valid JSON object."}, + }, 1) + return + } + + var req extractor.Request + if err := json.Unmarshal(input, &req); err != nil { + writeResult(extractor.Result{ + OK: false, + Operation: extractor.OperationExtract, + Files: []extractor.FileResult{}, + Diagnostics: []string{"File extractor input must be a valid JSON object."}, + }, 1) + return + } + + result := extractor.Run(req, extractor.InputDirectory, extractor.OutputDirectory) + code := 0 + if !result.OK { + code = 1 + } + writeResult(result, code) +} + +func writeResult(result extractor.Result, code int) { + payload, err := encodedExtractorResult(result) + if err != nil { + payload, _ = encodedExtractorResult(extractor.Result{ + OK: false, + Operation: result.Operation, + Files: []extractor.FileResult{}, + Diagnostics: []string{"File extractor output violated worker contract."}, + }) + code = 1 + } + _, _ = os.Stdout.Write(payload) + os.Exit(code) +} + +func encodedExtractorResult(result extractor.Result) ([]byte, error) { + if result.Files == nil { + result.Files = []extractor.FileResult{} + } + if result.Diagnostics == nil { + result.Diagnostics = []string{} + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(result); err != nil { + return nil, err + } + data := bytes.TrimSpace(buf.Bytes()) + if err := contract.ValidateFileExtractorResultJSON(data); err != nil { + return nil, err + } + return append(data, '\n'), nil +} diff --git a/workers/go/go.mod b/workers/go/go.mod new file mode 100644 index 00000000..df29702d --- /dev/null +++ b/workers/go/go.mod @@ -0,0 +1,21 @@ +module github.com/jsoneaday/derrick/workers + +go 1.27.1 + +require ( + github.com/PuerkitoBio/goquery v1.10.3 + github.com/xuri/excelize/v2 v2.9.1 +) + +require ( + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/tiendc/go-deepcopy v1.6.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.1 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/text v0.25.0 // indirect +) diff --git a/workers/go/go.sum b/workers/go/go.sum new file mode 100644 index 00000000..80c00844 --- /dev/null +++ b/workers/go/go.sum @@ -0,0 +1,100 @@ +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo= +github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= +github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= +github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= +github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/workers/go/internal/contract/contract.go b/workers/go/internal/contract/contract.go new file mode 100644 index 00000000..ba341c0b --- /dev/null +++ b/workers/go/internal/contract/contract.go @@ -0,0 +1,120 @@ +package contract + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "io/fs" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +//go:embed schemas/*.json +var schemaFS embed.FS + +// ValidateHopEventJSON checks stdin against hop-event.schema.json. +func ValidateHopEventJSON(data []byte) error { + return validate("hop-event.schema.json", data) +} + +// ValidateEnvelopeListJSON checks stdout against envelope-list.schema.json. +func ValidateEnvelopeListJSON(data []byte) error { + return validate("envelope-list.schema.json", data) +} + +// ValidateWebCrawlerResultJSON checks crawler stdout against web-crawler-result.schema.json. +func ValidateWebCrawlerResultJSON(data []byte) error { + return validate("web-crawler-result.schema.json", data) +} + +// ValidateFileExtractorResultJSON checks extractor stdout against file-extractor-result.schema.json. +func ValidateFileExtractorResultJSON(data []byte) error { + return validate("file-extractor-result.schema.json", data) +} + +func validate(schemaName string, data []byte) error { + compiler := jsonschema.NewCompiler() + if err := loadSchemas(compiler); err != nil { + return err + } + schema, err := compiler.Compile(schemaCompileURL(schemaName)) + if err != nil { + return fmt.Errorf("compile schema %s: %w", schemaName, err) + } + var value any + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return fmt.Errorf("invalid json: %w", err) + } + if err := schema.Validate(value); err != nil { + return fmt.Errorf("schema validation failed: %w", err) + } + return nil +} + +const schemaBase = "https://derrick.local/schemas/" + +func loadSchemas(compiler *jsonschema.Compiler) error { + return fs.WalkDir(schemaFS, "schemas", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".json") { + return nil + } + raw, err := schemaFS.ReadFile(path) + if err != nil { + return err + } + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + name := strings.TrimPrefix(path, "schemas/") + ids := schemaResourceIDs(name, doc) + for _, id := range ids { + if err := compiler.AddResource(id, doc); err != nil { + return err + } + } + return nil + }) +} + +func schemaResourceIDs(filename string, doc any) []string { + seen := map[string]bool{} + add := func(id string) { + if id == "" || seen[id] { + return + } + seen[id] = true + } + add(schemaBase + filename) + if m, ok := doc.(map[string]any); ok { + if id, ok := m["$id"].(string); ok { + add(id) + } + } + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + return ids +} + +func schemaCompileURL(name string) string { + raw, err := schemaFS.ReadFile("schemas/" + name) + if err != nil { + return schemaBase + name + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err == nil { + if id, ok := doc["$id"].(string); ok && id != "" { + return id + } + } + return schemaBase + name +} diff --git a/workers/go/internal/contract/contract_test.go b/workers/go/internal/contract/contract_test.go new file mode 100644 index 00000000..bd5d2291 --- /dev/null +++ b/workers/go/internal/contract/contract_test.go @@ -0,0 +1,49 @@ +package contract + +import "testing" + +func TestValidateHopEventAndEnvelopeList(t *testing.T) { + hop := []byte(`{"kind":"manual"}`) + if err := ValidateHopEventJSON(hop); err != nil { + t.Fatalf("hop event: %v", err) + } + env := []byte(`[{"verb":"result.emit","summary":"ok"}]`) + if err := ValidateEnvelopeListJSON(env); err != nil { + t.Fatalf("envelope list: %v", err) + } +} + +func TestValidateEnvelopeListRejectsNestedAlias(t *testing.T) { + env := []byte(`[{"verb":"result.emit","result":{"emit":{"content":"x"}}}]`) + if err := ValidateEnvelopeListJSON(env); err == nil { + t.Fatal("expected rejection") + } +} + +func TestValidateWebCrawlerResultAcceptsEmptyDiagnosticsArray(t *testing.T) { + payload := []byte(`{"ok":true,"start_url":"https://example.com/","pages":[],"stop_reason":"completed","requests_made":0,"bytes_read":0,"truncated":false,"diagnostics":[]}`) + if err := ValidateWebCrawlerResultJSON(payload); err != nil { + t.Fatalf("web crawler result: %v", err) + } +} + +func TestValidateWebCrawlerResultRejectsNullDiagnostics(t *testing.T) { + payload := []byte(`{"ok":true,"start_url":"https://example.com/","pages":[],"stop_reason":"completed","requests_made":0,"bytes_read":0,"truncated":false,"diagnostics":null}`) + if err := ValidateWebCrawlerResultJSON(payload); err == nil { + t.Fatal("expected rejection for null diagnostics") + } +} + +func TestValidateFileExtractorResultAcceptsEmptyFilesArray(t *testing.T) { + payload := []byte(`{"ok":true,"operation":"extract","files":[],"diagnostics":[]}`) + if err := ValidateFileExtractorResultJSON(payload); err != nil { + t.Fatalf("file extractor result: %v", err) + } +} + +func TestValidateFileExtractorResultRejectsNullFiles(t *testing.T) { + payload := []byte(`{"ok":false,"operation":"extract","files":null,"diagnostics":[]}`) + if err := ValidateFileExtractorResultJSON(payload); err == nil { + t.Fatal("expected rejection for null files") + } +} diff --git a/workers/go/internal/contract/schemas/connector-contract.schema.json b/workers/go/internal/contract/schemas/connector-contract.schema.json new file mode 100644 index 00000000..5ca68299 --- /dev/null +++ b/workers/go/internal/contract/schemas/connector-contract.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/connector-contract.json", + "title": "Derrick connector protocol document", + "description": "Canonical host ops, emit shapes, allowed vendor-call ids, and success rules. Vendor HTTP bindings live in contracts/vendors/*.json.", + "type": "object", + "required": ["version", "ops", "scopes", "rules"], + "additionalProperties": false, + "properties": { + "version": { "type": "integer", "const": 1 }, + "ops": { + "type": "object", + "required": ["sync_threads", "poll_inbox", "send_message"], + "additionalProperties": { "$ref": "#/$defs/op" } + }, + "scopes": { + "type": "object", + "required": ["full_sync"], + "additionalProperties": { "$ref": "#/$defs/scope" } + }, + "rules": { + "type": "object", + "required": [ + "sync_threads_lists_tabs_only", + "poll_loads_one_conversation", + "paginate_within_op", + "live_http_results_accumulate", + "direct_test_poll_requires_non_empty_messages", + "direct_test_threads_requires_non_empty", + "runtime_empty_messages_ok_if_vendor_ok" + ], + "properties": { + "sync_threads_lists_tabs_only": { "type": "boolean" }, + "poll_loads_one_conversation": { "type": "boolean" }, + "paginate_within_op": { "type": "boolean" }, + "live_http_results_accumulate": { "type": "boolean" }, + "direct_test_poll_requires_non_empty_messages": { "type": "boolean" }, + "direct_test_threads_requires_non_empty": { "type": "boolean" }, + "runtime_empty_messages_ok_if_vendor_ok": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "$defs": { + "op": { + "type": "object", + "required": ["kind", "emit"], + "properties": { + "kind": { "type": "string", "enum": ["manual", "message_in_room"] }, + "emit": { "type": "string", "enum": ["threads", "messages", "sent_message"] }, + "params": { "type": "array", "items": { "type": "string" } }, + "may_call": { "type": "array", "items": { "type": "string" } }, + "must_not_call": { "type": "array", "items": { "type": "string" } }, + "when_no_parent": { "$ref": "#/$defs/call_set" }, + "when_parent": { "$ref": "#/$defs/call_set" }, + "success": { "$ref": "#/$defs/success" }, + "failure": { "$ref": "#/$defs/failure" } + }, + "additionalProperties": false + }, + "call_set": { + "type": "object", + "properties": { + "may_call": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "success": { + "type": "object", + "properties": { + "empty_collection_ok": { "type": "boolean" }, + "empty_array_ok_if_vendor_ok": { "type": "boolean" }, + "requires_sent_message": { "type": "boolean" } + }, + "additionalProperties": false + }, + "failure": { + "type": "object", + "properties": { + "vendor_ok_false_empty_is_not_success": { "type": "boolean" }, + "codes": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "scope": { + "type": "object", + "required": ["ops", "include_reply_poll", "test_pagination"], + "properties": { + "ops": { "type": "array", "items": { "type": "string" } }, + "include_reply_poll": { "type": "boolean" }, + "test_pagination": { "type": "string", "enum": ["single_page", "follow_cursor"] }, + "poll_must_not_call": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + } + } +} diff --git a/workers/go/internal/contract/schemas/connector-params.schema.json b/workers/go/internal/contract/schemas/connector-params.schema.json new file mode 100644 index 00000000..32ba1a50 --- /dev/null +++ b/workers/go/internal/contract/schemas/connector-params.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/connector-params.json", + "title": "Connector hop params", + "description": "params object on a connector plugin.invoke hop event.", + "type": "object", + "properties": { + "messaging_op": { + "type": "string", + "enum": ["sync_threads", "poll_inbox", "send_message"] + }, + "vendor_thread_id": { "type": "string" }, + "text": { "type": "string" }, + "since": { "type": "string" }, + "oldest": { "type": "string" }, + "parent_vendor_message_id": { "type": "string" }, + "thread_ts": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/workers/go/internal/contract/schemas/connector-result-emit.schema.json b/workers/go/internal/contract/schemas/connector-result-emit.schema.json new file mode 100644 index 00000000..b67fcd14 --- /dev/null +++ b/workers/go/internal/contract/schemas/connector-result-emit.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/connector-result-emit.json", + "title": "Connector result.emit payload", + "description": "Structured fields the host persists from a connector terminal envelope.", + "type": "object", + "additionalProperties": false, + "properties": { + "threads": { + "type": "array", + "items": { + "type": "object", + "required": ["vendor_thread_id", "title"], + "additionalProperties": false, + "properties": { + "vendor_thread_id": { "type": "string" }, + "title": { "type": "string" }, + "is_member": { "type": "boolean" }, + "accessible": { "type": "boolean" } + } + } + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["vendor_thread_id", "vendor_message_id", "direction", "sender", "body", "created_at"], + "additionalProperties": false, + "properties": { + "vendor_thread_id": { "type": "string" }, + "vendor_message_id": { "type": "string" }, + "direction": { "type": "string", "enum": ["inbound", "outbound"] }, + "sender": { "type": "string" }, + "body": { "type": "string" }, + "created_at": { "type": ["string", "number"] }, + "parent_vendor_message_id": { "type": "string" }, + "thread_ts": { "type": "string" }, + "reply_count": { "type": "integer", "minimum": 0 } + } + } + }, + "sent_message": { + "type": "object", + "required": ["vendor_message_id", "created_at"], + "additionalProperties": false, + "properties": { + "vendor_message_id": { "type": ["string", "number"] }, + "created_at": { "type": ["string", "number"] } + } + }, + "title": { "type": "string" }, + "summary": { "type": "string" } + } +} diff --git a/workers/go/internal/contract/schemas/connector-vendor.schema.json b/workers/go/internal/contract/schemas/connector-vendor.schema.json new file mode 100644 index 00000000..5959d2a2 --- /dev/null +++ b/workers/go/internal/contract/schemas/connector-vendor.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/connector-vendor.schema.json", + "title": "Connector vendor profile", + "description": "HTTP bindings for one vendor. Call ids must match may_call / must_not_call in connector-contract.json.", + "type": "object", + "required": ["vendor", "vendor_ok_field", "calls"], + "additionalProperties": false, + "properties": { + "vendor": { "type": "string" }, + "vendor_ok_field": { "type": "string" }, + "membership_flag": { "type": "string" }, + "pagination_cursor": { "type": "string" }, + "calls": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "required": ["method", "url"], + "additionalProperties": false, + "properties": { + "method": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/workers/go/internal/contract/schemas/envelope-list.schema.json b/workers/go/internal/contract/schemas/envelope-list.schema.json new file mode 100644 index 00000000..5fbef6d8 --- /dev/null +++ b/workers/go/internal/contract/schemas/envelope-list.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/envelope-list.json", + "title": "Guest stdout envelope list", + "description": "JSON array written to stdout by an offline guest (plugin or script). Canonical contract for all guest languages.", + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["verb"], + "additionalProperties": false, + "properties": { + "verb": { + "type": "string", + "enum": [ + "http.request", + "result.emit", + "message.post", + "ui.present", + "secret.request", + "storage.read", + "storage.write", + "job.schedule", + "log" + ] + }, + "request_id": { "type": "string" }, + "method": { "type": "string" }, + "url": { "type": "string" }, + "title": { "type": "string" }, + "summary": { "type": "string" }, + "text": { "type": "string" }, + "content": { "type": "string" }, + "html": { "type": "string" }, + "markdown": { "type": "string" }, + "message": { "type": "string" }, + "schema_version": { "type": "integer" }, + "auth_ref": { "type": ["string", "null"] }, + "json": { + "description": "HTTP request body as a JSON value. HostHTTPRequest.json. The host sends this as the wire body." + }, + "headers": { "type": "object", "additionalProperties": { "type": "string" } }, + "widgets": { "type": "array", "items": { "type": "object" } }, + "secret_ref": { "type": "string" }, + "reason": { "type": "string" }, + "key": { "type": "string" }, + "value": true, + "interval_seconds": { "type": "integer" }, + "timezone": { "type": "string" }, + "threads": { "$ref": "connector-result-emit.schema.json#/properties/threads" }, + "messages": { "$ref": "connector-result-emit.schema.json#/properties/messages" }, + "sent_message": { "$ref": "connector-result-emit.schema.json#/properties/sent_message" } + } + } +} diff --git a/workers/go/internal/contract/schemas/execution-context-wire.schema.json b/workers/go/internal/contract/schemas/execution-context-wire.schema.json new file mode 100644 index 00000000..b1529b66 --- /dev/null +++ b/workers/go/internal/contract/schemas/execution-context-wire.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecutionContextWire", + "type": "object", + "required": ["schema_version", "session_id", "principal"], + "properties": { + "schema_version": { "const": 1 }, + "session_id": { "type": "string", "minLength": 1 }, + "turn_id": { "type": "string" }, + "agent_id": { "type": "string" }, + "principal": { "type": "string", "minLength": 1 }, + "workflow": { + "type": "object", + "properties": { + "workflow_id": { "type": "string" }, + "kind": { + "type": "string", + "enum": [ + "plugin_factory_create", + "plugin_factory_edit", + "connector_auth_discover", + "job_step", + "interactive_tool", + "none" + ] + }, + "step_id": { "type": "string" }, + "step_kind": { "type": "string" } + }, + "additionalProperties": false + }, + "delivery": { + "type": "string", + "enum": ["live_chat", "notification", "silent"] + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "enum": ["sync_web_crawl", "host_review_retry"] + }, + "uniqueItems": true + } + }, + "additionalProperties": false +} diff --git a/workers/go/internal/contract/schemas/file-extractor-result.schema.json b/workers/go/internal/contract/schemas/file-extractor-result.schema.json new file mode 100644 index 00000000..c4e12bc4 --- /dev/null +++ b/workers/go/internal/contract/schemas/file-extractor-result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/file-extractor-result.json", + "title": "File extractor worker stdout", + "description": "JSON object written to stdout by derrick-file-extractor.", + "$ref": "worker-product.schema.json#/$defs/file_extractor_result" +} diff --git a/workers/go/internal/contract/schemas/guest-runtime.schema.json b/workers/go/internal/contract/schemas/guest-runtime.schema.json new file mode 100644 index 00000000..03f4f622 --- /dev/null +++ b/workers/go/internal/contract/schemas/guest-runtime.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/guest-runtime.json", + "title": "Derrick offline guest runtime", + "description": "Canonical I/O contract for script_exec and plugin.invoke guests. Trusted worker stdout (crawler, extractor) is defined in worker-product.schema.json. Swift host and Go workers must match these schemas.", + "type": "object", + "required": ["language", "stdin", "stdout"], + "additionalProperties": false, + "properties": { + "language": { + "type": "string", + "const": "go", + "description": "Guest implementation language." + }, + "stdin": { + "description": "One hop event JSON object read from standard input.", + "$ref": "hop-event.schema.json" + }, + "stdout": { + "description": "Envelope list JSON array written to standard output.", + "$ref": "envelope-list.schema.json" + }, + "binary": { + "type": "string", + "const": "/tmp/guest", + "description": "Linux guest binary path inside the worker container." + } + } +} diff --git a/workers/go/internal/contract/schemas/hop-event.schema.json b/workers/go/internal/contract/schemas/hop-event.schema.json new file mode 100644 index 00000000..a183e284 --- /dev/null +++ b/workers/go/internal/contract/schemas/hop-event.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/hop-event.json", + "title": "Guest stdin hop event", + "description": "JSON object read from stdin when the host invokes an offline guest. Canonical contract for all guest languages.", + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "manual", + "schedule", + "message_in_room", + "http_results", + "ui_action", + "grant_ready", + "harness", + "script" + ] + }, + "http_results": { + "type": "array", + "items": { + "type": "object", + "required": ["request_id", "status"], + "additionalProperties": false, + "properties": { + "request_id": { "type": "string" }, + "status": { "type": "integer" }, + "headers": { "type": "object", "additionalProperties": { "type": "string" } }, + "body": { "type": "string" }, + "error": { "type": ["string", "null"] } + } + } + }, + "params": { + "$ref": "connector-params.schema.json" + } + }, + "additionalProperties": false +} diff --git a/workers/go/internal/contract/schemas/web-crawler-result.schema.json b/workers/go/internal/contract/schemas/web-crawler-result.schema.json new file mode 100644 index 00000000..9af39755 --- /dev/null +++ b/workers/go/internal/contract/schemas/web-crawler-result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/web-crawler-result.json", + "title": "Web crawler worker stdout", + "description": "JSON object written to stdout by derrick-web-crawler.", + "$ref": "worker-product.schema.json#/$defs/web_crawler_result" +} diff --git a/workers/go/internal/contract/schemas/worker-product.schema.json b/workers/go/internal/contract/schemas/worker-product.schema.json new file mode 100644 index 00000000..874f6bfb --- /dev/null +++ b/workers/go/internal/contract/schemas/worker-product.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/worker-product.json", + "title": "Derrick trusted worker product contracts", + "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler and file extractor). Swift host and Go workers must match these schemas.", + "$defs": { + "string_list": { + "type": "array", + "items": { "type": "string" } + }, + "web_crawler_stop_reason": { + "type": "string", + "enum": [ + "completed", + "max_pages", + "max_depth", + "timeout", + "total_bytes", + "queue_limit", + "cancelled", + "blocked" + ] + }, + "web_crawler_page": { + "type": "object", + "required": ["url", "depth", "status_code", "title", "text", "links_found"], + "additionalProperties": false, + "properties": { + "url": { "type": "string" }, + "depth": { "type": "integer" }, + "status_code": { "type": "integer" }, + "content_type": { "type": "string" }, + "title": { "type": "string" }, + "text": { "type": "string" }, + "links_found": { "type": "integer" } + } + }, + "web_crawler_result": { + "type": "object", + "required": [ + "ok", + "start_url", + "pages", + "stop_reason", + "requests_made", + "bytes_read", + "truncated", + "diagnostics" + ], + "additionalProperties": false, + "properties": { + "ok": { "type": "boolean" }, + "start_url": { "type": "string" }, + "pages": { + "type": "array", + "items": { "$ref": "#/$defs/web_crawler_page" } + }, + "stop_reason": { "$ref": "#/$defs/web_crawler_stop_reason" }, + "requests_made": { "type": "integer" }, + "bytes_read": { "type": "integer" }, + "truncated": { "type": "boolean" }, + "diagnostics": { "$ref": "#/$defs/string_list" } + } + }, + "file_extractor_operation": { + "type": "string", + "enum": ["extract", "convert"] + }, + "file_extractor_file_result": { + "type": "object", + "required": ["input_name", "kind", "byte_count"], + "additionalProperties": false, + "properties": { + "input_name": { "type": "string" }, + "output_name": { "type": "string" }, + "kind": { "type": "string" }, + "byte_count": { "type": "integer" }, + "preview": { "type": "string" }, + "error": { "type": "string" } + } + }, + "file_extractor_result": { + "type": "object", + "required": ["ok", "operation", "files", "diagnostics"], + "additionalProperties": false, + "properties": { + "ok": { "type": "boolean" }, + "operation": { "$ref": "#/$defs/file_extractor_operation" }, + "files": { + "type": "array", + "items": { "$ref": "#/$defs/file_extractor_file_result" } + }, + "diagnostics": { "$ref": "#/$defs/string_list" } + } + } + } +} diff --git a/workers/go/internal/crawler/engine.go b/workers/go/internal/crawler/engine.go new file mode 100644 index 00000000..890ea221 --- /dev/null +++ b/workers/go/internal/crawler/engine.go @@ -0,0 +1,372 @@ +package crawler + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +type extractedPage struct { + title string + text string + isHTML bool +} + +func Run(ctx context.Context, req ValidatedRequest, proxy *ProxyConfig) Result { + deadline := time.Now().Add(time.Duration(req.TimeoutSeconds) * time.Second) + engine := &bfsEngine{ + req: req, + deadline: deadline, + proxy: proxy, + client: newHTTPClient(proxy), + } + return engine.run(ctx) +} + +type queueItem struct { + url *url.URL + depth int +} + +type bfsEngine struct { + req ValidatedRequest + deadline time.Time + proxy *ProxyConfig + client *http.Client + queue []queueItem + queuedKeys map[string]bool + visitedKeys map[string]bool + pageDepths map[string]int + pages []Page + pageIndexes map[string]int + diagnostics []string + stopReason StopReason + reachedMaxDepth bool + requestsMade int + bytesRead int + lastRequestAt time.Time +} + +func (e *bfsEngine) run(ctx context.Context) Result { + startKey := URLKey(e.req.StartURL) + e.queuedKeys = map[string]bool{startKey: true} + e.visitedKeys = map[string]bool{} + e.pageDepths = map[string]int{startKey: 0} + e.pageIndexes = map[string]int{} + e.queue = []queueItem{{url: e.req.StartURL, depth: 0}} + + for len(e.queue) > 0 && e.stopReason == "" { + if time.Now().After(e.deadline) { + e.stopReason = StopTimeout + break + } + if len(e.pages) >= e.req.MaxPages { + e.stopReason = StopMaxPages + break + } + if ctx.Err() != nil { + e.stopReason = StopCancelled + break + } + + next := e.queue[0] + e.queue = e.queue[1:] + key := URLKey(next.url) + delete(e.queuedKeys, key) + + if !HostAllowed(e.req.AllowedHosts, next.url.Hostname()) || !IsHTTP(next.url) { + continue + } + if e.visitedKeys[key] { + continue + } + e.visitedKeys[key] = true + if len(e.visitedKeys) > e.req.MaxPages { + e.stopReason = StopMaxPages + break + } + + e.visit(ctx, next.url, next.depth) + } + + if e.stopReason == "" { + if e.reachedMaxDepth { + e.stopReason = StopMaxDepth + } else { + e.stopReason = StopCompleted + } + } + + truncated := false + totalChars := 0 + for i, p := range e.pages { + totalChars += len(p.Text) + if totalChars > MaximumOutputChars { + truncated = true + e.pages = e.pages[:i] + break + } + } + + return Result{ + OK: e.stopReason != StopBlocked && len(e.pages) > 0, + StartURL: e.req.StartURL.String(), + Pages: e.pages, + StopReason: e.stopReason, + RequestsMade: e.requestsMade, + BytesRead: e.bytesRead, + Truncated: truncated, + Diagnostics: e.diagnostics, + } +} + +func (e *bfsEngine) visit(ctx context.Context, pageURL *url.URL, depth int) { + if e.stopReason != "" || time.Now().After(e.deadline) { + return + } + + normalized := NormalizeURL(pageURL) + e.requestsMade++ + body, status, contentType, finalURL, err := e.fetch(ctx, normalized) + if err != nil { + e.diagnostics = append(e.diagnostics, fmt.Sprintf("%s: %s", normalized.String(), err.Error())) + return + } + + e.bytesRead += len(body) + if e.bytesRead > MaximumTotalBytes { + e.stopReason = StopTotalBytes + return + } + + extracted := extractContent(body, contentType, finalURL) + ct := contentType + page := Page{ + URL: finalURL.String(), + Depth: depth, + StatusCode: status, + ContentType: func() *string { + if ct == "" { + return nil + } + return &ct + }(), + Title: extracted.title, + Text: clipText(extracted.text, MaximumExtractedText), + } + e.pages = append(e.pages, page) + sourceKey := URLKey(finalURL) + e.pageIndexes[sourceKey] = len(e.pages) - 1 + e.pageDepths[sourceKey] = depth + + if !extracted.isHTML { + return + } + + links := parseLinks(body, finalURL) + accepted := e.enqueueLinks(sourceKey, links) + idx := e.pageIndexes[sourceKey] + e.pages[idx].LinksFound = accepted +} + +func (e *bfsEngine) enqueueLinks(sourceKey string, links []*url.URL) int { + sourceDepth := e.pageDepths[sourceKey] + accepted := 0 + for _, link := range links { + if e.stopReason != "" { + break + } + if len(links) > MaximumLinksPerPage && accepted >= MaximumLinksPerPage { + break + } + normalized := NormalizeURL(link) + key := URLKey(normalized) + if !HostAllowed(e.req.AllowedHosts, normalized.Hostname()) || !IsHTTP(normalized) { + continue + } + if e.visitedKeys[key] || e.queuedKeys[key] { + continue + } + nextDepth := sourceDepth + 1 + if nextDepth > e.req.MaxDepth { + e.reachedMaxDepth = true + continue + } + if len(e.queue) >= MaximumQueuedURLs { + e.stopReason = StopQueueLimit + break + } + e.queuedKeys[key] = true + e.pageDepths[key] = nextDepth + e.queue = append(e.queue, queueItem{url: normalized, depth: nextDepth}) + accepted++ + } + return accepted +} + +func newHTTPClient(proxy *ProxyConfig) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + if proxy != nil && proxy.Host != "" { + proxyURL, _ := url.Parse(fmt.Sprintf("http://%s:%d", proxy.Host, proxy.Port)) + transport.Proxy = http.ProxyURL(proxyURL) + if proxy.Token != "" { + transport.ProxyConnectHeader = http.Header{ + "X-Derrick-Crawler-Token": []string{proxy.Token}, + } + } + } + return &http.Client{ + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: 20 * time.Second, + } +} + +func (e *bfsEngine) fetch(ctx context.Context, start *url.URL) (body string, status int, contentType string, final *url.URL, err error) { + e.waitForSpacing() + current := start + redirected := map[string]bool{URLKey(start): true} + + for i := 0; i <= MaximumRedirectsPerPage; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, current.String(), nil) + if err != nil { + return "", 0, "", start, err + } + req.Header.Set("User-Agent", "DerrickWebCrawler/1") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Accept-Encoding", "identity") + if e.proxy != nil && e.proxy.Token != "" { + req.Header.Set("X-Derrick-Crawler-Token", e.proxy.Token) + } + + resp, err := e.client.Do(req) + if err != nil { + return "", 0, "", start, err + } + limited := io.LimitReader(resp.Body, int64(MaximumPageBytes)) + data, err := io.ReadAll(limited) + resp.Body.Close() + if err != nil { + return "", 0, "", start, err + } + text := string(data) + ct := resp.Header.Get("Content-Type") + + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return text, resp.StatusCode, ct, current, nil + } + + location := resp.Header.Get("Location") + if location == "" { + return text, resp.StatusCode, ct, current, nil + } + redirectURL, err := url.Parse(location) + if err != nil { + return "", 0, "", start, fmt.Errorf("redirect left the start URL origin") + } + redirectURL = current.ResolveReference(redirectURL) + if !IsHTTP(redirectURL) { + return "", 0, "", start, fmt.Errorf("redirect left the start URL origin") + } + host := strings.ToLower(strings.TrimSpace(redirectURL.Hostname())) + if host == "" { + return "", 0, "", start, fmt.Errorf("redirect left the start URL origin") + } + e.req.AllowedHosts[host] = true + normalized := NormalizeURL(redirectURL) + key := URLKey(normalized) + if redirected[key] { + return "", 0, "", start, fmt.Errorf("redirect loop detected") + } + redirected[key] = true + current = normalized + e.waitForSpacing() + } + return "", 0, "", start, fmt.Errorf("redirect limit reached") +} + +func (e *bfsEngine) waitForSpacing() { + if e.lastRequestAt.IsZero() { + e.lastRequestAt = time.Now() + return + } + elapsed := time.Since(e.lastRequestAt) + minimum := time.Duration(MinRequestDelayMS) * time.Millisecond + if elapsed < minimum { + time.Sleep(minimum - elapsed) + } + e.lastRequestAt = time.Now() +} + +func extractContent(body string, contentType string, base *url.URL) extractedPage { + lower := strings.ToLower(contentType) + if strings.Contains(lower, "html") || strings.Contains(lower, "xhtml") { + return extractHTML(body) + } + prefix := body + if len(prefix) > MaximumExtractedText { + prefix = prefix[:MaximumExtractedText] + } + return extractedPage{title: "", text: prefix, isHTML: false} +} + +func extractHTML(body string) extractedPage { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + return extractedPage{text: body, isHTML: true} + } + doc.Find("script, style, noscript, template, svg").Remove() + title := strings.TrimSpace(doc.Find("title").First().Text()) + text := strings.TrimSpace(doc.Find("body").Text()) + if text == "" { + text = strings.TrimSpace(doc.Text()) + } + return extractedPage{title: title, text: text, isHTML: true} +} + +func parseLinks(body string, base *url.URL) []*url.URL { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + return nil + } + var links []*url.URL + seen := map[string]bool{} + doc.Find("a[href]").Each(func(_ int, s *goquery.Selection) { + href, ok := s.Attr("href") + if !ok || strings.TrimSpace(href) == "" { + return + } + parsed, err := url.Parse(href) + if err != nil { + return + } + resolved := base.ResolveReference(parsed) + resolved.Fragment = "" + resolved.RawFragment = "" + resolved = NormalizeURL(resolved) + key := URLKey(resolved) + if seen[key] { + return + } + seen[key] = true + links = append(links, resolved) + }) + return links +} + +func clipText(text string, limit int) string { + if len(text) <= limit { + return text + } + return text[:limit] +} diff --git a/workers/go/internal/crawler/safety.go b/workers/go/internal/crawler/safety.go new file mode 100644 index 00000000..4a7f80b5 --- /dev/null +++ b/workers/go/internal/crawler/safety.go @@ -0,0 +1,33 @@ +package crawler + +import "strings" + +var blockedPatterns = []struct { + substr string + reason string +}{ + {"ddos", "distributed denial-of-service behavior is not allowed."}, + {"denial of service", "denial-of-service behavior is not allowed."}, + {"dos attack", "denial-of-service behavior is not allowed."}, + {"flood", "flooding a website is not allowed."}, + {"hammer", "repeatedly hammering a website is not allowed."}, + {"stress test", "load or stress testing a third-party website is not allowed."}, + {"load test", "load or stress testing a third-party website is not allowed."}, + {"port scan", "port scanning is not a web crawl."}, + {"brute force", "brute-force activity is not allowed."}, + {"infinite loop", "unbounded or infinite crawling is not allowed."}, + {"loop forever", "unbounded or infinite crawling is not allowed."}, + {"crawl forever", "unbounded or infinite crawling is not allowed."}, + {"never stop crawling", "unbounded or infinite crawling is not allowed."}, + {"unbounded crawl", "unbounded or infinite crawling is not allowed."}, +} + +func MaliciousGoalReason(goal string) string { + normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(goal), "-", " "), "_", " ") + for _, p := range blockedPatterns { + if strings.Contains(normalized, p.substr) { + return p.reason + } + } + return "" +} diff --git a/workers/go/internal/crawler/types.go b/workers/go/internal/crawler/types.go new file mode 100644 index 00000000..98f33e50 --- /dev/null +++ b/workers/go/internal/crawler/types.go @@ -0,0 +1,69 @@ +package crawler + +// Wire types mirror worker-product.schema.json (web_crawler_result) in Structure Contract. + +const ( + DefaultMaxPages = 10 + MaximumMaxPages = 100 + DefaultMaxDepth = 2 + MaximumMaxDepth = 5 + DefaultTimeoutSeconds = 120 + MaximumTimeoutSeconds = 900 + MaximumPageBytes = 1_048_576 + MaximumTotalBytes = 10 * 1_048_576 + MaximumLinksPerPage = 200 + MaximumQueuedURLs = 400 + MaximumExtractedText = 12_000 + MaximumOutputChars = 500_000 + MaximumRedirectsPerPage = 5 + MinRequestDelayMS = 150 +) + +type Request struct { + StartURL string `json:"start_url"` + Goal string `json:"goal"` + MaxPages int `json:"max_pages"` + MaxDepth int `json:"max_depth"` + TimeoutSeconds int `json:"timeout_seconds"` + AllowedHosts []string `json:"allowed_hosts,omitempty"` +} + +type Page struct { + URL string `json:"url"` + Depth int `json:"depth"` + StatusCode int `json:"status_code"` + ContentType *string `json:"content_type,omitempty"` + Title string `json:"title"` + Text string `json:"text"` + LinksFound int `json:"links_found"` +} + +type StopReason string + +const ( + StopCompleted StopReason = "completed" + StopMaxPages StopReason = "max_pages" + StopMaxDepth StopReason = "max_depth" + StopTimeout StopReason = "timeout" + StopTotalBytes StopReason = "total_bytes" + StopQueueLimit StopReason = "queue_limit" + StopCancelled StopReason = "cancelled" + StopBlocked StopReason = "blocked" +) + +type Result struct { + OK bool `json:"ok"` + StartURL string `json:"start_url"` + Pages []Page `json:"pages"` + StopReason StopReason `json:"stop_reason"` + RequestsMade int `json:"requests_made"` + BytesRead int `json:"bytes_read"` + Truncated bool `json:"truncated"` + Diagnostics []string `json:"diagnostics"` +} + +type ProxyConfig struct { + Host string + Port int + Token string +} diff --git a/workers/go/internal/crawler/url.go b/workers/go/internal/crawler/url.go new file mode 100644 index 00000000..b3c1400d --- /dev/null +++ b/workers/go/internal/crawler/url.go @@ -0,0 +1,87 @@ +package crawler + +import ( + "net" + "net/url" + "strings" +) + +func IsHTTP(u *url.URL) bool { + scheme := strings.ToLower(u.Scheme) + return scheme == "http" || scheme == "https" +} + +func NormalizeURL(u *url.URL) *url.URL { + if u == nil { + return u + } + clone := *u + clone.Scheme = strings.ToLower(clone.Scheme) + clone.Host = strings.ToLower(clone.Host) + clone.Fragment = "" + if clone.Path == "" { + clone.Path = "/" + } + if clone.Port() != "" { + if (clone.Scheme == "http" && clone.Port() == "80") || + (clone.Scheme == "https" && clone.Port() == "443") { + clone.Host = clone.Hostname() + } + } + return &clone +} + +func URLKey(u *url.URL) string { + return NormalizeURL(u).String() +} + +func ParseStartURL(raw string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, err + } + if !IsHTTP(u) { + return nil, errInvalidStartURL + } + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + if host == "" { + return nil, errInvalidStartURL + } + if u.User != nil { + return nil, errInvalidStartURL + } + return NormalizeURL(u), nil +} + +func ResolvedAllowedHosts(startHost string, explicit []string) map[string]bool { + hosts := map[string]bool{strings.ToLower(startHost): true} + for _, h := range explicit { + n := strings.ToLower(strings.TrimSpace(h)) + if n != "" { + hosts[n] = true + } + } + return hosts +} + +func HostAllowed(hosts map[string]bool, host string) bool { + return hosts[strings.ToLower(strings.TrimSpace(host))] +} + +func IsPrivateHost(host string) bool { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} + +var errInvalidStartURL = validationError("start_url must be an http or https URL without embedded credentials.") + +type validationError string + +func (e validationError) Error() string { return string(e) } diff --git a/workers/go/internal/crawler/validate.go b/workers/go/internal/crawler/validate.go new file mode 100644 index 00000000..ae08f384 --- /dev/null +++ b/workers/go/internal/crawler/validate.go @@ -0,0 +1,66 @@ +package crawler + +import ( + "net/url" + "strings" +) + +type ValidatedRequest struct { + StartURL *url.URL + Goal string + MaxPages int + MaxDepth int + TimeoutSeconds int + AllowedHosts map[string]bool +} + +func Validate(req Request) (ValidatedRequest, error) { + goal := strings.TrimSpace(req.Goal) + if goal == "" { + return ValidatedRequest{}, validationError("A crawl goal is required.") + } + if len(goal) > 2000 { + return ValidatedRequest{}, validationError("The crawl goal is too long.") + } + if reason := MaliciousGoalReason(goal); reason != "" { + return ValidatedRequest{}, validationError("Crawl blocked: " + reason) + } + + maxPages := req.MaxPages + if maxPages == 0 { + maxPages = DefaultMaxPages + } + if maxPages < 1 || maxPages > MaximumMaxPages { + return ValidatedRequest{}, validationError("max_pages must be between 1 and 100.") + } + + maxDepth := req.MaxDepth + if maxDepth == 0 { + maxDepth = DefaultMaxDepth + } + if maxDepth < 0 || maxDepth > MaximumMaxDepth { + return ValidatedRequest{}, validationError("max_depth must be between 0 and 5.") + } + + timeout := req.TimeoutSeconds + if timeout == 0 { + timeout = DefaultTimeoutSeconds + } + if timeout < 1 || timeout > MaximumTimeoutSeconds { + return ValidatedRequest{}, validationError("timeout_seconds must be between 1 and 900.") + } + + start, err := ParseStartURL(req.StartURL) + if err != nil { + return ValidatedRequest{}, err + } + + return ValidatedRequest{ + StartURL: start, + Goal: goal, + MaxPages: maxPages, + MaxDepth: maxDepth, + TimeoutSeconds: timeout, + AllowedHosts: ResolvedAllowedHosts(start.Hostname(), req.AllowedHosts), + }, nil +} diff --git a/workers/go/internal/extractor/engine.go b/workers/go/internal/extractor/engine.go new file mode 100644 index 00000000..6e86a4f4 --- /dev/null +++ b/workers/go/internal/extractor/engine.go @@ -0,0 +1,383 @@ +package extractor + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/xuri/excelize/v2" +) + +func Run(req Request, inputDir, outputDir string) Result { + if len(req.Files) == 0 { + return errorResult(req.Operation, "Choose at least one attached file.") + } + if len(req.Files) > MaximumFiles { + return errorResult(req.Operation, fmt.Sprintf("You can process at most %d files.", MaximumFiles)) + } + if req.OutputFormat == "" { + req.OutputFormat = FormatMarkdown + } + if req.Operation == "" { + req.Operation = OperationExtract + } + + var files []FileResult + var diagnostics []string + previewBudget := MaximumTotalPreviewChars + + for _, name := range req.Files { + safeName, err := validatedFilename(name) + if err != nil { + files = append(files, FileResult{ + InputName: name, + Kind: kindFor(name), + Error: strPtr(err.Error()), + }) + continue + } + inputPath := filepath.Join(inputDir, safeName) + if _, err := os.Stat(inputPath); err != nil { + msg := fmt.Sprintf("%s was not found in /data/in.", safeName) + files = append(files, FileResult{ + InputName: safeName, + Kind: kindFor(safeName), + Error: &msg, + }) + continue + } + + processed, err := process(inputPath, req.Operation, req.OutputFormat) + if err != nil { + msg := err.Error() + diagnostics = append(diagnostics, msg) + files = append(files, FileResult{ + InputName: safeName, + Kind: kindFor(safeName), + Error: &msg, + }) + continue + } + outPath := filepath.Join(outputDir, processed.OutputName) + if err := os.WriteFile(outPath, processed.Data, 0o644); err != nil { + msg := err.Error() + diagnostics = append(diagnostics, msg) + files = append(files, FileResult{ + InputName: safeName, + Kind: processed.Kind, + Error: &msg, + }) + continue + } + preview := clipPreview(processed.Preview, &previewBudget) + files = append(files, FileResult{ + InputName: safeName, + OutputName: &processed.OutputName, + Kind: processed.Kind, + ByteCount: len(processed.Data), + Preview: preview, + }) + } + + ok := false + for _, f := range files { + if f.Error == nil { + ok = true + break + } + } + return Result{ + OK: ok, + Operation: req.Operation, + Files: files, + Diagnostics: diagnostics, + } +} + +func errorResult(op Operation, message string) Result { + return Result{ + OK: false, + Operation: op, + Files: []FileResult{}, + Diagnostics: []string{message}, + } +} + +type processedFile struct { + OutputName string + Kind string + Data []byte + Preview string +} + +func validatedFilename(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" || strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") || + strings.Contains(trimmed, "\x00") || trimmed == "." || trimmed == ".." { + return "", fmt.Errorf("%s is not a safe file name.", name) + } + base := filepath.Base(trimmed) + if base != trimmed { + return "", fmt.Errorf("%s is not a safe file name.", name) + } + return base, nil +} + +func kindFor(filename string) string { + return strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".") +} + +func process(path string, operation Operation, format OutputFormat) (processedFile, error) { + filename := filepath.Base(path) + fileKind := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".") + extracted, err := extractText(path, fileKind) + if err != nil { + return processedFile{}, err + } + + if operation == OperationExtract { + ext := "md" + body := extracted + if format == FormatTXT { + ext = "txt" + } else { + body = fmt.Sprintf("# %s\n\n%s\n", filename, extracted) + } + return processedFile{ + OutputName: replaceExt(filename, ext), + Kind: fileKind, + Data: []byte(body), + Preview: extracted, + }, nil + } + + switch format { + case FormatXLSX: + if fileKind != "csv" && fileKind != "tsv" && fileKind != "txt" { + return processedFile{}, fmt.Errorf("That conversion is not supported for %s files.", fileKind) + } + csv := extracted + if fileKind == "tsv" { + csv = strings.ReplaceAll(extracted, "\t", ",") + } + data, err := csvToXLSX(csv) + if err != nil { + return processedFile{}, err + } + return processedFile{ + OutputName: replaceExt(filename, "xlsx"), + Kind: fileKind, + Data: data, + Preview: extracted, + }, nil + case FormatCSV: + if fileKind == "xlsx" { + raw, err := os.ReadFile(path) + if err != nil { + return processedFile{}, err + } + csv, err := xlsxToCSV(raw) + if err != nil { + return processedFile{}, err + } + return processedFile{ + OutputName: replaceExt(filename, "csv"), + Kind: fileKind, + Data: []byte(csv), + Preview: csv, + }, nil + } + if fileKind != "csv" && fileKind != "tsv" && fileKind != "txt" { + return processedFile{}, fmt.Errorf("That conversion is not supported for %s files.", fileKind) + } + return processedFile{ + OutputName: replaceExt(filename, "csv"), + Kind: fileKind, + Data: []byte(extracted), + Preview: extracted, + }, nil + default: + ext := "md" + body := extracted + if format == FormatTXT { + ext = "txt" + } else { + body = fmt.Sprintf("# %s\n\n%s\n", filename, extracted) + } + return processedFile{ + OutputName: replaceExt(filename, ext), + Kind: fileKind, + Data: []byte(body), + Preview: extracted, + }, nil + } +} + +func extractText(path, kind string) (string, error) { + switch kind { + case "pdf": + return pdfToText(path) + case "docx": + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + return docxToText(raw) + case "xlsx": + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + return xlsxToCSV(raw) + case "html", "htm": + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + return htmlToText(string(raw)), nil + default: + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(raw), nil + } +} + +func pdfToText(path string) (string, error) { + if _, err := exec.LookPath("pdftotext"); err != nil { + return "", fmt.Errorf("PDF text extraction is unavailable in this image.") + } + out, err := exec.Command("pdftotext", "-layout", path, "-").Output() + if err != nil { + return "", fmt.Errorf("PDF text extraction failed.") + } + return string(out), nil +} + +func docxToText(data []byte) (string, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", err + } + for _, f := range reader.File { + if f.Name != "word/document.xml" { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + return parseDocxXML(rc) + } + return "", fmt.Errorf("document.xml missing from docx") +} + +func parseDocxXML(r io.Reader) (string, error) { + decoder := xml.NewDecoder(r) + var parts []string + for { + tok, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "t" { + var text string + if err := decoder.DecodeElement(&text, &se); err != nil { + return "", err + } + if text != "" { + parts = append(parts, text) + } + } + } + return strings.Join(parts, ""), nil +} + +func xlsxToCSV(data []byte) (string, error) { + book, err := excelize.OpenReader(bytes.NewReader(data)) + if err != nil { + return "", err + } + sheets := book.GetSheetList() + if len(sheets) == 0 { + return "", nil + } + rows, err := book.GetRows(sheets[0]) + if err != nil { + return "", err + } + var lines []string + for _, row := range rows { + lines = append(lines, strings.Join(row, ",")) + } + return strings.Join(lines, "\n"), nil +} + +func csvToXLSX(csv string) ([]byte, error) { + book := excelize.NewFile() + sheet := book.GetSheetName(0) + lines := strings.Split(csv, "\n") + for i, line := range lines { + if line == "" { + continue + } + cells := strings.Split(line, ",") + for j, cell := range cells { + cellName, _ := excelize.CoordinatesToCellName(j+1, i+1) + _ = book.SetCellValue(sheet, cellName, cell) + } + } + buf, err := book.WriteToBuffer() + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func htmlToText(html string) string { + var out strings.Builder + inTag := false + for _, r := range html { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + out.WriteRune(' ') + case !inTag: + out.WriteRune(r) + } + } + return strings.Join(strings.Fields(out.String()), " ") +} + +func replaceExt(filename, ext string) string { + base := strings.TrimSuffix(filename, filepath.Ext(filename)) + return base + "." + ext +} + +func clipPreview(text string, remaining *int) *string { + if *remaining <= 0 { + return nil + } + limit := min(MaximumPreviewCharacters, *remaining) + preview := text + if len(preview) > limit { + preview = preview[:limit] + "…" + } + *remaining -= len(preview) + return &preview +} + +func strPtr(s string) *string { return &s } diff --git a/workers/go/internal/extractor/types.go b/workers/go/internal/extractor/types.go new file mode 100644 index 00000000..6463359a --- /dev/null +++ b/workers/go/internal/extractor/types.go @@ -0,0 +1,48 @@ +// Wire types mirror worker-product.schema.json (file_extractor_result) in Structure Contract. +package extractor + +const ( + InputDirectory = "/data/in" + OutputDirectory = "/data/out" + MaximumFiles = 5 + MaximumPreviewCharacters = 8000 + MaximumTotalPreviewChars = 32000 +) + +type Operation string + +const ( + OperationExtract Operation = "extract" + OperationConvert Operation = "convert" +) + +type OutputFormat string + +const ( + FormatMarkdown OutputFormat = "markdown" + FormatTXT OutputFormat = "txt" + FormatCSV OutputFormat = "csv" + FormatXLSX OutputFormat = "xlsx" +) + +type Request struct { + Operation Operation `json:"operation"` + OutputFormat OutputFormat `json:"output_format"` + Files []string `json:"files"` +} + +type FileResult struct { + InputName string `json:"input_name"` + OutputName *string `json:"output_name,omitempty"` + Kind string `json:"kind"` + ByteCount int `json:"byte_count"` + Preview *string `json:"preview,omitempty"` + Error *string `json:"error,omitempty"` +} + +type Result struct { + OK bool `json:"ok"` + Operation Operation `json:"operation"` + Files []FileResult `json:"files"` + Diagnostics []string `json:"diagnostics"` +} From 6980ece2d18cadf7027b064a2ce2d1980ee9c76e Mon Sep 17 00:00:00 2001 From: David Choi Date: Wed, 9 Sep 2026 22:25:01 -0400 Subject: [PATCH 02/17] improve app init time --- .../DockerProductImagePrewarmer.swift | 20 +------ ui/JobKeepAlive/DaemonModuleBootstrap.swift | 13 ---- .../MCPServiceDockerHelperRunner.swift | 4 +- .../Support/AppBootstrapStatus.swift | 25 ++++---- .../DockerRunner/XPCDockerRunner.swift | 59 +++++++++++++++---- ui/ui/Services/AgentServiceClient.swift | 30 +++++++--- ui/ui/Views/ContentView.swift | 50 +++++++++------- ui/uiTests/AppBootstrapStatusTests.swift | 10 ++++ 8 files changed, 130 insertions(+), 81 deletions(-) diff --git a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift index fcff866b..8eca7de5 100644 --- a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift +++ b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift @@ -86,30 +86,14 @@ public enum DockerProductImagePrewarmer: Sendable { } } -/// One in-flight worker image build per process. +/// Legacy alias gate — delegates to `WorkerImageGate` so crawler/script paths share one build. public actor WebCrawlerImageGate { public static let shared = WebCrawlerImageGate() - private var inFlight: Task? - public init() {} public func ensureReady(executor: @escaping DockerCLIExecutor) async throws { - if let inFlight { - try await inFlight.value - return - } - let task = Task { - try await DockerProductImagePrewarmer.ensureWorkerImage(executor: executor) - } - inFlight = task - do { - try await task.value - inFlight = nil - } catch { - inFlight = nil - throw error - } + try await WorkerImageGate.shared.ensureReady(executor: executor) } } diff --git a/ui/JobKeepAlive/DaemonModuleBootstrap.swift b/ui/JobKeepAlive/DaemonModuleBootstrap.swift index dfae72f2..638ec769 100644 --- a/ui/JobKeepAlive/DaemonModuleBootstrap.swift +++ b/ui/JobKeepAlive/DaemonModuleBootstrap.swift @@ -76,9 +76,6 @@ enum DaemonModuleBootstrap { Task { await prewarmGuestRuntimeImage() } - Task { - await startWebCrawlerImageInBackground() - } } catch { fputs("[derrickd] MCP module bootstrap failed: \(error.localizedDescription)\n", stderr) } @@ -127,14 +124,4 @@ enum DaemonModuleBootstrap { } } - /// Does not block jobs or chat. A crawl that arrives during this build waits on the same task. - private static func startWebCrawlerImageInBackground() async { - guard DerrickProcessRole.isDaemon else { return } - do { - try await MCPServiceDockerHelperRunner.shared.ensureWebCrawlerImage() - fputs("[derrickd] web crawler image ready\n", stderr) - } catch { - fputs("[derrickd] web crawler image background build skipped: \(error.localizedDescription)\n", stderr) - } - } } diff --git a/ui/MCPService/MCPServiceDockerHelperRunner.swift b/ui/MCPService/MCPServiceDockerHelperRunner.swift index 2106122b..b623facf 100644 --- a/ui/MCPService/MCPServiceDockerHelperRunner.swift +++ b/ui/MCPService/MCPServiceDockerHelperRunner.swift @@ -68,9 +68,9 @@ final class MCPServiceDockerHelperRunner: @unchecked Sendable { try await WorkerImageGate.shared.ensureReady(executor: makeStdinCLIExecutor()) } - /// Build the crawler image in the background. Joins an in-flight build if one exists. + /// Legacy alias — same worker image as `prewarmGuestRuntime`. func ensureWebCrawlerImage() async throws { - try await WebCrawlerImageGate.shared.ensureReady(executor: makeStdinCLIExecutor()) + try await prewarmGuestRuntime() } var hasPeerEndpoint: Bool { diff --git a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift index 5be15662..c75b9b42 100644 --- a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift +++ b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift @@ -144,16 +144,11 @@ final class AppBootstrapStatus: ObservableObject { debugLog("[bootstrap] ignore phase=\(phase.rawValue) (already ready): \(message)") return } - // Parallel bootstrap: once we move past Docker prep, do not let guest-image - // prewarm overwrite daemon/database status in the modal. - if phase == .checkingDocker || phase == .preparingImage || phase == .verifyingEnvironment { - switch self.phase { - case .connectingHelper, .loadingSession: - debugLog("[bootstrap] ignore docker phase=\(phase.rawValue) while \(self.phase.rawValue): \(message)") - return - default: - break - } + // Parallel bootstrap: keep the highest-priority in-flight step visible (daemon connect + // beats "Opening local database…" while XPC is still retrying). + if isInitializing, Self.phasePriority(phase) < Self.phasePriority(self.phase) { + debugLog("[bootstrap] ignore lower-priority phase=\(phase.rawValue) while \(self.phase.rawValue): \(message)") + return } // Don't let a cancelled re-entrant task demote ready via failed paths above. self.phase = phase @@ -226,6 +221,16 @@ final class AppBootstrapStatus: ObservableObject { debugLog("[bootstrap] failure modal dismissed") } + private static func phasePriority(_ phase: Phase) -> Int { + switch phase { + case .connectingHelper: return 4 + case .checkingDocker: return 3 + case .preparingImage, .verifyingEnvironment: return 2 + case .loadingSession: return 1 + default: return 0 + } + } + enum FailureRecovery: Equatable, Sendable { case none case retryDaemon diff --git a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift index 6c5f93e7..d1177e77 100644 --- a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift +++ b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift @@ -164,11 +164,13 @@ public final class XPCDockerRunner: @unchecked Sendable { public static let shared = XPCDockerRunner() private static let serviceName = "derrick.ui.DockerRunnerHelper" - private static let prewarmWaitCeilingSeconds: UInt64 = 1_200 + private static let dockerReachableWaitCeilingSeconds: UInt64 = 60 + private static let imagePrewarmWaitCeilingSeconds: UInt64 = 1_200 private let connection: NSXPCConnection private let appLogSink: XPCAppLogSink - private let prewarmState = PrewarmState() + private let dockerReachableState = PrewarmState() + private let imagePrewarmState = PrewarmState() public init() { let sink = XPCAppLogSink() @@ -206,11 +208,35 @@ public final class XPCDockerRunner: @unchecked Sendable { } } + /// Waits until Docker Desktop responds. Does not wait for the worker image build. + public func waitUntilDockerReachable() async throws { + if dockerReachableState.isCompleted() { + return + } + if let failure = dockerReachableState.failureIfCompleted() { + throw failure + } + let timeout = NSError( + domain: "XPCDockerRunner", + code: 504, + userInfo: [ + NSLocalizedDescriptionKey: + "Docker Desktop did not respond within \(Self.dockerReachableWaitCeilingSeconds)s." + ] + ) + try await dockerReachableState.wait( + timeoutNanoseconds: Self.dockerReachableWaitCeilingSeconds * 1_000_000_000, + timeoutError: timeout + ) + } + + /// Waits until the worker image is built or verified. Joins an in-flight background build. public func waitUntilPrewarmed() async throws { - if prewarmState.isCompleted() { + try await waitUntilDockerReachable() + if imagePrewarmState.isCompleted() { return } - if let failure = prewarmState.failureIfCompleted() { + if let failure = imagePrewarmState.failureIfCompleted() { throw failure } let timeout = NSError( @@ -218,11 +244,11 @@ public final class XPCDockerRunner: @unchecked Sendable { code: 504, userInfo: [ NSLocalizedDescriptionKey: - "Guest runtime setup timed out after \(Self.prewarmWaitCeilingSeconds)s." + "Worker image setup timed out after \(Self.imagePrewarmWaitCeilingSeconds)s." ] ) - try await prewarmState.wait( - timeoutNanoseconds: Self.prewarmWaitCeilingSeconds * 1_000_000_000, + try await imagePrewarmState.wait( + timeoutNanoseconds: Self.imagePrewarmWaitCeilingSeconds * 1_000_000_000, timeoutError: timeout ) } @@ -280,14 +306,27 @@ public final class XPCDockerRunner: @unchecked Sendable { ] ) } + dockerReachableState.markCompleted() + Task { + await prewarmWorkerImage() + } + } catch { + debugLog("Docker reachability check failed: \(error.localizedDescription)") + dockerReachableState.markFailed(error) + imagePrewarmState.markFailed(error) + } + } + + private func prewarmWorkerImage() async { + do { await reportBootstrap(phase: .preparingImage, message: "Preparing worker image…") let executor = makeDockerExecutor() try await WorkerImageGate.shared.ensureReady(executor: executor) - prewarmState.markCompleted() + imagePrewarmState.markCompleted() await reportBootstrap(phase: .verifyingEnvironment, message: "Worker image ready.") } catch { - debugLog("Guest runtime prewarming failed: \(error.localizedDescription)") - prewarmState.markFailed(error) + debugLog("Worker image prewarm failed: \(error.localizedDescription)") + imagePrewarmState.markFailed(error) } } diff --git a/ui/ui/Services/AgentServiceClient.swift b/ui/ui/Services/AgentServiceClient.swift index 339fb52d..73b1afd8 100644 --- a/ui/ui/Services/AgentServiceClient.swift +++ b/ui/ui/Services/AgentServiceClient.swift @@ -74,7 +74,10 @@ public final class AgentServiceClient: @unchecked Sendable { /// Connect (launch-on-demand), bootstrap DB/logs, return health. Retries a few times. /// Use at app startup (and when `ensureReadyForTurn` finds the link dead). - public func ensureUpAndHealth(retries: Int = 3) async throws -> ServiceHealthReport { + public func ensureUpAndHealth( + retries: Int = 3, + verifyHealth: Bool = true + ) async throws -> ServiceHealthReport { var lastError: Error? for attempt in 0.. NSXPCListenerEndpoint? { bootstrapStatus.update(phase: .checkingDocker, message: "Starting Docker runtime…") _ = XPCDockerRunner.shared - try await XPCDockerRunner.shared.waitUntilPrewarmed() + try await XPCDockerRunner.shared.waitUntilDockerReachable() do { return try await XPCDockerRunner.shared.fetchPeerListenerEndpoint() } catch { diff --git a/ui/uiTests/AppBootstrapStatusTests.swift b/ui/uiTests/AppBootstrapStatusTests.swift index 7fd7994e..a91072d0 100644 --- a/ui/uiTests/AppBootstrapStatusTests.swift +++ b/ui/uiTests/AppBootstrapStatusTests.swift @@ -174,6 +174,16 @@ import Testing #expect(status.phase == .ready) } + @MainActor + @Test func loadingSessionDoesNotOverwriteConnectingHelper() { + let status = freshStatus() + #expect(status.beginLoadingSession()) + status.update(phase: .connectingHelper, message: "Connecting to Derrick daemon…") + status.update(phase: .loadingSession, message: "Opening local database…") + #expect(status.phase == .connectingHelper) + #expect(status.statusMessage == "Connecting to Derrick daemon…") + } + @MainActor @Test func cancelClearsInProgressModal() { let status = freshStatus() From aaca55223b1a6f53f07d692ba13ae0296192bbd7 Mon Sep 17 00:00:00 2001 From: David Choi Date: Wed, 9 Sep 2026 22:42:34 -0400 Subject: [PATCH 03/17] consolidate api key resolver --- ui/JobService/MessagingAgentTurnClient.swift | 10 +----- ui/MCPService/MCPServiceScriptReviewer.swift | 23 +++++++++---- ui/MCPService/MCPServiceToolHost.swift | 4 --- .../Conversation/ConversationModel.swift | 23 +++---------- .../Conversation/ProfileDelegateRunner.swift | 2 +- .../LLMProviderCredentialGate.swift | 32 +++++++++++++++-- .../Services/XPCConversationToolClient.swift | 5 +-- .../Support/LLM/ConfigureScriptReviewer.swift | 17 ++-------- .../Support/LLM/SummarizerConfig.swift | 10 +----- .../Support/PluginFactoryModels.swift | 34 ++----------------- ui/ui/Views/ContentView.swift | 10 ++---- ui/uiTests/uiTests.swift | 2 ++ 12 files changed, 64 insertions(+), 108 deletions(-) diff --git a/ui/JobService/MessagingAgentTurnClient.swift b/ui/JobService/MessagingAgentTurnClient.swift index 461a1e49..12e260b7 100644 --- a/ui/JobService/MessagingAgentTurnClient.swift +++ b/ui/JobService/MessagingAgentTurnClient.swift @@ -11,7 +11,7 @@ enum MessagingAgentTurnClient { let profile = try await resolveProfile(handle: route.profileHandle, repository: repository) let model = (try? JSONDecoder().decode(LLMModelChoice.self, from: profile.modelJSON)) ?? .defaultHelperModel - let apiKey = await resolveAPIKey(for: model) ?? "" + let apiKey = await LLMProviderCredentialGate.resolveAPIKey(for: model) ?? "" let profileContextJSON = try JSONEncoder().encode(AgentProfileTurnContext(profile: profile)) let sessionID = MessagingAgentSessionID.make( pluginID: route.pluginID, @@ -60,14 +60,6 @@ enum MessagingAgentTurnClient { throw MessagingAgentTurnClientError.profileUnavailable(handle) } - @MainActor - private static func resolveAPIKey(for model: LLMModelChoice) -> String? { - AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ) - } - private static func sendConnectorMessage( route: MessagingAgentRoute, text: String, diff --git a/ui/MCPService/MCPServiceScriptReviewer.swift b/ui/MCPService/MCPServiceScriptReviewer.swift index 9a1e4986..7da7ba47 100644 --- a/ui/MCPService/MCPServiceScriptReviewer.swift +++ b/ui/MCPService/MCPServiceScriptReviewer.swift @@ -20,9 +20,8 @@ struct MCPServiceScriptReviewer: ScriptReviewer { } func review(_ args: ScriptExecutionArguments) async throws -> ScriptReviewOutcome { - guard let apiKey = MCPServiceCallContext.shared.helperAPIKey, - !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { + let selected = resolveSelectedModel() + guard let apiKey = await resolveAPIKey(for: selected) else { throw NSError( domain: "MCPService", code: 404, @@ -30,7 +29,6 @@ struct MCPServiceScriptReviewer: ScriptReviewer { ) } - let selected = resolveSelectedModel() do { return try await review(args, model: selected, apiKey: apiKey) } catch { @@ -38,7 +36,7 @@ struct MCPServiceScriptReviewer: ScriptReviewer { "[MCPService] reviewer model \(selected.label) failed: \(error.localizedDescription); trying defaults\n", stderr ) - if let fallback = await fallbackReview(args: args, apiKey: apiKey, excluding: selected) { + if let fallback = await fallbackReview(args: args, excluding: selected) { return fallback } throw error @@ -108,12 +106,12 @@ struct MCPServiceScriptReviewer: ScriptReviewer { private func fallbackReview( args: ScriptExecutionArguments, - apiKey: String, excluding: ReviewerModel ) async -> ScriptReviewOutcome? { var candidates: [ReviewerModel] = [Self.defaultModel, Self.secondaryDefault] candidates.removeAll { $0 == excluding } for candidate in candidates { + guard let apiKey = await resolveAPIKey(for: candidate) else { continue } do { let outcome = try await review(args, model: candidate, apiKey: apiKey) fputs("[MCPService] fallback reviewer succeeded model=\(candidate.label)\n", stderr) @@ -127,6 +125,19 @@ struct MCPServiceScriptReviewer: ScriptReviewer { } return nil } + + private func resolveAPIKey(for model: ReviewerModel) async -> String? { + await LLMProviderCredentialGate.resolveAPIKey(for: llmModelChoice(from: model)) + } + + private func llmModelChoice(from model: ReviewerModel) -> LLMModelChoice { + switch model { + case .openai(let openAIModel): + return .openai(openAIModel) + case .gemini(let geminiModel): + return .gemini(geminiModel) + } + } } /// Process-wide slots for the current MCPService tool call (handlers may not inherit TaskLocal). diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index 8808e4cb..d3aa0aff 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -67,10 +67,6 @@ actor MCPServiceToolHost { await WorkflowProgressPublisher.publish(stage: "factory", message: progress) } }, - apiKeyProvider: { - MCPServiceCallContext.shared.helperAPIKey - ?? TurnProcessContext.effectiveAPIKey - } ) let made = try await MCPLocalBridge.make { server in await server.registerScriptExecutionTool( diff --git a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift index 95e02e85..e2ed82c1 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift @@ -266,7 +266,7 @@ final class ConversationModel { let interceptor = makeContentPolicyInterceptor() let orchestrator = self.orchestrator let workerModel = helperModelSettings.workerAgentModel - let workerApiKey = resolveAPIKey(for: workerModel, turnFallback: apiKey) ?? apiKey + let workerApiKey = await LLMProviderCredentialGate.resolveAPIKey(for: workerModel) ?? apiKey let effectiveModel: LLMModelChoice let effectiveThinking: ModelThinkingOption? @@ -593,21 +593,6 @@ final class ConversationModel { return DefaultPolicyInterceptor(policy: policy) } - /// API key for a helper/worker model: keychain/env for its provider, else the active turn key. - private func resolveAPIKey(for model: LLMModelChoice, turnFallback: String) -> String? { - if let key = AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ), !key.isEmpty { - return key - } - if let turnKey = TurnProcessContext.effectiveAPIKey, !turnKey.isEmpty { - return turnKey - } - let trimmed = turnFallback.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - /// In-process host for orchestration tools (`agents_*`, `jobs_*`). Not used for MCP effectors. /// `nonisolated`: tool handlers must not hop to MainActor while the turn awaits the MCP local bridge /// (that pattern deadlocks when runTurn is MainActor-isolated). @@ -727,13 +712,14 @@ final class ConversationModel { wakePrompt: wakePrompt, description: description ) + let providerAPIKey = await LLMProviderCredentialGate.resolveAPIKey(for: .defaultHelperModel) let request = try JobOrderBuilder.createJobRequest( from: input, principal: principal, source: .agent, sessionID: sessionID, agentID: agentID, - helperAPIKey: TurnProcessContext.effectiveAPIKey, + helperAPIKey: providerAPIKey, helperReviewerModelJSON: reviewerJSON ) debugLog("[jobs_create] calling JobService createJob…") @@ -787,13 +773,14 @@ final class ConversationModel { wakePrompt: wakePrompt, enabled: true ) + let providerAPIKey = await LLMProviderCredentialGate.resolveAPIKey(for: .defaultHelperModel) let request = try JobOrderBuilder.createScheduleRequest( from: input, principal: principal, source: .agent, sessionID: sessionID, agentID: agentID, - helperAPIKey: TurnProcessContext.effectiveAPIKey, + helperAPIKey: providerAPIKey, helperReviewerModelJSON: reviewerJSON ) let schedule = try await placer.createSchedule(request) diff --git a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift index 3a9f8666..38f040b8 100644 --- a/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift +++ b/ui/SharedAgentRuntime/Conversation/ProfileDelegateRunner.swift @@ -40,7 +40,7 @@ enum ProfileDelegateRunner { let thinking = profileContext.thinkingJSON.flatMap { try? JSONDecoder().decode(ModelThinkingOption.self, from: $0) } - let apiKey = TurnProcessContext.effectiveAPIKey ?? "" + let apiKey = await LLMProviderCredentialGate.resolveAPIKey(for: model) ?? "" let delegateSessionKey = MemorySessionKey( sessionID: sessionKey.sessionID, diff --git a/ui/SharedAgentRuntime/LLMProviderCredentialGate.swift b/ui/SharedAgentRuntime/LLMProviderCredentialGate.swift index 4dfecd82..58cb31df 100644 --- a/ui/SharedAgentRuntime/LLMProviderCredentialGate.swift +++ b/ui/SharedAgentRuntime/LLMProviderCredentialGate.swift @@ -1,14 +1,40 @@ import Foundation import Structure -/// Whether an LLM provider has a usable API key (Keychain or `.env`, per `AppSecretResolver`). +/// Provider credential checks and API key resolution (Keychain or `.env`, per `AppSecretResolver`). @MainActor enum LLMProviderCredentialGate { - static func hasAPIKey(for provider: LLMProviderChoice, resolver: AppSecretResolver) -> Bool { + /// One key per provider — the single resolution path for chat, reviewers, workers, and jobs. + static func resolveAPIKey( + for provider: LLMProviderChoice, + resolver: AppSecretResolver = AppSecretResolver() + ) -> String? { resolver.resolve( account: provider.secretAccount, environmentKeys: provider.apiKeyEnvironmentKeys - ) != nil + ) + } + + static func resolveAPIKey( + for model: LLMModelChoice, + resolver: AppSecretResolver = AppSecretResolver() + ) -> String? { + resolveAPIKey(for: model.provider, resolver: resolver) + } + + /// Off-main callers (AgentService, MCPService, jobs). + static func resolveAPIKey(for provider: LLMProviderChoice) async -> String? { + await MainActor.run { + resolveAPIKey(for: provider) + } + } + + static func resolveAPIKey(for model: LLMModelChoice) async -> String? { + await resolveAPIKey(for: model.provider) + } + + static func hasAPIKey(for provider: LLMProviderChoice, resolver: AppSecretResolver) -> Bool { + resolveAPIKey(for: provider, resolver: resolver) != nil } static func configuredProviders(resolver: AppSecretResolver) -> [LLMProviderChoice] { diff --git a/ui/SharedAgentRuntime/Services/XPCConversationToolClient.swift b/ui/SharedAgentRuntime/Services/XPCConversationToolClient.swift index e02829a9..dd097866 100644 --- a/ui/SharedAgentRuntime/Services/XPCConversationToolClient.swift +++ b/ui/SharedAgentRuntime/Services/XPCConversationToolClient.swift @@ -9,19 +9,16 @@ import Structure public struct XPCConversationToolClient: ConversationToolClient, Sendable { private let principal: ServicePrincipal private let agentsClient: MCPClient? - private let helperAPIKeyProvider: @Sendable () -> String? /// JSON `HelperModelWire` for MCP script security reviewer (from `LLMModelSettings`). private let helperReviewerModelJSONProvider: @Sendable () async -> String? public init( principal: ServicePrincipal, agentsClient: MCPClient? = nil, - helperAPIKeyProvider: @escaping @Sendable () -> String? = { TurnProcessContext.effectiveAPIKey }, helperReviewerModelJSONProvider: @escaping @Sendable () async -> String? = { nil } ) { self.principal = principal self.agentsClient = agentsClient - self.helperAPIKeyProvider = helperAPIKeyProvider self.helperReviewerModelJSONProvider = helperReviewerModelJSONProvider } @@ -85,7 +82,7 @@ public struct XPCConversationToolClient: ConversationToolClient, Sendable { principal: activePrincipal, toolName: name, argumentsJSON: argumentsJSON, - helperAPIKey: helperAPIKeyProvider(), + helperAPIKey: nil, helperReviewerModelJSON: reviewerModelJSON, pluginFactoryCreationActive: executionContextJSON != nil && TurnProcessContext.effectivePluginFactoryCreationActive, diff --git a/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift b/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift index 3b9552b6..035572df 100644 --- a/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift +++ b/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift @@ -21,7 +21,7 @@ actor ConfiguredScriptReviewer: ScriptReviewer { func review(_ args: ScriptExecutionArguments) async throws -> ScriptReviewOutcome { let selectedModel = await MainActor.run { settings.scriptReviewerModel } - guard let apiKey = await resolveAPIKey(for: selectedModel) else { + guard let apiKey = await LLMProviderCredentialGate.resolveAPIKey(for: selectedModel) else { await MainActor.run { debugLog( "Helper reviewer model \(selectedModel.helperDisplayName) unavailable; trying default helper reviewer." @@ -83,7 +83,7 @@ actor ConfiguredScriptReviewer: ScriptReviewer { if selectedModel == defaultModel { return nil } - guard let apiKey = await resolveAPIKey(for: defaultModel) else { + guard let apiKey = await LLMProviderCredentialGate.resolveAPIKey(for: defaultModel) else { return nil } @@ -111,17 +111,4 @@ actor ConfiguredScriptReviewer: ScriptReviewer { return try await reviewer.review(args) } } - - private func resolveAPIKey(for model: LLMModelChoice) async -> String? { - if let key = await MainActor.run(body: { - AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ) - }), !key.isEmpty { - return key - } - // AgentService XPC process cannot read the UI keychain; use the turn-supplied key. - return TurnProcessContext.effectiveAPIKey - } } diff --git a/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift b/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift index fe516fdb..8697e4d8 100644 --- a/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift +++ b/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift @@ -111,15 +111,7 @@ actor ConfiguredMemorySummarizer: MemorySummarizer { } private func resolveAPIKey(for model: LLMModelChoice) async -> String? { - if let key = await MainActor.run(body: { - AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ) - }), !key.isEmpty { - return key - } - return TurnProcessContext.effectiveAPIKey + await LLMProviderCredentialGate.resolveAPIKey(for: model) } private static func makeSummary(text: String, keywords: [String], sourceTokenCount: Int) -> MemorySummary { diff --git a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift index e487ee76..9153314a 100644 --- a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift +++ b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift @@ -13,22 +13,18 @@ actor ConfiguredPluginFactoryService { private let thinkingSettings: LLMModelThinkingSettings private let executor: any PluginFactoryExecutor private let logger: PluginFactoryLogger - private let apiKeyProvider: @Sendable () -> String? - init( repository: DBRepository, settings: LLMModelSettings, thinkingSettings: LLMModelThinkingSettings, executor: any PluginFactoryExecutor, - logger: @escaping PluginFactoryLogger = { _ in }, - apiKeyProvider: @escaping @Sendable () -> String? = { TurnProcessContext.effectiveAPIKey } + logger: @escaping PluginFactoryLogger = { _ in } ) { self.repository = repository self.settings = settings self.thinkingSettings = thinkingSettings self.executor = executor self.logger = logger - self.apiKeyProvider = apiKeyProvider } func build( @@ -43,7 +39,6 @@ actor ConfiguredPluginFactoryService { settings: settings, thinkingSettings: thinkingSettings, existingReleases: existingReleases, - apiKeyProvider: apiKeyProvider, logger: logger ), executor: executor, @@ -51,7 +46,6 @@ actor ConfiguredPluginFactoryService { inner: ConfiguredPluginSafetyReviewer( settings: settings, thinkingSettings: thinkingSettings, - apiKeyProvider: apiKeyProvider, logger: logger ) ), @@ -68,20 +62,17 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { private let settings: LLMModelSettings private let thinkingSettings: LLMModelThinkingSettings private let existingReleases: [PluginFactoryReleaseSummary] - private let apiKeyProvider: @Sendable () -> String? private let logger: PluginFactoryLogger init( settings: LLMModelSettings, thinkingSettings: LLMModelThinkingSettings, existingReleases: [PluginFactoryReleaseSummary] = [], - apiKeyProvider: @escaping @Sendable () -> String? = { TurnProcessContext.effectiveAPIKey }, logger: @escaping PluginFactoryLogger = { _ in } ) { self.settings = settings self.thinkingSettings = thinkingSettings self.existingReleases = existingReleases - self.apiKeyProvider = apiKeyProvider self.logger = logger } @@ -107,15 +98,7 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { } private func resolveAPIKey(for model: LLMModelChoice) async -> String? { - if let key = await MainActor.run(body: { - AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ) - }), !key.isEmpty { - return key - } - return apiKeyProvider() + await LLMProviderCredentialGate.resolveAPIKey(for: model) } private func stream( @@ -315,18 +298,15 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { actor ConfiguredPluginSafetyReviewer: PluginFactoryReviewer { private let settings: LLMModelSettings private let thinkingSettings: LLMModelThinkingSettings - private let apiKeyProvider: @Sendable () -> String? private let logger: PluginFactoryLogger init( settings: LLMModelSettings, thinkingSettings: LLMModelThinkingSettings, - apiKeyProvider: @escaping @Sendable () -> String? = { TurnProcessContext.effectiveAPIKey }, logger: @escaping PluginFactoryLogger = { _ in } ) { self.settings = settings self.thinkingSettings = thinkingSettings - self.apiKeyProvider = apiKeyProvider self.logger = logger } @@ -354,15 +334,7 @@ actor ConfiguredPluginSafetyReviewer: PluginFactoryReviewer { } private func resolveAPIKey(for model: LLMModelChoice) async -> String? { - if let key = await MainActor.run(body: { - AppSecretResolver().resolve( - account: model.provider.secretAccount, - environmentKeys: model.provider.apiKeyEnvironmentKeys - ) - }), !key.isEmpty { - return key - } - return apiKeyProvider() + await LLMProviderCredentialGate.resolveAPIKey(for: model) } private func stream( diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 8a24fb77..f4e27839 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -275,10 +275,7 @@ struct ContentView: View { } private var currentHelperAPIKey: String? { - secretResolver.resolve( - account: selectedProvider.secretAccount, - environmentKeys: selectedProvider.apiKeyEnvironmentKeys - ) + LLMProviderCredentialGate.resolveAPIKey(for: selectedProvider, resolver: secretResolver) } private var currentHelperReviewerModelJSON: String? { @@ -1496,10 +1493,7 @@ struct ContentView: View { } private func resolveAPIKey() -> String? { - secretResolver.resolve( - account: selectedModel.provider.secretAccount, - environmentKeys: selectedModel.provider.apiKeyEnvironmentKeys - ) + LLMProviderCredentialGate.resolveAPIKey(for: selectedModel.provider, resolver: secretResolver) } @ViewBuilder diff --git a/ui/uiTests/uiTests.swift b/ui/uiTests/uiTests.swift index c2687c8d..a3f64c31 100644 --- a/ui/uiTests/uiTests.swift +++ b/ui/uiTests/uiTests.swift @@ -205,6 +205,8 @@ import DBRepository #expect(LLMProviderCredentialGate.hasAPIKey(for: .openai, resolver: resolver)) #expect(!LLMProviderCredentialGate.hasAPIKey(for: .google, resolver: resolver)) #expect(LLMProviderCredentialGate.configuredProviders(resolver: resolver) == [.openai]) + #expect(LLMProviderCredentialGate.resolveAPIKey(for: .openai, resolver: resolver) == "test") + #expect(LLMProviderCredentialGate.resolveAPIKey(for: .google, resolver: resolver) == nil) } @Test func llmFailureClassifierDetectsCreditErrors() { From db7458a51771d20ad342d92fd5f60138f72dd053 Mon Sep 17 00:00:00 2001 From: David Choi Date: Wed, 9 Sep 2026 23:17:02 -0400 Subject: [PATCH 04/17] fix script use consolidated schema --- .githooks/pre-commit | 1 + .../LiveFactoryModels.swift | 43 +-- .../MCPServer/Script/GuestHopLoop.swift | 1 + .../MCPServer/Script/HostHTTPClient.swift | 2 +- .../MCPServer/Script/SystemInstruction.swift | 2 +- .../MCPServer/WebCrawlerToolModule.swift | 13 +- .../Factory/PluginFactoryImplementation.swift | 27 +- .../AppLayerServices/News/NewsSourceURL.swift | 66 +++- .../SharedAgentRuntime/PromptResources.swift | 4 - .../ScriptExecContract.generated.swift | 13 + .../Sources/Contract/GuestContract.swift | 1 + .../contracts/script-exec-contract.json | 173 +++++++++ .../schemas/script-exec-contract.schema.json | 346 ++++++++++++++++++ .../Contract/ScriptExecContractDocument.swift | 250 +++++++++++++ .../ScriptExecContractIntegrity.swift | 93 +++++ .../Contract/ScriptExecContractPrompts.swift | 56 +++ .../Contract/ScriptExecContractStore.swift | 64 ++++ .../DockerRunnerXPC/DockerWorkerRuntime.swift | 2 +- .../Plugin/Envelope/DerrickGuestGo.swift | 58 +-- .../AppLayerServicesWireTests.swift | 10 +- .../StructureTests/GuestContractTests.swift | 1 + .../StructureTests/NewsReaderTests.swift | 19 + .../ScriptExecContractTests.swift | 50 +++ readme.md | 2 +- scripts/generate-script-exec-contract.swift | 121 ++++++ .../Conversation/ConversationPipeline.swift | 5 - .../ConversationPipelinePolicy.swift | 2 +- .../conversation_rag_instructions.md | 12 - .../Resources/mcp_tool_instructions.md | 28 +- .../Resources/script_reviewer_instructions.md | 37 -- .../Support/PluginFactoryModels.swift | 58 +-- ui/ui/Views/PluginHTMLResultExtractor.swift | 2 + ui/uiTests/PromptResourcesTests.swift | 2 +- .../schemas/script-exec-contract.schema.json | 346 ++++++++++++++++++ 34 files changed, 1627 insertions(+), 283 deletions(-) create mode 100644 packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift create mode 100644 packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/script-exec-contract.schema.json create mode 100644 packages/Structure/Sources/Contract/ScriptExecContractDocument.swift create mode 100644 packages/Structure/Sources/Contract/ScriptExecContractIntegrity.swift create mode 100644 packages/Structure/Sources/Contract/ScriptExecContractPrompts.swift create mode 100644 packages/Structure/Sources/Contract/ScriptExecContractStore.swift create mode 100644 packages/Structure/Tests/StructureTests/ScriptExecContractTests.swift create mode 100755 scripts/generate-script-exec-contract.swift delete mode 100644 ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md create mode 100644 workers/go/internal/contract/schemas/script-exec-contract.schema.json diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b9202975..1cfc9f32 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,3 +3,4 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" "$ROOT/scripts/verify-no-secrets.sh" --staged "$ROOT/scripts/generate-connector-contract.swift" --check +"$ROOT/scripts/generate-script-exec-contract.swift" --check diff --git a/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift b/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift index af2a46cb..e721d2c3 100644 --- a/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift +++ b/packages/MCPServer/Sources/FactoryHarnessSupport/LiveFactoryModels.swift @@ -75,41 +75,8 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder { private static func builderSystemPrompt(for userGoal: String) -> String { """ You are the Derrick plugin builder. Convert the user's goal into one complete Agent Plugin draft. - Return exactly one JSON object with these keys: - plugin_id (string), version (string), description (string), go_source (string), - test_input_json (string containing valid JSON — a serialized object, not prose), - skill_files (array of objects with path and body), - secrets (array of objects with id, label, and kind; required for connector plugins), - role (string, optional: "connector" or "standard"), - messaging_ops (array of strings, required for connector role). - plugin_id must use lowercase letters, numbers, hyphens, and dots only - (for example my-connector). Never use underscores in plugin_id. - If the plugin needs a username, password, token, or API key, declare them in secrets. - kind must be username, password, token, or api_key. id is a stable Keychain key - such as username or bot_token. label is the text shown when the user saves the value. - Never put real credentials in go_source. - Set role to "connector" when the plugin sends and receives messages with an external - messaging service (any chat or mail connector). Omit role or use "standard" otherwise. - For role connector, include messaging_ops: an array of implemented ops - (send_message, poll_inbox, sync_threads). It must match the user goal scope and test_input_json. - Do not return manifest_json. The host creates the canonical Agent Plugin manifest. - If skill_files is not needed, return an empty array. Every skill file path must be exactly - skills//SKILL.md. Never use manifest.json or other paths in skill_files. - \(DerrickGuestGo.modelContract) + \(ScriptExecContractPrompts.pluginFactoryBuilderGuide()) \(ConnectorContractPrompts.builderGuide(forUserGoal: userGoal)) - Before returning the draft, self-check the implementation: - - Sort every returned collection by an explicit stable key after parsing and de-duplicate it. - - Match host responses by the emitted request_id. - - Use only the Go standard library (no net/http, os/exec, or filesystem access). - - The direct test input must exercise the terminal result path with matching http_results fixtures. - For messaging connector plugins (role connector) that call a vendor HTTP API: - - Declare secrets in the manifest only. Never hard-code credentials. - - Parse each http_results body as JSON when the vendor returns JSON. - - When scope includes send_message, the final result.emit must include sent_message. - - Direct tests for poll_inbox must include a non-empty messages array; runtime empty messages with vendor success is success. - - When scope includes sync_threads, the final result.emit must include a non-empty threads array. - Each thread needs vendor_thread_id (opaque vendor ID) and title (human label for the host channel picker). - Emit only conversations the saved secret can access; for Slack skip channels where is_member is false. When vendor documentation is supplied in the user prompt, use it only to fill may_call HTTP details. """ } @@ -199,12 +166,8 @@ public actor LiveFactoryReviewer: PluginFactoryReviewer { private static func reviewerSystemPrompt(for userGoal: String?) -> String { """ - You are Derrick's independent plugin alignment and safety reviewer. - Return exactly one JSON object: - {"decision":"approved|rejected","summary":"...","findings":[ - {"severity":"info|warning|blocking","category":"alignment|safety|correctness|privacy|supplyChain","message":"..."} - ]} - Reject unsafe or non-deterministic code. Approve when direct test output matches fixtures through result.emit. + Review the user's goal, manifest, test_input_json, exact Go source, and direct test output. + \(ScriptExecContractPrompts.pluginFactoryReviewerGuide()) \(ConnectorContractPrompts.reviewerGuide(forUserGoal: userGoal)) """ } diff --git a/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift b/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift index 943248ad..ed9aae65 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/GuestHopLoop.swift @@ -62,6 +62,7 @@ public enum GuestHopLoop: Sendable { } lastSummary = envelope.payload["summary"]?.stringValue ?? envelope.payload["content"]?.stringValue + ?? envelope.payload["markdown"]?.stringValue ?? envelope.payload["html"]?.stringValue ?? envelope.payload["text"]?.stringValue ?? envelope.payload["title"]?.stringValue diff --git a/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift b/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift index 629c803c..b7069634 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/HostHTTPClient.swift @@ -62,7 +62,7 @@ public actor HostHTTPClient { guard !trimmed.isEmpty, let url = URL(string: wireURL), url.scheme != nil, url.host != nil else { return HostHTTPFetch(status: 0, headers: [:], body: "", error: "invalid_url") } - var currentURL = url + var currentURL = NewsSourceURL.canonicalFetchURL(url) var currentMethod = request.method var currentBody = wire.body let envelopeHeaders = wire.headers diff --git a/packages/MCPServer/Sources/MCPServer/Script/SystemInstruction.swift b/packages/MCPServer/Sources/MCPServer/Script/SystemInstruction.swift index 6ea9c131..a05bfb75 100644 --- a/packages/MCPServer/Sources/MCPServer/Script/SystemInstruction.swift +++ b/packages/MCPServer/Sources/MCPServer/Script/SystemInstruction.swift @@ -9,5 +9,5 @@ import Foundation import Structure public var ReviewerSystemPrompt: String { - DerrickBundledText.mustLoad("script_reviewer_instructions.md") + ScriptExecContractPrompts.reviewerGuide() } diff --git a/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift b/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift index 29d8d176..bfa60b2b 100644 --- a/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/WebCrawlerToolModule.swift @@ -149,15 +149,16 @@ public enum WebCrawlerToolModule: MCPToolModule { let timeoutSeconds = intValue(arguments["timeout_seconds"]) ?? 120 guard !startURL.isEmpty else { throw WebCrawlerToolError.invalidStartURL } - guard let url = URL(string: startURL), - let scheme = url.scheme?.lowercased(), + guard let rawURL = URL(string: startURL), + let scheme = rawURL.scheme?.lowercased(), scheme == "http" || scheme == "https", - url.host?.isEmpty == false, - url.user == nil, - url.password == nil + rawURL.host?.isEmpty == false, + rawURL.user == nil, + rawURL.password == nil else { throw WebCrawlerToolError.invalidStartURL } + let url = NewsSourceURL.canonicalFetchURL(rawURL, contextHint: goal) guard !goal.isEmpty else { throw WebCrawlerToolError.emptyGoal } guard goal.count <= 2_000 else { throw WebCrawlerToolError.goalTooLong } if let reason = maliciousGoalReason(goal) { @@ -174,7 +175,7 @@ public enum WebCrawlerToolModule: MCPToolModule { } return WebCrawlerWireRequest( - startURL: startURL, + startURL: url.absoluteString, goal: goal, maxPages: maxPages, maxDepth: maxDepth, diff --git a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift index fb7cdfd7..cee3ef60 100644 --- a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift +++ b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift @@ -76,12 +76,8 @@ public struct PluginFactorySession: Sendable { "Fix every item below in your next JSON draft response:", ] parts.append(contentsOf: findings.map { "- \($0)" }) - parts.append( - """ - Connector test_input_json must use a hops array replayed by the factory. Match each http.request \ - request_id to an http_results fixture. Declare the same ops in messaging_ops and params.messaging_op. - """ - ) + parts.append(ScriptExecContractPrompts.pluginFactoryBuilderGuide()) + parts.append(ConnectorContractPrompts.builderGuide(forUserGoal: userGoal)) return parts.joined(separator: "\n") case .reviewRejected(let summary, let findings): var parts = [ @@ -92,23 +88,8 @@ public struct PluginFactorySession: Sendable { parts.append("Findings:") parts.append(contentsOf: findings.map { "- \($0)" }) } - parts.append( - """ - Connector protocol (do not add rules): - \(ConnectorContractPrompts.reviewerGuide(forUserGoal: userGoal)) - """ - ) - parts.append( - """ - Before returning the next draft, update test_input_json to a hops array replayed by the factory: - {"hops":[{"kind":"message_in_room","params":{"messaging_op":"send_message",...}},\ - {"kind":"http_results","http_results":[{"request_id":"...","status":200,"body":"..."}],\ - "params":{...}}]} - Include http_results fixtures for every messaging_op you implement. Match request_id values \ - in fixtures to the http.request envelopes your go_source emits. De-duplicate http_results \ - by request_id using stable sorting — do not overwrite duplicates by response order. - """ - ) + parts.append(ScriptExecContractPrompts.pluginFactoryReviewerGuide()) + parts.append(ConnectorContractPrompts.reviewerGuide(forUserGoal: userGoal)) return parts.joined(separator: "\n") default: return error.localizedDescription diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift b/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift index a07e64af..7317b3e8 100644 --- a/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift +++ b/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift @@ -2,30 +2,82 @@ import Foundation /// Rewrites well-known news homepages to a public RSS/Atom endpoint the host can parse. public enum NewsSourceURL { - public static func canonicalFetchURL(_ url: URL) -> URL { + /// Rewrites Google News HTML/topic URLs to RSS feeds the host can fetch reliably. + /// `contextHint` may carry a crawl goal or user prompt (for example "tech news"). + public static func canonicalFetchURL(_ url: URL, contextHint: String? = nil) -> URL { let host = (url.host ?? "").lowercased() guard isGoogleNewsHost(host) else { return url } let path = url.path.lowercased() if path.contains("/rss") || path.hasSuffix(".xml") { return url } + if path.contains("/topics/") { + if let section = googleNewsSection(from: contextHint) { + return googleNewsSectionRSS(section: section) + } + return googleNewsGeneralRSS() + } var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents() parts.scheme = "https" parts.host = "news.google.com" parts.path = "/rss" if parts.queryItems == nil || parts.queryItems?.isEmpty == true { - parts.queryItems = [ - URLQueryItem(name: "hl", value: "en-US"), - URLQueryItem(name: "gl", value: "US"), - URLQueryItem(name: "ceid", value: "US:en"), - ] + parts.queryItems = defaultLocaleQueryItems } parts.fragment = nil - return parts.url ?? URL(string: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en")! + return parts.url ?? googleNewsGeneralRSS() } public static func isGoogleNewsHost(_ host: String) -> Bool { let value = host.lowercased() return value == "news.google.com" || value.hasSuffix(".news.google.com") } + + private static let defaultLocaleQueryItems = [ + URLQueryItem(name: "hl", value: "en-US"), + URLQueryItem(name: "gl", value: "US"), + URLQueryItem(name: "ceid", value: "US:en"), + ] + + private static func googleNewsGeneralRSS() -> URL { + URL(string: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en")! + } + + private static func googleNewsSectionRSS(section: String) -> URL { + var parts = URLComponents() + parts.scheme = "https" + parts.host = "news.google.com" + parts.path = "/rss/headlines/section/topic/\(section)" + parts.queryItems = defaultLocaleQueryItems + return parts.url ?? googleNewsGeneralRSS() + } + + private static func googleNewsSection(from contextHint: String?) -> String? { + let hint = (contextHint ?? "") + .lowercased() + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + if hint.contains("tech") { + return "TECHNOLOGY" + } + if hint.contains("business") || hint.contains("finance") || hint.contains("market") { + return "BUSINESS" + } + if hint.contains("science") { + return "SCIENCE" + } + if hint.contains("sport") { + return "SPORTS" + } + if hint.contains("health") { + return "HEALTH" + } + if hint.contains("entertainment") { + return "ENTERTAINMENT" + } + if hint.contains("world") { + return "WORLD" + } + return nil + } } diff --git a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift index 9f832978..cf769fa3 100644 --- a/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift +++ b/packages/Structure/Sources/AppLayerServices/SharedAgentRuntime/PromptResources.swift @@ -31,10 +31,6 @@ public enum PromptResources { try load(named: "files_extract_skill", from: resourceRoot) } - public static func scriptReviewerInstructions(from resourceRoot: URL? = nil) throws -> String { - try DerrickBundledText.load("script_reviewer_instructions.md", from: resourceRoot) - } - public static func workerOverlay(from resourceRoot: URL? = nil) throws -> String { try DerrickBundledText.load("worker_overlay.md", from: resourceRoot) } diff --git a/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift b/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift new file mode 100644 index 00000000..e64934fc --- /dev/null +++ b/packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift @@ -0,0 +1,13 @@ +// Automatically generated by scripts/generate-script-exec-contract.swift. DO NOT EDIT. + +/// SHA-256 of script_exec protocol JSON and schemas. `swift test` fails when this is stale. +public enum ScriptExecContractFingerprint: Sendable { + public static let sha256 = "3a543a59f64b9c72a1fe94b8845af719740c17dfbc5ea81f5849c9554dcc8c89" + public static let sourceFiles: [String] = [ + "schemas/script-exec-contract.schema.json", + "schemas/guest-runtime.schema.json", + "schemas/hop-event.schema.json", + "schemas/envelope-list.schema.json", + "contracts/script-exec-contract.json", + ] +} diff --git a/packages/Structure/Sources/Contract/GuestContract.swift b/packages/Structure/Sources/Contract/GuestContract.swift index 25568ca1..fdf034ad 100644 --- a/packages/Structure/Sources/Contract/GuestContract.swift +++ b/packages/Structure/Sources/Contract/GuestContract.swift @@ -15,6 +15,7 @@ public enum GuestContract: Sendable { case workerProduct = "worker-product.schema.json" case webCrawlerResult = "web-crawler-result.schema.json" case fileExtractorResult = "file-extractor-result.schema.json" + case scriptExecContract = "script-exec-contract.schema.json" } public static func loadSchemaText(_ schema: Schema) throws -> String { diff --git a/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json b/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json new file mode 100644 index 00000000..99f30c60 --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json @@ -0,0 +1,173 @@ +{ + "version": 1, + "runtime": { + "language": "go", + "package": "main", + "binary": "/tmp/guest", + "stdlib_only": true, + "forbidden_imports": ["net/http", "net", "os/exec"], + "allowed_os_usage": ["os.Stdin", "os.Stdout"], + "example_go": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n)\n\nfunc main() {\n\tvar event map[string]any\n\tif err := json.NewDecoder(os.Stdin).Decode(&event); err != nil {\n\t\treturn\n\t}\n\temit([]map[string]any{{\"verb\": \"result.emit\", \"title\": \"Result\", \"summary\": \"done\"}})\n}\n\nfunc emit(envelopes []map[string]any) {\n\tenc := json.NewEncoder(os.Stdout)\n\tenc.SetEscapeHTML(false)\n\t_ = enc.Encode(envelopes)\n}" + }, + "io": { + "stdin_schema": "hop-event.schema.json", + "stdout_schema": "envelope-list.schema.json", + "guest_runtime_schema": "guest-runtime.schema.json" + }, + "workflow": { + "first_hop_verbs": ["http.request"], + "http_results_event_kind": "http_results", + "terminal_verbs": ["result.emit", "message.post"], + "match_http_by": "request_id", + "http_results_accumulate": true, + "post_body_field": "json" + }, + "output": { + "fields": { + "content": { + "purpose": "parsed plain-text summaries from fetched HTML, XML, or RSS", + "host_strips_incidental_markup": true + }, + "summary": { + "purpose": "short plain-text headline or one-line result", + "host_strips_incidental_markup": true + }, + "html": { + "purpose": "only when the user explicitly requested HTML", + "host_allowlist_sanitizes": true + }, + "markdown": { + "purpose": "intentional Markdown formatting only", + "prefer_content_for_extracted_text": true + } + }, + "forbidden_patterns": ["repr(http_results)"], + "raw_body_in_content_requires_explicit_user_request": true + }, + "agent": { + "prefer_direct_urls_over_serp_html": true, + "retry_with_different_urls_on_empty_fetch": true, + "max_correction_attempts_after_block": 1 + }, + "review": { + "fail_fast": true, + "response_schema": { + "alignedWithRequest": "boolean", + "confidence": "number 0.0-1.0", + "suggestedAction": "allow|deny", + "concerns": "string[]", + "summary": "string" + }, + "checks": [ + { + "id": "intent_alignment", + "order": 1, + "description": "Script, description, reason, and user prompt are consistent.", + "notes": [ + "Derrick has a job scheduler (jobs_create, run_after_seconds, cron). Timing is applied by JobService before this script runs.", + "The script must do the work immediately when invoked.", + "Words like delayed, scheduled, in 7 seconds, later, or run_after refer to the scheduler, not sleep inside the script.", + "Do not deny a script that performs the requested work just because it has no delay." + ] + }, + { + "id": "no_secret_literals", + "order": 2, + "description": "No tokens, API keys, passwords, or other secret literals in the source." + }, + { + "id": "terminal_result_not_fetch_only", + "order": 3, + "description": "The script implements the requested terminal result, not just the fetch.", + "notes": [ + "For summarize, list, inspect, or extract requests, the http_results branch must parse the response body and emit the requested data.", + "Do not allow repr(http_results), a fetch-only confirmation, or an entire raw body copied to content unless the user explicitly requested the raw source.", + "If raw HTML is requested, html is allowed because the host sanitizes it before rendering." + ] + }, + { + "id": "untrusted_remote_content", + "order": 4, + "description": "Fetched HTML or XML is untrusted input; output must use envelope-list output fields correctly.", + "notes": [ + "Prefer content or summary for extracted plain-text lists and headlines.", + "Use html only when the user explicitly asked for HTML.", + "Use markdown only for intentional Markdown formatting.", + "Approve scripts that parse RSS, XML, or HTML and emit normalized plain text in content or summary.", + "Do not deny solely because the source was XML or HTML or because the guest does not re-validate http or https on plain-text lines." + ] + } + ], + "do_not_deny_for": [ + "go_style", + "envelope_construction", + "destination_urls", + "absence_of_dependencies" + ], + "enforced_elsewhere": [ + "static_go_verifier_enforces_forbidden_imports", + "guest_has_no_network_host_performs_http", + "host_applies_ssrf_on_http" + ] + }, + "rules": { + "guest_has_no_network": true, + "host_performs_http": true, + "host_applies_ssrf": true, + "static_verifier_enforces_imports": true, + "scheduler_timing_not_in_script": true, + "deterministic_output_required": true, + "stable_sort_and_dedupe_collections": true, + "no_time_random_uuid_for_visible_output": true, + "match_http_results_by_request_id": true + }, + "plugin_factory": { + "manifest": { + "host_creates_manifest": true, + "do_not_return_manifest_json": true, + "agent_plugin_schema": "Agent Plugin 1.0", + "entrypoint": "./app.derrick/plugin.go", + "plugin_id_allowed_chars": "lowercase letters, numbers, hyphens, dots", + "plugin_id_forbidden_chars": ["underscore"], + "roles": ["connector", "standard"], + "connector_messaging_ops": ["send_message", "poll_inbox", "sync_threads"], + "secret_kinds": ["username", "password", "token", "api_key"], + "never_embed_credentials_in_go_source": true, + "host_lists_connectors_under_messaging": true + }, + "builder": { + "response_keys": [ + "plugin_id", + "version", + "description", + "go_source", + "test_input_json", + "skill_files", + "secrets", + "role", + "messaging_ops" + ], + "skill_files_path_pattern": "skills//SKILL.md", + "empty_skill_files_when_unused": true, + "test_input_is_serialized_json_object": true, + "test_input_must_not_be_empty": true, + "test_input_exercises_terminal_result": true, + "connector_test_input_uses_hops_array": true, + "connector_parse_json_http_results_when_vendor_returns_json": true + }, + "review": { + "compilation_success_not_approval": true, + "response_schema": { + "decision": "approved|rejected", + "summary": "string", + "findings": "[{severity: info|warning|blocking, category: alignment|safety|correctness|privacy|supplyChain, message: string}]" + }, + "reject_non_go_source": true, + "reject_raw_network_outside_http_request_envelopes": true, + "reject_missing_stdin_read": true, + "source_derived_titles_may_be_fragments": true, + "reject_unsupported_direct_test_claims": true, + "connector_rules_document": "connector-contract.json" + } + } +} diff --git a/packages/Structure/Sources/Contract/Resources/schemas/script-exec-contract.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/script-exec-contract.schema.json new file mode 100644 index 00000000..2e369012 --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/schemas/script-exec-contract.schema.json @@ -0,0 +1,346 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/script-exec-contract.json", + "title": "Derrick script_exec protocol document", + "description": "Canonical guest runtime, workflow, output, agent, and reviewer rules for script_exec and offline Go guests.", + "type": "object", + "required": [ + "version", + "runtime", + "io", + "workflow", + "output", + "agent", + "review", + "rules", + "plugin_factory" + ], + "additionalProperties": false, + "properties": { + "version": { "type": "integer", "const": 1 }, + "runtime": { "$ref": "#/$defs/runtime" }, + "io": { "$ref": "#/$defs/io" }, + "workflow": { "$ref": "#/$defs/workflow" }, + "output": { "$ref": "#/$defs/output" }, + "agent": { "$ref": "#/$defs/agent" }, + "review": { "$ref": "#/$defs/review" }, + "rules": { "$ref": "#/$defs/rules" }, + "plugin_factory": { "$ref": "#/$defs/plugin_factory" } + }, + "$defs": { + "runtime": { + "type": "object", + "required": [ + "language", + "package", + "binary", + "stdlib_only", + "forbidden_imports", + "allowed_os_usage", + "example_go" + ], + "additionalProperties": false, + "properties": { + "language": { "type": "string", "const": "go" }, + "package": { "type": "string", "const": "main" }, + "binary": { "type": "string", "const": "/tmp/guest" }, + "stdlib_only": { "type": "boolean" }, + "forbidden_imports": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "allowed_os_usage": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "example_go": { "type": "string", "minLength": 1 } + } + }, + "io": { + "type": "object", + "required": ["stdin_schema", "stdout_schema", "guest_runtime_schema"], + "additionalProperties": false, + "properties": { + "stdin_schema": { "type": "string", "const": "hop-event.schema.json" }, + "stdout_schema": { "type": "string", "const": "envelope-list.schema.json" }, + "guest_runtime_schema": { "type": "string", "const": "guest-runtime.schema.json" } + } + }, + "workflow": { + "type": "object", + "required": [ + "first_hop_verbs", + "http_results_event_kind", + "terminal_verbs", + "match_http_by", + "http_results_accumulate", + "post_body_field" + ], + "additionalProperties": false, + "properties": { + "first_hop_verbs": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "http_results_event_kind": { "type": "string" }, + "terminal_verbs": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "match_http_by": { "type": "string", "const": "request_id" }, + "http_results_accumulate": { "type": "boolean" }, + "post_body_field": { "type": "string", "const": "json" } + } + }, + "output": { + "type": "object", + "required": ["fields", "forbidden_patterns", "raw_body_in_content_requires_explicit_user_request"], + "additionalProperties": false, + "properties": { + "fields": { + "type": "object", + "required": ["content", "summary", "html", "markdown"], + "additionalProperties": false, + "properties": { + "content": { "$ref": "#/$defs/output_field" }, + "summary": { "$ref": "#/$defs/output_field" }, + "html": { "$ref": "#/$defs/output_field" }, + "markdown": { "$ref": "#/$defs/output_field" } + } + }, + "forbidden_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "raw_body_in_content_requires_explicit_user_request": { "type": "boolean" } + } + }, + "output_field": { + "type": "object", + "required": ["purpose"], + "additionalProperties": true, + "properties": { + "purpose": { "type": "string" }, + "host_strips_incidental_markup": { "type": "boolean" }, + "host_allowlist_sanitizes": { "type": "boolean" }, + "prefer_content_for_extracted_text": { "type": "boolean" } + } + }, + "agent": { + "type": "object", + "required": [ + "prefer_direct_urls_over_serp_html", + "retry_with_different_urls_on_empty_fetch", + "max_correction_attempts_after_block" + ], + "additionalProperties": false, + "properties": { + "prefer_direct_urls_over_serp_html": { "type": "boolean" }, + "retry_with_different_urls_on_empty_fetch": { "type": "boolean" }, + "max_correction_attempts_after_block": { "type": "integer", "minimum": 0 } + } + }, + "review": { + "type": "object", + "required": [ + "fail_fast", + "response_schema", + "checks", + "do_not_deny_for", + "enforced_elsewhere" + ], + "additionalProperties": false, + "properties": { + "fail_fast": { "type": "boolean" }, + "response_schema": { + "type": "object", + "required": [ + "alignedWithRequest", + "confidence", + "suggestedAction", + "concerns", + "summary" + ], + "additionalProperties": false, + "properties": { + "alignedWithRequest": { "type": "string" }, + "confidence": { "type": "string" }, + "suggestedAction": { "type": "string" }, + "concerns": { "type": "string" }, + "summary": { "type": "string" } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/review_check" } + }, + "do_not_deny_for": { + "type": "array", + "items": { "type": "string" } + }, + "enforced_elsewhere": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "review_check": { + "type": "object", + "required": ["id", "order", "description"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "order": { "type": "integer", "minimum": 1 }, + "description": { "type": "string" }, + "notes": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "rules": { + "type": "object", + "required": [ + "guest_has_no_network", + "host_performs_http", + "host_applies_ssrf", + "static_verifier_enforces_imports", + "scheduler_timing_not_in_script", + "deterministic_output_required", + "stable_sort_and_dedupe_collections", + "no_time_random_uuid_for_visible_output", + "match_http_results_by_request_id" + ], + "additionalProperties": false, + "properties": { + "guest_has_no_network": { "type": "boolean" }, + "host_performs_http": { "type": "boolean" }, + "host_applies_ssrf": { "type": "boolean" }, + "static_verifier_enforces_imports": { "type": "boolean" }, + "scheduler_timing_not_in_script": { "type": "boolean" }, + "deterministic_output_required": { "type": "boolean" }, + "stable_sort_and_dedupe_collections": { "type": "boolean" }, + "no_time_random_uuid_for_visible_output": { "type": "boolean" }, + "match_http_results_by_request_id": { "type": "boolean" } + } + }, + "plugin_factory": { + "type": "object", + "required": ["manifest", "builder", "review"], + "additionalProperties": false, + "properties": { + "manifest": { "$ref": "#/$defs/plugin_factory_manifest" }, + "builder": { "$ref": "#/$defs/plugin_factory_builder" }, + "review": { "$ref": "#/$defs/plugin_factory_review" } + } + }, + "plugin_factory_manifest": { + "type": "object", + "required": [ + "host_creates_manifest", + "do_not_return_manifest_json", + "agent_plugin_schema", + "entrypoint", + "plugin_id_allowed_chars", + "plugin_id_forbidden_chars", + "roles", + "connector_messaging_ops", + "secret_kinds", + "never_embed_credentials_in_go_source", + "host_lists_connectors_under_messaging" + ], + "additionalProperties": false, + "properties": { + "host_creates_manifest": { "type": "boolean" }, + "do_not_return_manifest_json": { "type": "boolean" }, + "agent_plugin_schema": { "type": "string" }, + "entrypoint": { "type": "string" }, + "plugin_id_allowed_chars": { "type": "string" }, + "plugin_id_forbidden_chars": { + "type": "array", + "items": { "type": "string" } + }, + "roles": { + "type": "array", + "items": { "type": "string" } + }, + "connector_messaging_ops": { + "type": "array", + "items": { "type": "string" } + }, + "secret_kinds": { + "type": "array", + "items": { "type": "string" } + }, + "never_embed_credentials_in_go_source": { "type": "boolean" }, + "host_lists_connectors_under_messaging": { "type": "boolean" } + } + }, + "plugin_factory_builder": { + "type": "object", + "required": [ + "response_keys", + "skill_files_path_pattern", + "empty_skill_files_when_unused", + "test_input_is_serialized_json_object", + "test_input_must_not_be_empty", + "test_input_exercises_terminal_result", + "connector_test_input_uses_hops_array", + "connector_parse_json_http_results_when_vendor_returns_json" + ], + "additionalProperties": false, + "properties": { + "response_keys": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "skill_files_path_pattern": { "type": "string" }, + "empty_skill_files_when_unused": { "type": "boolean" }, + "test_input_is_serialized_json_object": { "type": "boolean" }, + "test_input_must_not_be_empty": { "type": "boolean" }, + "test_input_exercises_terminal_result": { "type": "boolean" }, + "connector_test_input_uses_hops_array": { "type": "boolean" }, + "connector_parse_json_http_results_when_vendor_returns_json": { "type": "boolean" } + } + }, + "plugin_factory_review": { + "type": "object", + "required": [ + "compilation_success_not_approval", + "response_schema", + "reject_non_go_source", + "reject_raw_network_outside_http_request_envelopes", + "reject_missing_stdin_read", + "source_derived_titles_may_be_fragments", + "reject_unsupported_direct_test_claims", + "connector_rules_document" + ], + "additionalProperties": false, + "properties": { + "compilation_success_not_approval": { "type": "boolean" }, + "response_schema": { + "type": "object", + "required": ["decision", "summary", "findings"], + "additionalProperties": false, + "properties": { + "decision": { "type": "string" }, + "summary": { "type": "string" }, + "findings": { "type": "string" } + } + }, + "reject_non_go_source": { "type": "boolean" }, + "reject_raw_network_outside_http_request_envelopes": { "type": "boolean" }, + "reject_missing_stdin_read": { "type": "boolean" }, + "source_derived_titles_may_be_fragments": { "type": "boolean" }, + "reject_unsupported_direct_test_claims": { "type": "boolean" }, + "connector_rules_document": { "type": "string" } + } + } + } +} diff --git a/packages/Structure/Sources/Contract/ScriptExecContractDocument.swift b/packages/Structure/Sources/Contract/ScriptExecContractDocument.swift new file mode 100644 index 00000000..83a308d8 --- /dev/null +++ b/packages/Structure/Sources/Contract/ScriptExecContractDocument.swift @@ -0,0 +1,250 @@ +import Foundation + +/// Canonical Derrick script_exec protocol decoded from `script-exec-contract.json`. +public struct ScriptExecContractDocument: Codable, Sendable, Hashable { + public var version: Int + public var runtime: ScriptExecRuntimeSpec + public var io: ScriptExecIOSpec + public var workflow: ScriptExecWorkflowSpec + public var output: ScriptExecOutputSpec + public var agent: ScriptExecAgentSpec + public var review: ScriptExecReviewSpec + public var rules: ScriptExecProtocolRules + public var pluginFactory: ScriptExecPluginFactorySpec + + enum CodingKeys: String, CodingKey { + case version, runtime, io, workflow, output, agent, review, rules + case pluginFactory = "plugin_factory" + } +} + +public struct ScriptExecRuntimeSpec: Codable, Sendable, Hashable { + public var language: String + public var package: String + public var binary: String + public var stdlibOnly: Bool + public var forbiddenImports: [String] + public var allowedOSUsage: [String] + public var exampleGo: String + + enum CodingKeys: String, CodingKey { + case language, package, binary + case stdlibOnly = "stdlib_only" + case forbiddenImports = "forbidden_imports" + case allowedOSUsage = "allowed_os_usage" + case exampleGo = "example_go" + } +} + +public struct ScriptExecIOSpec: Codable, Sendable, Hashable { + public var stdinSchema: String + public var stdoutSchema: String + public var guestRuntimeSchema: String + + enum CodingKeys: String, CodingKey { + case stdinSchema = "stdin_schema" + case stdoutSchema = "stdout_schema" + case guestRuntimeSchema = "guest_runtime_schema" + } +} + +public struct ScriptExecWorkflowSpec: Codable, Sendable, Hashable { + public var firstHopVerbs: [String] + public var httpResultsEventKind: String + public var terminalVerbs: [String] + public var matchHTTPBy: String + public var httpResultsAccumulate: Bool + public var postBodyField: String + + enum CodingKeys: String, CodingKey { + case firstHopVerbs = "first_hop_verbs" + case httpResultsEventKind = "http_results_event_kind" + case terminalVerbs = "terminal_verbs" + case matchHTTPBy = "match_http_by" + case httpResultsAccumulate = "http_results_accumulate" + case postBodyField = "post_body_field" + } +} + +public struct ScriptExecOutputSpec: Codable, Sendable, Hashable { + public var fields: [String: ScriptExecOutputFieldSpec] + public var forbiddenPatterns: [String] + public var rawBodyInContentRequiresExplicitUserRequest: Bool + + enum CodingKeys: String, CodingKey { + case fields + case forbiddenPatterns = "forbidden_patterns" + case rawBodyInContentRequiresExplicitUserRequest = + "raw_body_in_content_requires_explicit_user_request" + } +} + +public struct ScriptExecOutputFieldSpec: Codable, Sendable, Hashable { + public var purpose: String + public var hostStripsIncidentalMarkup: Bool? + public var hostAllowlistSanitizes: Bool? + public var preferContentForExtractedText: Bool? + + enum CodingKeys: String, CodingKey { + case purpose + case hostStripsIncidentalMarkup = "host_strips_incidental_markup" + case hostAllowlistSanitizes = "host_allowlist_sanitizes" + case preferContentForExtractedText = "prefer_content_for_extracted_text" + } +} + +public struct ScriptExecAgentSpec: Codable, Sendable, Hashable { + public var preferDirectURLsOverSERPHTML: Bool + public var retryWithDifferentURLsOnEmptyFetch: Bool + public var maxCorrectionAttemptsAfterBlock: Int + + enum CodingKeys: String, CodingKey { + case preferDirectURLsOverSERPHTML = "prefer_direct_urls_over_serp_html" + case retryWithDifferentURLsOnEmptyFetch = "retry_with_different_urls_on_empty_fetch" + case maxCorrectionAttemptsAfterBlock = "max_correction_attempts_after_block" + } +} + +public struct ScriptExecReviewSpec: Codable, Sendable, Hashable { + public var failFast: Bool + public var responseSchema: [String: String] + public var checks: [ScriptExecReviewCheck] + public var doNotDenyFor: [String] + public var enforcedElsewhere: [String] + + enum CodingKeys: String, CodingKey { + case failFast = "fail_fast" + case responseSchema = "response_schema" + case checks + case doNotDenyFor = "do_not_deny_for" + case enforcedElsewhere = "enforced_elsewhere" + } +} + +public struct ScriptExecReviewCheck: Codable, Sendable, Hashable { + public var id: String + public var order: Int + public var description: String + public var notes: [String]? +} + +public struct ScriptExecProtocolRules: Codable, Sendable, Hashable { + public var guestHasNoNetwork: Bool + public var hostPerformsHTTP: Bool + public var hostAppliesSSRF: Bool + public var staticVerifierEnforcesImports: Bool + public var schedulerTimingNotInScript: Bool + public var deterministicOutputRequired: Bool + public var stableSortAndDedupeCollections: Bool + public var noTimeRandomUUIDForVisibleOutput: Bool + public var matchHTTPResultsByRequestID: Bool + + enum CodingKeys: String, CodingKey { + case guestHasNoNetwork = "guest_has_no_network" + case hostPerformsHTTP = "host_performs_http" + case hostAppliesSSRF = "host_applies_ssrf" + case staticVerifierEnforcesImports = "static_verifier_enforces_imports" + case schedulerTimingNotInScript = "scheduler_timing_not_in_script" + case deterministicOutputRequired = "deterministic_output_required" + case stableSortAndDedupeCollections = "stable_sort_and_dedupe_collections" + case noTimeRandomUUIDForVisibleOutput = "no_time_random_uuid_for_visible_output" + case matchHTTPResultsByRequestID = "match_http_results_by_request_id" + } +} + +public struct ScriptExecPluginFactorySpec: Codable, Sendable, Hashable { + public var manifest: ScriptExecPluginFactoryManifestSpec + public var builder: ScriptExecPluginFactoryBuilderSpec + public var review: ScriptExecPluginFactoryReviewSpec +} + +public struct ScriptExecPluginFactoryManifestSpec: Codable, Sendable, Hashable { + public var hostCreatesManifest: Bool + public var doNotReturnManifestJSON: Bool + public var agentPluginSchema: String + public var entrypoint: String + public var pluginIDAllowedChars: String + public var pluginIDForbiddenChars: [String] + public var roles: [String] + public var connectorMessagingOps: [String] + public var secretKinds: [String] + public var neverEmbedCredentialsInGoSource: Bool + public var hostListsConnectorsUnderMessaging: Bool + + enum CodingKeys: String, CodingKey { + case hostCreatesManifest = "host_creates_manifest" + case doNotReturnManifestJSON = "do_not_return_manifest_json" + case agentPluginSchema = "agent_plugin_schema" + case entrypoint + case pluginIDAllowedChars = "plugin_id_allowed_chars" + case pluginIDForbiddenChars = "plugin_id_forbidden_chars" + case roles + case connectorMessagingOps = "connector_messaging_ops" + case secretKinds = "secret_kinds" + case neverEmbedCredentialsInGoSource = "never_embed_credentials_in_go_source" + case hostListsConnectorsUnderMessaging = "host_lists_connectors_under_messaging" + } +} + +public struct ScriptExecPluginFactoryBuilderSpec: Codable, Sendable, Hashable { + public var responseKeys: [String] + public var skillFilesPathPattern: String + public var emptySkillFilesWhenUnused: Bool + public var testInputIsSerializedJSONObject: Bool + public var testInputMustNotBeEmpty: Bool + public var testInputExercisesTerminalResult: Bool + public var connectorTestInputUsesHopsArray: Bool + public var connectorParseJSONHTTPResultsWhenVendorReturnsJSON: Bool + + enum CodingKeys: String, CodingKey { + case responseKeys = "response_keys" + case skillFilesPathPattern = "skill_files_path_pattern" + case emptySkillFilesWhenUnused = "empty_skill_files_when_unused" + case testInputIsSerializedJSONObject = "test_input_is_serialized_json_object" + case testInputMustNotBeEmpty = "test_input_must_not_be_empty" + case testInputExercisesTerminalResult = "test_input_exercises_terminal_result" + case connectorTestInputUsesHopsArray = "connector_test_input_uses_hops_array" + case connectorParseJSONHTTPResultsWhenVendorReturnsJSON = + "connector_parse_json_http_results_when_vendor_returns_json" + } +} + +public struct ScriptExecPluginFactoryReviewSpec: Codable, Sendable, Hashable { + public var compilationSuccessNotApproval: Bool + public var responseSchema: [String: String] + public var rejectNonGoSource: Bool + public var rejectRawNetworkOutsideHTTPRequestEnvelopes: Bool + public var rejectMissingStdinRead: Bool + public var sourceDerivedTitlesMayBeFragments: Bool + public var rejectUnsupportedDirectTestClaims: Bool + public var connectorRulesDocument: String + + enum CodingKeys: String, CodingKey { + case compilationSuccessNotApproval = "compilation_success_not_approval" + case responseSchema = "response_schema" + case rejectNonGoSource = "reject_non_go_source" + case rejectRawNetworkOutsideHTTPRequestEnvelopes = + "reject_raw_network_outside_http_request_envelopes" + case rejectMissingStdinRead = "reject_missing_stdin_read" + case sourceDerivedTitlesMayBeFragments = "source_derived_titles_may_be_fragments" + case rejectUnsupportedDirectTestClaims = "reject_unsupported_direct_test_claims" + case connectorRulesDocument = "connector_rules_document" + } +} + +public enum ScriptExecContractError: Error, Equatable, LocalizedError, Sendable { + case missingResource(String) + case invalidJSON(String) + case integrityFailed(String) + + public var errorDescription: String? { + switch self { + case .missingResource(let name): + return "Missing bundled script_exec contract resource \(name)." + case .invalidJSON(let name): + return "Script_exec contract resource \(name) is not valid JSON." + case .integrityFailed(let detail): + return "Script_exec contract JSON failed schema checks: \(detail)" + } + } +} diff --git a/packages/Structure/Sources/Contract/ScriptExecContractIntegrity.swift b/packages/Structure/Sources/Contract/ScriptExecContractIntegrity.swift new file mode 100644 index 00000000..81face78 --- /dev/null +++ b/packages/Structure/Sources/Contract/ScriptExecContractIntegrity.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Host checks that the bundled script_exec JSON still matches its schema and wire schemas. +public enum ScriptExecContractIntegrity: Sendable { + public static func validateBundledGraph() throws { + let contractData = try ScriptExecContractStore.resourceData( + name: ScriptExecContractStore.protocolResource, + subdirectory: "contracts" + ) + let contractJSON: [String: Any] + do { + contractJSON = try JSONSchema.object( + from: contractData, + name: ScriptExecContractStore.protocolResource + ) + } catch { + throw ScriptExecContractError.invalidJSON(ScriptExecContractStore.protocolResource) + } + try validateAgainstSchema(contractJSON, schema: .scriptExecContract) + try validateWireSchemaRefs(contractJSON) + try validateOutputFields(contractJSON) + try validateReviewChecks(contractJSON) + } + + private static func validateAgainstSchema(_ instance: Any, schema: GuestContract.Schema) throws { + do { + try GuestContract.validate(instance, against: schema) + } catch let error as GuestContractError { + throw ScriptExecContractError.integrityFailed(error.localizedDescription) + } + } + + private static func validateWireSchemaRefs(_ contract: [String: Any]) throws { + guard let io = contract["io"] as? [String: Any] else { return } + for key in ["stdin_schema", "stdout_schema", "guest_runtime_schema"] { + guard let fileName = io[key] as? String, + GuestContract.Schema(rawValue: fileName) != nil else { + throw ScriptExecContractError.integrityFailed( + "script-exec-contract.json io.\(key) must reference a bundled guest schema." + ) + } + } + } + + private static func validateOutputFields(_ contract: [String: Any]) throws { + let envelope = try GuestContract.loadSchemaObject(.envelopeList) + guard let properties = (envelope["items"] as? [String: Any])?["properties"] as? [String: Any] else { + throw ScriptExecContractError.integrityFailed( + "envelope-list.schema.json is missing items.properties." + ) + } + let envelopeKeys = Set(properties.keys) + guard let output = contract["output"] as? [String: Any], + let fields = output["fields"] as? [String: Any] else { + return + } + let missing = Set(fields.keys).subtracting(envelopeKeys) + if !missing.isEmpty { + throw ScriptExecContractError.integrityFailed( + "script-exec-contract.json output.fields references unknown envelope fields: \(missing.sorted().joined(separator: ", "))." + ) + } + } + + private static func validateReviewChecks(_ contract: [String: Any]) throws { + guard let review = contract["review"] as? [String: Any], + let checks = review["checks"] as? [[String: Any]] else { + return + } + var seenIDs: Set = [] + var seenOrders: Set = [] + for check in checks { + guard let id = check["id"] as? String, + let order = check["order"] as? Int else { + throw ScriptExecContractError.integrityFailed( + "script-exec-contract.json review.checks entries require id and order." + ) + } + if seenIDs.contains(id) { + throw ScriptExecContractError.integrityFailed( + "script-exec-contract.json review.checks has duplicate id \(id)." + ) + } + if seenOrders.contains(order) { + throw ScriptExecContractError.integrityFailed( + "script-exec-contract.json review.checks has duplicate order \(order)." + ) + } + seenIDs.insert(id) + seenOrders.insert(order) + } + } +} diff --git a/packages/Structure/Sources/Contract/ScriptExecContractPrompts.swift b/packages/Structure/Sources/Contract/ScriptExecContractPrompts.swift new file mode 100644 index 00000000..82278b35 --- /dev/null +++ b/packages/Structure/Sources/Contract/ScriptExecContractPrompts.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Renders the bundled script_exec protocol for agent, builder, and reviewer prompts. +public enum ScriptExecContractPrompts: Sendable { + public static func builderGuide() -> String { + dumpOrUnavailable(preamble: """ + Offline Go guest rules come only from this protocol JSON and the wire schemas below. \ + Do not invent requirements that are not in the JSON. + """) + } + + public static func reviewerGuide() -> String { + dumpOrUnavailable(preamble: """ + You are a reviewer for script_exec declarations. Review against script-exec-contract.json only. \ + If a rule is not in the JSON, do not require it. Apply review.checks in order; when review.fail_fast \ + is true, return on the first failing check. Return only valid JSON matching review.response_schema. + """) + } + + public static func pluginFactoryBuilderGuide() -> String { + dumpOrUnavailable(preamble: """ + Plugin factory builder rules come only from script-exec-contract.json (plugin_factory, runtime, \ + workflow, output, rules) and connector-contract.json when building a connector. Do not invent \ + requirements that are not in those JSON documents. + """) + } + + public static func pluginFactoryReviewerGuide() -> String { + dumpOrUnavailable(preamble: """ + You are Derrick's independent plugin alignment and safety reviewer. Review guest go_source against \ + script-exec-contract.json (runtime, workflow, output, rules, plugin_factory.review). If a guest rule \ + is not in the JSON, do not require it. For connector plugins also apply connector-contract.json below. \ + Return exactly one JSON object matching plugin_factory.review.response_schema. + """) + } + + public static func dump(preamble: String) throws -> String { + _ = try ScriptExecContractStore.loadProtocol() + var lines: [String] = [preamble, "", "--- script-exec-contract.json ---"] + lines.append(try ScriptExecContractStore.loadProtocolText()) + lines.append("") + lines.append("--- \(GuestContract.Schema.guestRuntime.rawValue) ---") + lines.append(try GuestContract.loadSchemaText(.guestRuntime)) + lines.append("") + lines.append("--- \(GuestContract.Schema.hopEvent.rawValue) ---") + lines.append(try GuestContract.loadSchemaText(.hopEvent)) + lines.append("") + lines.append("--- \(GuestContract.Schema.envelopeList.rawValue) ---") + lines.append(try GuestContract.loadSchemaText(.envelopeList)) + return lines.joined(separator: "\n") + } + + private static func dumpOrUnavailable(preamble: String) -> String { + (try? dump(preamble: preamble)) ?? "Script_exec contract JSON failed to load." + } +} diff --git a/packages/Structure/Sources/Contract/ScriptExecContractStore.swift b/packages/Structure/Sources/Contract/ScriptExecContractStore.swift new file mode 100644 index 00000000..063fe59f --- /dev/null +++ b/packages/Structure/Sources/Contract/ScriptExecContractStore.swift @@ -0,0 +1,64 @@ +import CryptoKit +import Foundation + +/// Loads the bundled script_exec protocol JSON. +public enum ScriptExecContractStore: Sendable { + public static let protocolResource = "script-exec-contract.json" + public static let fingerprintEntries: [(subdirectory: String, file: String)] = [ + ("schemas", "script-exec-contract.schema.json"), + ("schemas", "guest-runtime.schema.json"), + ("schemas", "hop-event.schema.json"), + ("schemas", "envelope-list.schema.json"), + ("contracts", "script-exec-contract.json"), + ] + + public static var fingerprintSources: [String] { + fingerprintEntries.map { "\($0.subdirectory)/\($0.file)" } + } + + public static func loadProtocol() throws -> ScriptExecContractDocument { + try ScriptExecContractIntegrity.validateBundledGraph() + let data = try resourceData(name: protocolResource, subdirectory: "contracts") + do { + return try JSONDecoder().decode(ScriptExecContractDocument.self, from: data) + } catch { + throw ScriptExecContractError.invalidJSON(protocolResource) + } + } + + public static func loadProtocolText() throws -> String { + try utf8Text(resourceData(name: protocolResource, subdirectory: "contracts")) + } + + public static func computeFingerprint() throws -> String { + var joined = Data() + for entry in fingerprintEntries { + let relative = "\(entry.subdirectory)/\(entry.file)" + let data = try resourceData(name: entry.file, subdirectory: entry.subdirectory) + joined.append(Data(relative.utf8)) + joined.append(0) + joined.append(data) + joined.append(0) + } + let digest = SHA256.hash(data: joined) + return digest.map { String(format: "%02x", $0) }.joined() + } + + static func resourceData(name: String, subdirectory: String) throws -> Data { + guard let url = Bundle.module.url( + forResource: name, + withExtension: nil, + subdirectory: subdirectory + ) else { + throw ScriptExecContractError.missingResource("\(subdirectory)/\(name)") + } + return try Data(contentsOf: url) + } + + private static func utf8Text(_ data: Data) throws -> String { + guard let text = String(data: data, encoding: .utf8) else { + throw ScriptExecContractError.invalidJSON(protocolResource) + } + return text + } +} diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift index dca7e126..7a8f1f3a 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift @@ -15,7 +15,7 @@ public enum DockerWorkerRuntime: Sendable { /// Allowed `docker exec … sh -c` payloads for guest source I/O and in-container compile. public static let guestWriteSourceShell = "cat > /tmp/plugin.go" public static let guestCompileShell = - "cd /tmp && /usr/local/go/bin/go build -trimpath -ldflags=-s -w -o guest plugin.go && chmod +x guest" + "cd /tmp && /usr/local/go/bin/go build -trimpath -ldflags=\"-s -w\" -o guest plugin.go && chmod +x guest" public static let guestReadBinaryShell = "cat /tmp/guest" public static let pinnedDigest = DockerProductImageDigests.worker diff --git a/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift b/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift index 175d3142..033bd9e7 100644 --- a/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift +++ b/packages/Structure/Sources/Plugin/Envelope/DerrickGuestGo.swift @@ -2,64 +2,8 @@ import Foundation /// Go contract shown to models that generate Derrick plugin guest programs. public enum DerrickGuestGo: Sendable { - public static let modelContract = """ - Go guest contract: - - The program is a standalone `package main` built to a Linux binary and run as `/tmp/guest`. - - Read one JSON object from standard input matching the hop-event schema. - - Write one JSON array of envelope objects to standard output matching the envelope-list schema. - - Every envelope object needs `verb` from the envelope-list schema. - - POST bodies go in `json`. The host decodes `http.request` as HostHTTPRequest and sends `json` as the HTTP body. - - On the first event, emit `http.request` envelopes for host HTTP. - - On an `http_results` event, emit `result.emit` or `message.post`. - - The host, not the guest container, performs HTTP and supplies response bodies. - - Do not import net/http, net, os/exec, os (except os.Stdin/os.Stdout), or perform filesystem access. - - Use only the Go standard library. - - For repeatable output, match HTTP responses by request_id, sort and de-duplicate collections - by stable keys, and never use current time, randomness, UUIDs, response arrival order, or - map iteration order for user-visible output. - - Later http_results events include earlier responses plus the newest ones. Match by request_id. - - Minimal output pattern: - ```go - package main - - import ( - "encoding/json" - "os" - ) - - func main() { - var event map[string]any - if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { - return - } - emit([]map[string]any{{"verb": "result.emit", "title": "Result", "summary": "done"}}) - } - - func emit(envelopes []map[string]any) { - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - _ = enc.Encode(envelopes) - } - ``` - Inspect `event["kind"]` and `event["http_results"]` to choose the next envelopes. - """ - public static func source(for spec: PluginSpec? = nil) throws -> String { - var sections = [modelContract] - sections.append( - """ - Canonical JSON schemas (Swift host and Go guest must match exactly): - --- \(GuestContract.Schema.guestRuntime.rawValue) --- - \(try GuestContract.loadSchemaText(.guestRuntime)) - - --- \(GuestContract.Schema.hopEvent.rawValue) --- - \(try GuestContract.loadSchemaText(.hopEvent)) - - --- \(GuestContract.Schema.envelopeList.rawValue) --- - \(try GuestContract.loadSchemaText(.envelopeList)) - """ - ) + var sections = [ScriptExecContractPrompts.builderGuide()] if let spec { sections.append( """ diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index e594bbfc..f79aa322 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -84,11 +84,11 @@ import Testing #expect(decoded.executableFingerprint == nil) } - @Test func bundledScriptReviewerInstructionsLoadFromSourceTree() throws { - let scriptReviewer = try DerrickBundledText.load("script_reviewer_instructions.md") - #expect(scriptReviewer.contains("intent alignment")) - #expect(scriptReviewer.contains("secret literals")) - #expect(scriptReviewer.contains("Go verifier")) + @Test func scriptExecReviewerPromptLoadsFromBundledContract() { + let scriptReviewer = ScriptExecContractPrompts.reviewerGuide() + #expect(scriptReviewer.contains("script-exec-contract.json")) + #expect(scriptReviewer.contains("intent_alignment")) + #expect(scriptReviewer.contains("If a rule is not in the JSON")) } @Test func healthDecodesLegacyPayloadWithoutGuestRuntime() throws { diff --git a/packages/Structure/Tests/StructureTests/GuestContractTests.swift b/packages/Structure/Tests/StructureTests/GuestContractTests.swift index 430d1346..58d1a06d 100644 --- a/packages/Structure/Tests/StructureTests/GuestContractTests.swift +++ b/packages/Structure/Tests/StructureTests/GuestContractTests.swift @@ -142,4 +142,5 @@ import Testing try GuestContractValidation.validateFileExtractorResultJSON(Data(json.utf8)) } } + } diff --git a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift index 7b0f12a5..56235d1f 100644 --- a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift +++ b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift @@ -129,6 +129,25 @@ import Structure #expect(already.path.contains("rss")) } + @Test func googleNewsTopicPageMapsToSectionRSSWhenHintMentionsTech() { + let topic = URL( + string: "https://news.google.com/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGx1YlY4U0FtVnVHZ0pWVXlnQVAB" + )! + let mapped = NewsSourceURL.canonicalFetchURL( + topic, + contextHint: "today's technology headlines" + ) + #expect(mapped.path == "/rss/headlines/section/topic/TECHNOLOGY") + } + + @Test func googleNewsTopicPageFallsBackToGeneralRSSWithoutHint() { + let topic = URL( + string: "https://news.google.com/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGx1YlY4U0FtVnVHZ0pWVXlnQVAB" + )! + let mapped = NewsSourceURL.canonicalFetchURL(topic) + #expect(mapped.path == "/rss") + } + @Test func refreshParsesGoogleNewsHomepageViaRSS() async throws { let rss = """ diff --git a/packages/Structure/Tests/StructureTests/ScriptExecContractTests.swift b/packages/Structure/Tests/StructureTests/ScriptExecContractTests.swift new file mode 100644 index 00000000..c71db5c2 --- /dev/null +++ b/packages/Structure/Tests/StructureTests/ScriptExecContractTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Structure +import Testing + +@Suite struct ScriptExecContractTests { + @Test func protocolJSONLoadsAndMatchesRules() throws { + let document = try ScriptExecContractStore.loadProtocol() + #expect(document.version == 1) + #expect(document.runtime.language == "go") + #expect(document.review.failFast) + #expect(document.review.checks.count == 4) + #expect(document.rules.guestHasNoNetwork) + #expect(document.output.fields["content"]?.purpose.contains("plain-text") == true) + #expect(document.pluginFactory.manifest.hostCreatesManifest) + #expect(document.pluginFactory.builder.connectorTestInputUsesHopsArray) + #expect(document.pluginFactory.review.compilationSuccessNotApproval) + } + + @Test func pluginFactoryPromptsReferenceContractJSON() { + let builder = ScriptExecContractPrompts.pluginFactoryBuilderGuide() + let reviewer = ScriptExecContractPrompts.pluginFactoryReviewerGuide() + #expect(builder.contains("plugin_factory")) + #expect(builder.contains("--- script-exec-contract.json ---")) + #expect(reviewer.contains("plugin_factory.review")) + #expect(reviewer.contains("If a guest rule is not in the JSON")) + } + + @Test func fingerprintMatchesGeneratedFile() throws { + #expect(try ScriptExecContractStore.computeFingerprint() == ScriptExecContractFingerprint.sha256) + #expect(ScriptExecContractFingerprint.sourceFiles == ScriptExecContractStore.fingerprintSources) + } + + @Test func bundledContractSatisfiesItsSchema() throws { + try ScriptExecContractIntegrity.validateBundledGraph() + } + + @Test func reviewerGuideDumpsCanonicalJSON() throws { + let guide = ScriptExecContractPrompts.reviewerGuide() + #expect(guide.contains("If a rule is not in the JSON")) + #expect(guide.contains("--- script-exec-contract.json ---")) + #expect(guide.contains(try ScriptExecContractStore.loadProtocolText())) + #expect(guide.contains("--- \(GuestContract.Schema.envelopeList.rawValue) ---")) + } + + @Test func builderGuideDumpsWireSchemas() throws { + let guide = ScriptExecContractPrompts.builderGuide() + #expect(guide.contains("--- \(GuestContract.Schema.hopEvent.rawValue) ---")) + #expect(guide.contains("--- \(GuestContract.Schema.guestRuntime.rawValue) ---")) + } +} diff --git a/readme.md b/readme.md index dbf7d9f9..b8b12621 100644 --- a/readme.md +++ b/readme.md @@ -67,7 +67,7 @@ Before `script_exec` writes to disk, a **configured LLM reviewer** checks: - No secret literals in source - Safe handling of fetched content (no raw HTML leakage unless requested) -Instructions live in `ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md`. A static **Go verifier** also rejects forbidden APIs. +Rules live in `packages/Structure/Sources/Contract/Resources/contracts/script-exec-contract.json` (same pattern as connector plugins). A static **Go verifier** also rejects forbidden APIs. ### Egress & network diff --git a/scripts/generate-script-exec-contract.swift b/scripts/generate-script-exec-contract.swift new file mode 100755 index 00000000..4c1995ce --- /dev/null +++ b/scripts/generate-script-exec-contract.swift @@ -0,0 +1,121 @@ +#!/usr/bin/env swift +import CryptoKit +import Foundation + +/// Regenerates `ScriptExecContract.generated.swift` from bundled script_exec JSON. +/// `--check` exits 1 when the committed fingerprint does not match the JSON files +/// or when the JSON no longer satisfies the bundled schemas. + +let repoRoot = URL(fileURLWithPath: CommandLine.arguments[0]) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .deletingLastPathComponent() + +let resources = repoRoot + .appendingPathComponent("packages/Structure/Sources/Contract/Resources") + +let sources: [(relative: String, url: URL)] = [ + "schemas/script-exec-contract.schema.json", + "schemas/guest-runtime.schema.json", + "schemas/hop-event.schema.json", + "schemas/envelope-list.schema.json", + "contracts/script-exec-contract.json", +].map { relative in + (relative, resources.appendingPathComponent(relative)) +} + +let generatedURL = repoRoot + .appendingPathComponent("packages/Structure/Sources/Contract/Generated/ScriptExecContract.generated.swift") + +func jsonObject(_ url: URL) throws -> [String: Any] { + let data = try Data(contentsOf: url) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ScriptError("\(url.lastPathComponent) is not a JSON object.") + } + return object +} + +func requireKeys(_ object: [String: Any], _ keys: [String], file: String) throws { + for key in keys where object[key] == nil { + throw ScriptError("\(file) is missing required key \(key).") + } +} + +func validateGraph() throws { + let contractSchema = try jsonObject(resources.appendingPathComponent("schemas/script-exec-contract.schema.json")) + let contract = try jsonObject(resources.appendingPathComponent("contracts/script-exec-contract.json")) + try requireKeys(contract, contractSchema["required"] as? [String] ?? [], file: "script-exec-contract.json") + let rulesSchema = (contractSchema["properties"] as? [String: Any])?["rules"] as? [String: Any] + try requireKeys( + contract["rules"] as? [String: Any] ?? [:], + rulesSchema?["required"] as? [String] ?? [], + file: "script-exec-contract.json rules" + ) + let checks = (contract["review"] as? [String: Any])?["checks"] as? [[String: Any]] ?? [] + if checks.isEmpty { + throw ScriptError("script-exec-contract.json review.checks must not be empty.") + } + let pluginFactorySchema = (contractSchema["properties"] as? [String: Any])?["plugin_factory"] as? [String: Any] + try requireKeys( + contract["plugin_factory"] as? [String: Any] ?? [:], + pluginFactorySchema?["required"] as? [String] ?? [], + file: "script-exec-contract.json plugin_factory" + ) +} + +func fingerprint() throws -> String { + var joined = Data() + for source in sources { + let data = try Data(contentsOf: source.url) + joined.append(Data(source.relative.utf8)) + joined.append(0) + joined.append(data) + joined.append(0) + } + return SHA256.hash(data: joined).map { String(format: "%02x", $0) }.joined() +} + +func generatedSource(hash: String) -> String { + """ + // Automatically generated by scripts/generate-script-exec-contract.swift. DO NOT EDIT. + + /// SHA-256 of script_exec protocol JSON and schemas. `swift test` fails when this is stale. + public enum ScriptExecContractFingerprint: Sendable { + public static let sha256 = "\(hash)" + public static let sourceFiles: [String] = [ + \(sources.map { " \"\($0.relative)\"," }.joined(separator: "\n")) + ] + } + + """ +} + +struct ScriptError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } +} + +do { + try validateGraph() +} catch { + fputs("\(error)\n", stderr) + exit(1) +} + +let hash = try fingerprint() +let check = CommandLine.arguments.contains("--check") +if check { + let existing = try String(contentsOf: generatedURL, encoding: .utf8) + if !existing.contains("public static let sha256 = \"\(hash)\"") { + fputs("script_exec contract fingerprint is stale. Run scripts/generate-script-exec-contract.swift\n", stderr) + exit(1) + } + exit(0) +} + +try FileManager.default.createDirectory( + at: generatedURL.deletingLastPathComponent(), + withIntermediateDirectories: true +) +try generatedSource(hash: hash).write(to: generatedURL, atomically: true, encoding: .utf8) +print("Wrote \(generatedURL.path) sha256=\(hash)") diff --git a/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift b/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift index de55fe91..8a658c8e 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationPipeline.swift @@ -122,11 +122,6 @@ struct ConversationPipeline: Sen if !toolInstructions.isEmpty { sections.append(toolInstructions) } - if !toolInstructions.contains("Go guest contract:") { - if let sdk = try? PromptResources.guestSDKForModel() { - sections.append(sdk) - } - } return sections.joined(separator: "\n\n") } diff --git a/ui/SharedAgentRuntime/Conversation/ConversationPipelinePolicy.swift b/ui/SharedAgentRuntime/Conversation/ConversationPipelinePolicy.swift index 8c8c64b3..406d88bf 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationPipelinePolicy.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationPipelinePolicy.swift @@ -877,7 +877,7 @@ extension ConversationPipeline { private static func longRunningToolProgressMessage(for toolName: String) -> String? { switch toolName { case AllowedMCPTool.webCrawl.rawValue: - return "Crawling vendor documentation. This may take a few minutes…" + return "Fetching web content. This may take a few minutes…" case AllowedMCPTool.pluginFactoryBuild.rawValue: return "Building the plugin (code generation, Docker tests, and safety review). This may take several minutes…" default: diff --git a/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md b/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md index 2e186cfb..7b381d60 100644 --- a/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md +++ b/ui/SharedAgentRuntime/Resources/conversation_rag_instructions.md @@ -13,26 +13,14 @@ Use a tool when the user asks for any of: - search, look up, browse, fetch, scrape, or “from the web / online” - site-specific retail or catalog data (e.g. Amazon, “best sellers”, “top 10 … being sold”, prices, availability) -Use `script_exec` for scripting, automation, and live web access through the host HTTP bridge. - For those requests: 1. Prefer calling the tool **on the first turn** with reasonable defaults. 2. Do **not** answer with only clarifying questions when a sensible default exists (e.g. US site, general category, bestseller or top search results). State the default you used in the final answer after the tool runs. 3. Ask a clarifying question only when the request is impossible to execute without a critical missing fact (not for optional polish). 4. Never invent live rankings, prices, stock, market moves, or “what’s selling now” from training data. -5. For current events / market turmoil: fetch real articles from news or finance sites (not Google search results pages). If a scrape returns no usable content, retry with other sites before concluding data is unavailable. - -## Response format - -Always respond using the required JSON schema (`thinking` / `tool_call` / `tool_batch` / `complete`). Never reply as free-form plain text outside that schema. When presenting a list of choices, options, steps, items, or alternative paths to the user, ALWAYS format them as a clean Markdown bulleted list (using `-` or `*`) or a numbered list (using `1.`, `2.`), instead of writing them as plain paragraphs. -## Website crawling (`web.crawl`) - -- Call `web.crawl` **directly** in live chat for typical same-origin crawls; wait for the result in the same turn. -- Use `jobs_create` with `tool_name` `web.crawl` only when you judge the crawl is likely to take **more than about one minute** (large `max_pages`, deep `max_depth`, or long `timeout_seconds`). Set `wake_after: true` and a short `wake_prompt` so the user gets a notification when it finishes. - ## Scheduled jobs (`jobs_create`) - `wake_after` (default **true**): when **true**, the agent is woken after a **successful** run to summarize the tool result and the user gets a notification + result panel. When **false**, success is silent (no wake, no notification). diff --git a/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md b/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md index 6fdc0364..11753240 100644 --- a/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md +++ b/ui/SharedAgentRuntime/Resources/mcp_tool_instructions.md @@ -9,31 +9,9 @@ - Set `status` to "complete" when you have finished and are responding directly to the user, and populate the `assistant_response` field with your Markdown reply. - Pass tool `arguments` as a **stringified JSON object** under the `arguments` key (schema requirement). Prefer short Go source and avoid embedding unescaped double quotes in the script body. 6. Users should not have to name tools. Choose tools autonomously from intent. -7. Use `files.extract` for attached PDFs, Office documents, HTML, CSV, and Excel. Use `script_exec` for other scripting/automation. Use `web.crawl` for live website access. - 1. For `script_exec`, use standalone **Go** (`package main`) only. Read one hop-event JSON object from standard input and write an envelope-list JSON **array** to standard output. Do not import net/http, net, os/exec, or perform filesystem access. No third-party modules. - 2. The container has no network. Emit `http.request` envelopes; the host performs HTTP and invokes the guest program again with an `http_results` event. - 3. On the first hop emit `{"verb":"http.request","request_id":"…","method":"GET","url":"…"}`. On `http_results`, parse the supplied UTF-8 body and emit `result.emit` or `message.post`. - 4. The script must complete the user's requested extraction or summary, not only prove that a fetch happened. Never emit `repr(http_results)` or copy an entire fetched body into `content` unless the user explicitly requested the raw source. For HTML/XML, remove scripts and styles, extract the relevant visible fields, normalize the text, and cap the result. If raw HTML is explicitly requested, emit it in `html`; the host sanitizes that field. - 5. Prefer content sites. Do **not** scrape Google/Bing/Yahoo SERP HTML. - 6. Keep scripts short. Use `timeout_seconds` on the tool args if needed. - 7. If the first fetch is empty, try another `script_exec` with different URLs before answering. - 8. If `script_exec` returns `blocked` or `failed` with implementation findings, treat those - findings as correction feedback and make at most one corrected `script_exec` call before - answering. Do not repeat the same script unchanged. If the reviewer identifies a security - refusal or the corrected call also fails, report the exact finding instead of claiming success. -9. Use `web.crawl` for website crawling instead of generating a crawler script. Call it directly - in live chat so the result returns in the same turn. Only submit a crawl through `jobs_create` - when you judge it is likely to take more than about one minute (for example a large `max_pages`, - deep `max_depth`, many start URLs, or a long `timeout_seconds`). For background crawls use - `tool_name` `web.crawl`, `wake_after` true, and a short `wake_prompt`; tell the user the crawl - was submitted and that the result will arrive in a notification banner. Never request more than - 900 seconds. -10. A web crawl goal must describe the requested result. Never use it for DDoS, flooding, - load/stress testing, port scanning, brute force, or other high-volume behavior. Keep the - crawl same-origin and rely on the tool's page, depth, byte, rate, and timeout limits. -11. Use `files.extract` for attached files instead of generating an extractor script. Call it directly; do not submit it through `jobs_create`. Omit `filenames` to process every attached file in this chat. Never request more than 180 seconds. -12. After tool execution, respond with clean user-facing output only (Markdown/JSON/CSV as requested); do not include raw tool-call JSON, escaped script source, or internal control payloads. -13. Multi-agent tools (when listed in the catalog): +7. Use `files.extract` for attached files, `script_exec` for other scripting/automation, and `web.crawl` for live website access. Obey the bundled guest SDK (`script-exec-contract.json`) for `script_exec`; obey the web crawler and file extractor skills for those tools. +8. After tool execution, respond with clean user-facing output only (Markdown/JSON/CSV as requested); do not include raw tool-call JSON, escaped script source, or internal control payloads. +9. Multi-agent tools (when listed in the catalog): 1. If the user names a multi-agent tool or asks to spawn/list/send/cancel agents, issue that `tool_call` (or `tool_batch`) **before** any `complete` answer. Do not invent tool results. 2. `agents_spawn` — required args: `goal` (short), `task` (concrete). Blocks until the worker finishes; use the returned `result` in your next step. Optional `agent_id` slug. 3. Workers never talk to the user; you synthesize worker results into the final `assistant_response`. diff --git a/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md b/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md deleted file mode 100644 index 10b3640d..00000000 --- a/ui/SharedAgentRuntime/Resources/script_reviewer_instructions.md +++ /dev/null @@ -1,37 +0,0 @@ -You are a reviewer for script_exec declarations. - -FAIL-FAST (mandatory): -- Apply checks in order. As soon as ANY single check fails, return with failure JSON immediately. -- Do NOT continue scanning for more issues after the first failure. -- On first failure: return suggestedAction "deny", alignedWithRequest false, concerns with only that one failing reason, and a short summary. No essays. -- Only if every check passes: return suggestedAction "allow" with a brief summary (1-2 sentences). concerns may be empty or at most one minor operational note. - -Checks (if any fail return failure JSON immediately): -1) Script, description, reason, and user prompt are consistent (intent alignment). - Derrick has a job scheduler (jobs_create / run_after_seconds / cron). Timing is applied by - JobService before this script runs. The script must do the work immediately when invoked. - Words like delayed, scheduled, in 7 seconds, later, or run_after in the reason or user_prompt - refer to that scheduler — not to sleep/setTimeout inside the script. Do not deny a script - that performs the requested work (e.g. netFetch the URL) just because it has no delay. -2) No tokens, API keys, passwords, or other secret literals in the source. - -3) The script implements the requested terminal result, not just the fetch. - For requests to summarize, list, inspect, or extract fetched content, the `http_results` branch - must parse the relevant response body and emit the requested data. Do not allow - `repr(http_results)`, a fetch-only confirmation, or an entire raw body copied to - `content` unless the user explicitly requested the raw source. If raw HTML is requested, `html` - is allowed because the host sanitizes it before rendering. -4) Fetched HTML/XML is treated as untrusted data. Text and Markdown results must remove unsafe - markup and validate generated links as http or https. Do not reject ordinary safe HTML in the - `html` field solely because the host performs the final sanitization. - -Do not deny for Go style, envelope construction, destination URLs, or the absence of dependencies. The static Go verifier enforces direct network and process restrictions. The guest has no network; the host performs HTTP and applies SSRF there. - -Return only valid JSON with this exact schema: -{ - "alignedWithRequest": true|false, - "confidence": 0.0-1.0, - "suggestedAction": "allow"|"deny", - "concerns": ["..."], - "summary": "short explanation" -} diff --git a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift index 9153314a..225f4294 100644 --- a/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift +++ b/ui/SharedAgentRuntime/Support/PluginFactoryModels.swift @@ -131,49 +131,8 @@ actor ConfiguredPluginFactoryBuilder: PluginFactoryBuilder { private static func builderSystemPrompt(for userGoal: String) -> String { """ You are the Derrick plugin builder. Convert the user's goal into one complete Agent Plugin draft. - Return exactly one JSON object with these keys: - plugin_id (string), version (string), description (string), go_source (string), - test_input_json (string containing valid JSON — a serialized object, not prose), - skill_files (array of objects with path and body), - secrets (array of objects with id, label, and kind; required for connector plugins), - role (string, optional: "connector" or "standard"). - plugin_id must use lowercase letters, numbers, hyphens, and dots only - (for example my-connector). Never use underscores in plugin_id. - If the plugin needs a username, password, token, or API key, declare them in secrets. - kind must be username, password, token, or api_key. id is a stable Keychain key - such as username or bot_token. label is the text shown when the user saves the value. - Never put real credentials in go_source. - Set role to "connector" when the plugin sends and receives messages with an external - messaging service (any chat or mail connector). Omit role or use "standard" otherwise. - For role connector, include messaging_ops: an array of implemented ops - (send_message, poll_inbox, sync_threads). It must match the user goal scope and test_input_json. - The host writes messaging_ops into extensions.app.derrick in plugin.json. - The host lists connector plugins under Messaging. Do not guess this from the plugin_id. - Do not return manifest_json. The host creates the canonical Agent Plugin manifest, - including the exact `$schema` field for Agent Plugin 1.0 and the fixed - extensions.app.derrick.entrypoint ./app.derrick/plugin.go. - \(DerrickGuestGo.modelContract) + \(ScriptExecContractPrompts.pluginFactoryBuilderGuide()) \(ConnectorContractPrompts.builderGuide(forUserGoal: userGoal)) - Before returning the draft, self-check the implementation: - - Sort every returned collection by an explicit stable key after parsing and de-duplicate it. - - Match host responses by the emitted request_id. - - Use only the Go standard library (no third-party modules, raw sockets, or subprocess). - - The direct test input must exercise the terminal result path with matching http_results fixtures. - If skill_files is not needed, return an empty array. Every skill file path must be exactly - skills//SKILL.md. - For messaging connector plugins (role connector) that call a vendor HTTP API: - - Declare secrets in the manifest only. Never hard-code credentials. - - Parse each http_results body as JSON when the vendor returns JSON. - - test_input_json http_results must exercise success paths for every messaging_op in scope. - - test_input_json must be a single JSON object serialized as a string (valid JSON.parse input). - - test_input_json must not be empty or "{}". - - For connector plugins, test_input_json must use a hops array: - {"hops":[{"kind":"message_in_room","params":{"messaging_op":"send_message",...}},\ - {"kind":"http_results","http_results":[{"request_id":"...","status":200,"body":"..."}],\ - "params":{...}}]} - Repeat additional hop pairs for each messaging_op in scope. request_id values in fixtures must \ - match the http.request envelopes your go_source emits. - - Match http_results by request_id and de-duplicate with stable sorting; never depend on response order. When vendor documentation is supplied in the user prompt, use it only to fill may_call HTTP details. """ } @@ -366,22 +325,9 @@ actor ConfiguredPluginSafetyReviewer: PluginFactoryReviewer { private static func reviewerSystemPrompt(for userGoal: String?) -> String { """ - You are Derrick's independent plugin alignment and safety reviewer. Review the user's goal, manifest, test_input_json, exact Go source, and direct test output. - Return exactly one JSON object: - {"decision":"approved|rejected","summary":"...","findings":[ - {"severity":"info|warning|blocking","category":"alignment|safety|correctness|privacy|supplyChain","message":"..."} - ]} - Reject unsafe, misleading, unrelated, non-deterministic, credential-seeking, or policy-bypassing code. - Apply these checks from observable evidence: - - A deterministic result uses stable sorting and de-duplication and does not depend on response order, current time, randomness, or UUIDs. - - Source-derived headline titles may be fragments; only generated explanatory summaries must be complete sentences when the manifest requires prose. - - `result.emit.html` is an allowed output format. Derrick sanitizes it with an allowlist before rendering. Reject executable script behavior or a deliberate sanitizer bypass, not ordinary safe HTML tags. - - Reject missing source-grounded parsing or claims that the direct test output does not support. - - For connector plugins, obey the connector protocol JSON below. If a rule is not in that JSON, do not require it. + \(ScriptExecContractPrompts.pluginFactoryReviewerGuide()) \(ConnectorContractPrompts.reviewerGuide(forUserGoal: userGoal)) - Compilation success is not approval. Do not rewrite the code or approve a draft that fails these checks. - Reject non-Go source, raw network usage outside http.request envelopes, or missing stdin reads. """ } diff --git a/ui/ui/Views/PluginHTMLResultExtractor.swift b/ui/ui/Views/PluginHTMLResultExtractor.swift index 77e7d828..cbe041a7 100644 --- a/ui/ui/Views/PluginHTMLResultExtractor.swift +++ b/ui/ui/Views/PluginHTMLResultExtractor.swift @@ -57,6 +57,7 @@ enum PluginResultExtractor { let format = envelope.payload["format"]?.stringValue?.lowercased() let candidate = envelope.payload["content"]?.stringValue ?? envelope.payload["summary"]?.stringValue + ?? envelope.payload["markdown"]?.stringValue ?? envelope.payload["text"]?.stringValue ?? "" guard !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -320,6 +321,7 @@ enum PluginResultExtractor { let candidate = item["content"] as? String ?? item["summary"] as? String + ?? item["markdown"] as? String ?? item["text"] as? String ?? "" guard !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/ui/uiTests/PromptResourcesTests.swift b/ui/uiTests/PromptResourcesTests.swift index c63e47a5..0c9990a0 100644 --- a/ui/uiTests/PromptResourcesTests.swift +++ b/ui/uiTests/PromptResourcesTests.swift @@ -77,7 +77,7 @@ import Testing #expect(source.contains("package main")) let wrapped = try PromptResources.guestSDKForModel() #expect(wrapped.contains("```go")) - #expect(wrapped.contains("Go guest contract")) + #expect(wrapped.contains("script-exec-contract.json")) } @Test func throwsWhenConversationRAGInstructionsAreMissing() throws { diff --git a/workers/go/internal/contract/schemas/script-exec-contract.schema.json b/workers/go/internal/contract/schemas/script-exec-contract.schema.json new file mode 100644 index 00000000..2e369012 --- /dev/null +++ b/workers/go/internal/contract/schemas/script-exec-contract.schema.json @@ -0,0 +1,346 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/script-exec-contract.json", + "title": "Derrick script_exec protocol document", + "description": "Canonical guest runtime, workflow, output, agent, and reviewer rules for script_exec and offline Go guests.", + "type": "object", + "required": [ + "version", + "runtime", + "io", + "workflow", + "output", + "agent", + "review", + "rules", + "plugin_factory" + ], + "additionalProperties": false, + "properties": { + "version": { "type": "integer", "const": 1 }, + "runtime": { "$ref": "#/$defs/runtime" }, + "io": { "$ref": "#/$defs/io" }, + "workflow": { "$ref": "#/$defs/workflow" }, + "output": { "$ref": "#/$defs/output" }, + "agent": { "$ref": "#/$defs/agent" }, + "review": { "$ref": "#/$defs/review" }, + "rules": { "$ref": "#/$defs/rules" }, + "plugin_factory": { "$ref": "#/$defs/plugin_factory" } + }, + "$defs": { + "runtime": { + "type": "object", + "required": [ + "language", + "package", + "binary", + "stdlib_only", + "forbidden_imports", + "allowed_os_usage", + "example_go" + ], + "additionalProperties": false, + "properties": { + "language": { "type": "string", "const": "go" }, + "package": { "type": "string", "const": "main" }, + "binary": { "type": "string", "const": "/tmp/guest" }, + "stdlib_only": { "type": "boolean" }, + "forbidden_imports": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "allowed_os_usage": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "example_go": { "type": "string", "minLength": 1 } + } + }, + "io": { + "type": "object", + "required": ["stdin_schema", "stdout_schema", "guest_runtime_schema"], + "additionalProperties": false, + "properties": { + "stdin_schema": { "type": "string", "const": "hop-event.schema.json" }, + "stdout_schema": { "type": "string", "const": "envelope-list.schema.json" }, + "guest_runtime_schema": { "type": "string", "const": "guest-runtime.schema.json" } + } + }, + "workflow": { + "type": "object", + "required": [ + "first_hop_verbs", + "http_results_event_kind", + "terminal_verbs", + "match_http_by", + "http_results_accumulate", + "post_body_field" + ], + "additionalProperties": false, + "properties": { + "first_hop_verbs": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "http_results_event_kind": { "type": "string" }, + "terminal_verbs": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "match_http_by": { "type": "string", "const": "request_id" }, + "http_results_accumulate": { "type": "boolean" }, + "post_body_field": { "type": "string", "const": "json" } + } + }, + "output": { + "type": "object", + "required": ["fields", "forbidden_patterns", "raw_body_in_content_requires_explicit_user_request"], + "additionalProperties": false, + "properties": { + "fields": { + "type": "object", + "required": ["content", "summary", "html", "markdown"], + "additionalProperties": false, + "properties": { + "content": { "$ref": "#/$defs/output_field" }, + "summary": { "$ref": "#/$defs/output_field" }, + "html": { "$ref": "#/$defs/output_field" }, + "markdown": { "$ref": "#/$defs/output_field" } + } + }, + "forbidden_patterns": { + "type": "array", + "items": { "type": "string" } + }, + "raw_body_in_content_requires_explicit_user_request": { "type": "boolean" } + } + }, + "output_field": { + "type": "object", + "required": ["purpose"], + "additionalProperties": true, + "properties": { + "purpose": { "type": "string" }, + "host_strips_incidental_markup": { "type": "boolean" }, + "host_allowlist_sanitizes": { "type": "boolean" }, + "prefer_content_for_extracted_text": { "type": "boolean" } + } + }, + "agent": { + "type": "object", + "required": [ + "prefer_direct_urls_over_serp_html", + "retry_with_different_urls_on_empty_fetch", + "max_correction_attempts_after_block" + ], + "additionalProperties": false, + "properties": { + "prefer_direct_urls_over_serp_html": { "type": "boolean" }, + "retry_with_different_urls_on_empty_fetch": { "type": "boolean" }, + "max_correction_attempts_after_block": { "type": "integer", "minimum": 0 } + } + }, + "review": { + "type": "object", + "required": [ + "fail_fast", + "response_schema", + "checks", + "do_not_deny_for", + "enforced_elsewhere" + ], + "additionalProperties": false, + "properties": { + "fail_fast": { "type": "boolean" }, + "response_schema": { + "type": "object", + "required": [ + "alignedWithRequest", + "confidence", + "suggestedAction", + "concerns", + "summary" + ], + "additionalProperties": false, + "properties": { + "alignedWithRequest": { "type": "string" }, + "confidence": { "type": "string" }, + "suggestedAction": { "type": "string" }, + "concerns": { "type": "string" }, + "summary": { "type": "string" } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/review_check" } + }, + "do_not_deny_for": { + "type": "array", + "items": { "type": "string" } + }, + "enforced_elsewhere": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "review_check": { + "type": "object", + "required": ["id", "order", "description"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "order": { "type": "integer", "minimum": 1 }, + "description": { "type": "string" }, + "notes": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "rules": { + "type": "object", + "required": [ + "guest_has_no_network", + "host_performs_http", + "host_applies_ssrf", + "static_verifier_enforces_imports", + "scheduler_timing_not_in_script", + "deterministic_output_required", + "stable_sort_and_dedupe_collections", + "no_time_random_uuid_for_visible_output", + "match_http_results_by_request_id" + ], + "additionalProperties": false, + "properties": { + "guest_has_no_network": { "type": "boolean" }, + "host_performs_http": { "type": "boolean" }, + "host_applies_ssrf": { "type": "boolean" }, + "static_verifier_enforces_imports": { "type": "boolean" }, + "scheduler_timing_not_in_script": { "type": "boolean" }, + "deterministic_output_required": { "type": "boolean" }, + "stable_sort_and_dedupe_collections": { "type": "boolean" }, + "no_time_random_uuid_for_visible_output": { "type": "boolean" }, + "match_http_results_by_request_id": { "type": "boolean" } + } + }, + "plugin_factory": { + "type": "object", + "required": ["manifest", "builder", "review"], + "additionalProperties": false, + "properties": { + "manifest": { "$ref": "#/$defs/plugin_factory_manifest" }, + "builder": { "$ref": "#/$defs/plugin_factory_builder" }, + "review": { "$ref": "#/$defs/plugin_factory_review" } + } + }, + "plugin_factory_manifest": { + "type": "object", + "required": [ + "host_creates_manifest", + "do_not_return_manifest_json", + "agent_plugin_schema", + "entrypoint", + "plugin_id_allowed_chars", + "plugin_id_forbidden_chars", + "roles", + "connector_messaging_ops", + "secret_kinds", + "never_embed_credentials_in_go_source", + "host_lists_connectors_under_messaging" + ], + "additionalProperties": false, + "properties": { + "host_creates_manifest": { "type": "boolean" }, + "do_not_return_manifest_json": { "type": "boolean" }, + "agent_plugin_schema": { "type": "string" }, + "entrypoint": { "type": "string" }, + "plugin_id_allowed_chars": { "type": "string" }, + "plugin_id_forbidden_chars": { + "type": "array", + "items": { "type": "string" } + }, + "roles": { + "type": "array", + "items": { "type": "string" } + }, + "connector_messaging_ops": { + "type": "array", + "items": { "type": "string" } + }, + "secret_kinds": { + "type": "array", + "items": { "type": "string" } + }, + "never_embed_credentials_in_go_source": { "type": "boolean" }, + "host_lists_connectors_under_messaging": { "type": "boolean" } + } + }, + "plugin_factory_builder": { + "type": "object", + "required": [ + "response_keys", + "skill_files_path_pattern", + "empty_skill_files_when_unused", + "test_input_is_serialized_json_object", + "test_input_must_not_be_empty", + "test_input_exercises_terminal_result", + "connector_test_input_uses_hops_array", + "connector_parse_json_http_results_when_vendor_returns_json" + ], + "additionalProperties": false, + "properties": { + "response_keys": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "skill_files_path_pattern": { "type": "string" }, + "empty_skill_files_when_unused": { "type": "boolean" }, + "test_input_is_serialized_json_object": { "type": "boolean" }, + "test_input_must_not_be_empty": { "type": "boolean" }, + "test_input_exercises_terminal_result": { "type": "boolean" }, + "connector_test_input_uses_hops_array": { "type": "boolean" }, + "connector_parse_json_http_results_when_vendor_returns_json": { "type": "boolean" } + } + }, + "plugin_factory_review": { + "type": "object", + "required": [ + "compilation_success_not_approval", + "response_schema", + "reject_non_go_source", + "reject_raw_network_outside_http_request_envelopes", + "reject_missing_stdin_read", + "source_derived_titles_may_be_fragments", + "reject_unsupported_direct_test_claims", + "connector_rules_document" + ], + "additionalProperties": false, + "properties": { + "compilation_success_not_approval": { "type": "boolean" }, + "response_schema": { + "type": "object", + "required": ["decision", "summary", "findings"], + "additionalProperties": false, + "properties": { + "decision": { "type": "string" }, + "summary": { "type": "string" }, + "findings": { "type": "string" } + } + }, + "reject_non_go_source": { "type": "boolean" }, + "reject_raw_network_outside_http_request_envelopes": { "type": "boolean" }, + "reject_missing_stdin_read": { "type": "boolean" }, + "source_derived_titles_may_be_fragments": { "type": "boolean" }, + "reject_unsupported_direct_test_claims": { "type": "boolean" }, + "connector_rules_document": { "type": "string" } + } + } + } +} From 1bad7a397e6db372598f02b19b14cffc87eb589f Mon Sep 17 00:00:00 2001 From: David Choi Date: Wed, 9 Sep 2026 23:27:06 -0400 Subject: [PATCH 05/17] update init modal --- .../Support/AppBootstrapStatus.swift | 100 +++++++++++++++++- .../DockerRunner/XPCDockerRunner.swift | 9 ++ ui/ui/Views/ContentView.swift | 71 ++++++------- ui/uiTests/AppBootstrapStatusTests.swift | 29 +++++ 4 files changed, 169 insertions(+), 40 deletions(-) diff --git a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift index c75b9b42..e5016dab 100644 --- a/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift +++ b/ui/SharedAgentRuntime/Support/AppBootstrapStatus.swift @@ -18,8 +18,44 @@ final class AppBootstrapStatus: ObservableObject { case failed } + /// Parallel bootstrap steps shown in the init modal. Completed steps are removed from the list. + enum TaskID: String, Sendable, CaseIterable, Equatable { + case daemon + case database + case docker + case workerImage + + var sortOrder: Int { + switch self { + case .daemon: return 0 + case .database: return 1 + case .docker: return 2 + case .workerImage: return 3 + } + } + + var defaultMessage: String { + switch self { + case .daemon: + return "Connecting to Derrick daemon…" + case .database: + return "Opening local database…" + case .docker: + return "Checking Docker Desktop…" + case .workerImage: + return "Preparing worker image…" + } + } + } + + struct LoadingTask: Identifiable, Equatable, Sendable { + let id: TaskID + var message: String + } + @Published private(set) var phase: Phase = .idle @Published private(set) var statusMessage: String = "Starting…" + @Published private(set) var activeLoadingTasks: [LoadingTask] = [] @Published private(set) var failureTitle: String? @Published private(set) var failureMessage: String? /// Extra recovery control on the failure modal (for example Open Login Items). @@ -43,6 +79,7 @@ final class AppBootstrapStatus: ObservableObject { deferModalPresentation = false phase = .idle statusMessage = "Starting…" + activeLoadingTasks = [] failureTitle = nil failureMessage = nil failureRecovery = .none @@ -108,6 +145,7 @@ final class AppBootstrapStatus: ObservableObject { deferModalPresentation = deferModal phase = .loadingSession statusMessage = "Loading session store…" + activeLoadingTasks = [] failureTitle = nil failureMessage = nil failureRecovery = .none @@ -138,6 +176,37 @@ final class AppBootstrapStatus: ObservableObject { } } + func beginTask(_ id: TaskID, message: String? = nil) { + guard isInitializing else { return } + let label = message ?? id.defaultMessage + if let index = activeLoadingTasks.firstIndex(where: { $0.id == id }) { + activeLoadingTasks[index].message = label + } else { + activeLoadingTasks.append(LoadingTask(id: id, message: label)) + activeLoadingTasks.sort { $0.id.sortOrder < $1.id.sortOrder } + } + if !deferModalPresentation { + isModalPresented = true + } + debugLog("[bootstrap] task begin \(id.rawValue): \(label)") + } + + func updateTask(_ id: TaskID, message: String) { + guard isInitializing else { return } + if let index = activeLoadingTasks.firstIndex(where: { $0.id == id }) { + activeLoadingTasks[index].message = message + } else { + beginTask(id, message: message) + } + debugLog("[bootstrap] task update \(id.rawValue): \(message)") + } + + func completeTask(_ id: TaskID) { + guard activeLoadingTasks.contains(where: { $0.id == id }) else { return } + activeLoadingTasks.removeAll { $0.id == id } + debugLog("[bootstrap] task complete \(id.rawValue)") + } + func update(phase: Phase, message: String) { // Never re-open the modal after ready (parallel service ensure-up must not reflash it). if self.phase == .ready, phase != .failed, phase != .ready { @@ -148,23 +217,45 @@ final class AppBootstrapStatus: ObservableObject { // beats "Opening local database…" while XPC is still retrying). if isInitializing, Self.phasePriority(phase) < Self.phasePriority(self.phase) { debugLog("[bootstrap] ignore lower-priority phase=\(phase.rawValue) while \(self.phase.rawValue): \(message)") - return + } else { + // Don't let a cancelled re-entrant task demote ready via failed paths above. + self.phase = phase + self.statusMessage = message } - // Don't let a cancelled re-entrant task demote ready via failed paths above. - self.phase = phase - self.statusMessage = message + syncLoadingTask(for: phase, message: message) if !deferModalPresentation { isModalPresented = true } debugLog("[bootstrap] phase=\(phase.rawValue) \(message)") } + private func syncLoadingTask(for phase: Phase, message: String) { + guard isInitializing else { return } + switch phase { + case .connectingHelper: + beginTask(.daemon, message: message) + case .loadingSession: + if message.localizedCaseInsensitiveContains("database") { + beginTask(.database, message: message) + } + case .checkingDocker: + beginTask(.docker, message: message) + case .preparingImage: + beginTask(.workerImage, message: message) + case .verifyingEnvironment: + completeTask(.workerImage) + default: + break + } + } + func markReady() { deferredModalRevealTask?.cancel() deferredModalRevealTask = nil deferModalPresentation = false phase = .ready statusMessage = "Ready" + activeLoadingTasks = [] failureTitle = nil failureMessage = nil failureRecovery = .none @@ -208,6 +299,7 @@ final class AppBootstrapStatus: ObservableObject { deferModalPresentation = false phase = .idle statusMessage = "Starting…" + activeLoadingTasks = [] failureTitle = nil failureMessage = nil failureRecovery = .none diff --git a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift index d1177e77..b08dedf1 100644 --- a/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift +++ b/ui/SharedAgentRuntime/Support/DockerRunner/XPCDockerRunner.swift @@ -307,6 +307,7 @@ public final class XPCDockerRunner: @unchecked Sendable { ) } dockerReachableState.markCompleted() + await reportBootstrapTaskCompleted(.docker) Task { await prewarmWorkerImage() } @@ -413,6 +414,14 @@ public final class XPCDockerRunner: @unchecked Sendable { } } + private func reportBootstrapTaskCompleted(_ id: AppBootstrapStatus.TaskID) async { + await MainActor.run { + let status = AppBootstrapStatus.shared + guard status.isInitializing else { return } + status.completeTask(id) + } + } + deinit { connection.invalidate() } diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index f4e27839..1380c2d2 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -596,7 +596,7 @@ struct ContentView: View { minWidth: 380, minHeight: 0, maxWidth: 440, - maxHeight: bootstrapStatus.phase == .failed ? 420 : 280, + maxHeight: bootstrapModalMaxHeight, onBackdropDismiss: bootstrapStatus.phase == .failed ? { bootstrapStatus.dismissFailure() } : nil, @@ -605,10 +605,7 @@ struct ContentView: View { : nil, header: { HStack(spacing: 10) { - if bootstrapStatus.showsProgressIndicator { - ProgressView() - .controlSize(.small) - } else if bootstrapStatus.phase == .failed { + if bootstrapStatus.phase == .failed { Image(systemName: ModalChrome.bootstrapFailureSymbol) .font(ModalChrome.symbolFont) .symbolRenderingMode(.hierarchical) @@ -626,23 +623,29 @@ struct ContentView: View { }, body: { VStack(alignment: .leading, spacing: 12) { - Text(bootstrapStatus.statusMessage) - .font(.body) - .foregroundStyle(.primary) - .fixedSize(horizontal: false, vertical: true) - - if bootstrapStatus.phase == .failed, let detail = bootstrapStatus.failureMessage, - detail != bootstrapStatus.statusMessage { - Text(detail) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + if bootstrapStatus.isInitializing { + VStack(alignment: .leading, spacing: 10) { + ForEach(bootstrapStatus.activeLoadingTasks) { task in + HStack(alignment: .top, spacing: 10) { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + .padding(.top, 2) + Text(task.message) + .font(.body) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .animation(.easeOut(duration: 0.2), value: bootstrapStatus.activeLoadingTasks) } - if bootstrapStatus.isInitializing { - Text(bootstrapProgressHint) - .font(.caption) - .foregroundStyle(.secondary) + if bootstrapStatus.phase == .failed { + Text(bootstrapStatus.failureMessage ?? bootstrapStatus.statusMessage) + .font(.body) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) } } .padding(.horizontal, 20) @@ -720,17 +723,12 @@ struct ContentView: View { .background(WindowConfigurator()) } - private var bootstrapProgressHint: String { - switch bootstrapStatus.phase { - case .preparingImage, .checkingDocker, .verifyingEnvironment: - return "Keep Docker Desktop running. The worker image may finish building in the background." - case .connectingHelper: - return "Starting the background helper…" - case .loadingSession: - return "Loading your local workspace…" - default: - return "Starting…" + private var bootstrapModalMaxHeight: CGFloat { + if bootstrapStatus.phase == .failed { + return 420 } + let rowCount = max(bootstrapStatus.activeLoadingTasks.count, 1) + return CGFloat(120 + rowCount * 34) } @MainActor @@ -761,23 +759,23 @@ struct ContentView: View { do { bootstrapStatus.update(phase: .loadingSession, message: "Starting Derrick…") + bootstrapStatus.beginTask(.daemon) + bootstrapStatus.beginTask(.database) + bootstrapStatus.beginTask(.docker) // Docker reachability, daemon, and DB are independent — run in parallel. let dockerPeerTask = Task { try await prewarmLaunchDockerPeer() } async let health = connectLaunchDaemon() async let repo = loadLaunchRepository() - bootstrapStatus.update( - phase: .connectingHelper, - message: "Connecting to Derrick daemon…" - ) let healthResult = try await health + bootstrapStatus.completeTask(.daemon) debugLog( "Daemon ensure-up ok status=\(healthResult.status.rawValue) pid=\(healthResult.pid) runtime=\(healthResult.guestRuntimeImage ?? "?") detail=\(healthResult.detail ?? "")" ) - bootstrapStatus.update(phase: .loadingSession, message: "Opening local database…") let repoResult = try await repo + bootstrapStatus.completeTask(.database) sessionReady = true bootstrapStatus.markReady() @@ -856,9 +854,10 @@ struct ContentView: View { /// Prewarm Docker in parallel with daemon + DB. Only peer handoff needs both daemon XPC and Docker. @MainActor private func prewarmLaunchDockerPeer() async throws -> NSXPCListenerEndpoint? { - bootstrapStatus.update(phase: .checkingDocker, message: "Starting Docker runtime…") + bootstrapStatus.updateTask(.docker, message: "Starting Docker runtime…") _ = XPCDockerRunner.shared try await XPCDockerRunner.shared.waitUntilDockerReachable() + bootstrapStatus.completeTask(.docker) do { return try await XPCDockerRunner.shared.fetchPeerListenerEndpoint() } catch { diff --git a/ui/uiTests/AppBootstrapStatusTests.swift b/ui/uiTests/AppBootstrapStatusTests.swift index a91072d0..007d27a1 100644 --- a/ui/uiTests/AppBootstrapStatusTests.swift +++ b/ui/uiTests/AppBootstrapStatusTests.swift @@ -184,6 +184,35 @@ import Testing #expect(status.statusMessage == "Connecting to Derrick daemon…") } + @MainActor + @Test func parallelLoadingTasksTrackIndependently() { + let status = freshStatus() + #expect(status.beginLoadingSession()) + status.beginTask(.daemon) + status.beginTask(.database) + status.beginTask(.docker) + #expect(status.activeLoadingTasks.map(\.id) == [.daemon, .database, .docker]) + status.completeTask(.database) + #expect(status.activeLoadingTasks.map(\.id) == [.daemon, .docker]) + status.completeTask(.daemon) + status.completeTask(.docker) + #expect(status.activeLoadingTasks.isEmpty) + } + + @MainActor + @Test func phaseUpdateAddsMatchingLoadingTasks() { + let status = freshStatus() + #expect(status.beginLoadingSession()) + status.update(phase: .connectingHelper, message: "Connecting to Derrick daemon…") + status.update(phase: .loadingSession, message: "Opening local database…") + status.update(phase: .checkingDocker, message: "Checking Docker Desktop…") + #expect(status.activeLoadingTasks.map(\.id) == [.daemon, .database, .docker]) + status.update(phase: .preparingImage, message: "Preparing worker image…") + #expect(status.activeLoadingTasks.map(\.id) == [.daemon, .database, .docker, .workerImage]) + status.update(phase: .verifyingEnvironment, message: "Worker image ready.") + #expect(status.activeLoadingTasks.map(\.id) == [.daemon, .database, .docker]) + } + @MainActor @Test func cancelClearsInProgressModal() { let status = freshStatus() From aff3459e2f5ef7bd806ceb41190de5591e57ba51 Mon Sep 17 00:00:00 2001 From: David Choi Date: Thu, 10 Sep 2026 23:52:28 -0400 Subject: [PATCH 06/17] fix news reader --- CONTRIBUTING.md | 3 +- THIRD_PARTY_NOTICES.md | 2 +- docker/guest-runtime/Dockerfile | 15 - docker/worker/Dockerfile | 4 + master-todo.md | 2 +- .../DBRepository/DBRepositoryNews.swift | 19 +- .../Migrations/0005_news_readers.up.sql | 1 + .../DBRepositoryTests/DBNewsReaderTests.swift | 6 +- .../PluginFactoryCreateWorkflow.swift | 127 ++- .../DockerRunRequestValidator.swift | 1 + .../DockerRunnerXPCTests.swift | 3 +- .../MCPServer/DockerImageInspector.swift | 21 + .../DockerProductImagePrewarmer.swift | 13 +- .../MCPServer/NewsReaderDockerExecutor.swift | 115 +++ .../NewsReaderDockerInputPreparer.swift | 43 + .../Tests/MCPServerTests/MCPServerTests.swift | 62 +- .../Factory/PluginFactoryImplementation.swift | 6 +- .../PluginTests/PluginFactoryTests.swift | 111 ++- .../AppServices/DerrickBundledText.swift | 2 +- .../AppServices/ServiceHealth.swift | 3 - .../InProcessServiceBridges.swift | 3 + .../MCPService/MCPServiceXPC.swift | 10 + .../MCPService/MCPToolCallTimeouts.swift | 3 + .../MCPService/PluginFactoryCreateInput.swift | 95 +- .../News/NewsFeedParser.swift | 154 --- .../News/NewsReaderModels.swift | 76 +- .../News/NewsReaderRefresh.swift | 195 ++-- .../News/NewsReaderWorkerTypes.swift | 72 ++ .../News/NewsWorkerBridge.swift | 30 + .../Plugin/PluginSkillDraft.swift | 447 +++++++++ .../Sources/Contract/GuestContract.swift | 1 + .../schemas/news-reader-result.schema.json | 7 + .../schemas/worker-product.schema.json | 31 +- .../DerrickDockerRuntimeIdentity.swift | 1 + .../DockerProductImageDigests.generated.swift | 2 +- .../DockerRunnerXPC/DockerWorkerRuntime.swift | 12 + .../Factory/PluginFactoryRuntimeTypes.swift | 28 + .../Plugin/Factory/PluginFactoryTypes.swift | 8 +- .../Plugin/Manifest/DerrickRuntime.swift | 4 +- .../Plugin/Manifest/PluginManifestError.swift | 2 +- .../AppLayerServicesWireTests.swift | 24 +- .../StructureTests/NewsReaderTests.swift | 193 +--- .../PluginSkillDraftTests.swift | 118 +++ scripts/reset-local-state.sh | 48 + ui/JobKeepAlive/DaemonModuleBootstrap.swift | 5 +- ui/JobKeepAlive/DaemonUnifiedXPC.swift | 4 + ui/MCPService/MCPServiceExportedObject.swift | 17 + ui/MCPService/MCPServiceToolHost.swift | 42 + .../Services/MCPServiceClient.swift | 19 + ui/ui/News/MCPServiceNewsWorker.swift | 23 + ui/ui/News/NewsReaderStore.swift | 44 +- ui/ui/News/NewsReaderSummarizer.swift | 65 ++ ui/ui/News/NewsWorkspaceView.swift | 80 +- ui/ui/Plugins/PluginCreationController.swift | 596 ++++++------ ui/ui/Plugins/PluginsWorkspaceView.swift | 890 +++++++++++------- ui/ui/Views/ContentView.swift | 5 +- ui/uiTests/NewsReaderFlowTests.swift | 58 ++ workers/go/cmd/derrick-news-reader/main.go | 97 ++ workers/go/internal/contract/contract.go | 5 + .../schemas/news-reader-result.schema.json | 7 + .../schemas/worker-product.schema.json | 31 +- workers/go/internal/newsreader/engine.go | 34 + workers/go/internal/newsreader/fetch.go | 110 +++ workers/go/internal/newsreader/list.go | 88 ++ .../go/internal/newsreader/newsreader_test.go | 30 + workers/go/internal/newsreader/rss.go | 207 ++++ workers/go/internal/newsreader/text.go | 76 ++ workers/go/internal/newsreader/types.go | 49 + workers/go/internal/newsreader/urls.go | 57 ++ workers/go/internal/newsreader/validate.go | 38 + 70 files changed, 3531 insertions(+), 1269 deletions(-) delete mode 100644 docker/guest-runtime/Dockerfile create mode 100644 packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift create mode 100644 packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift delete mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsFeedParser.swift create mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift create mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json create mode 100644 packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift create mode 100755 scripts/reset-local-state.sh create mode 100644 ui/ui/News/MCPServiceNewsWorker.swift create mode 100644 ui/ui/News/NewsReaderSummarizer.swift create mode 100644 ui/uiTests/NewsReaderFlowTests.swift create mode 100644 workers/go/cmd/derrick-news-reader/main.go create mode 100644 workers/go/internal/contract/schemas/news-reader-result.schema.json create mode 100644 workers/go/internal/newsreader/engine.go create mode 100644 workers/go/internal/newsreader/fetch.go create mode 100644 workers/go/internal/newsreader/list.go create mode 100644 workers/go/internal/newsreader/newsreader_test.go create mode 100644 workers/go/internal/newsreader/rss.go create mode 100644 workers/go/internal/newsreader/text.go create mode 100644 workers/go/internal/newsreader/types.go create mode 100644 workers/go/internal/newsreader/urls.go create mode 100644 workers/go/internal/newsreader/validate.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 755f87f3..96feb39e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ Thank you for your interest in contributing. Please read the [Code of Conduct](C ## Requirements - **macOS** with **Xcode 27** (Swift 6.4+) -- **Docker Desktop** (Python guest runtime, web crawler, and file extractor images) +- **Docker Desktop** (Go worker image for guests, web crawler, and file extractor) +- **Go 1.27.1+** (`brew install go`) for local diagnostics; guest compile runs in Docker - Apple Developer account for code signing ## Getting started diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d7da35c2..8d30ce1e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -23,7 +23,7 @@ Derrick integrates with user-configured APIs. You supply your own keys and are s ## Runtime -- **Docker Desktop** — script and plugin execution use the `python:3.14.7` guest image (see `docs/adr-swift-script-runtime.md` for superseded Swift guest notes). +- **Docker Desktop** — script and plugin execution use the `derrick-worker:go-v1` guest image. ## Project license diff --git a/docker/guest-runtime/Dockerfile b/docker/guest-runtime/Dockerfile deleted file mode 100644 index 5c99ce18..00000000 --- a/docker/guest-runtime/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Offline guest runtime for script_exec and plugin.invoke (Python primary). -# Build: docker build -f docker/guest-runtime/Dockerfile -t derrick-guest-runtime:python-v1 . -FROM python:3.14.7 - -RUN useradd --create-home --uid 10001 guest - -# uv — fast, lockfile-friendly dependency installs inside the guest image. -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -WORKDIR /home/guest -USER guest - -# Guests run with --network none; deps are baked into the image or installed at build time. -ENV UV_LINK_MODE=copy \ - PYTHONUNBUFFERED=1 diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile index bab3814a..7098c076 100644 --- a/docker/worker/Dockerfile +++ b/docker/worker/Dockerfile @@ -9,9 +9,12 @@ COPY workers/go ./ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-web-crawler ./cmd/derrick-crawler RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-file-extractor ./cmd/derrick-file-extractor +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-news-reader ./cmd/derrick-news-reader FROM debian:bookworm-slim +LABEL derrick.worker.binaries="crawler,extractor,news-reader" + RUN apt-get update \ && apt-get install -y --no-install-recommends poppler-utils ca-certificates \ && rm -rf /var/lib/apt/lists/* \ @@ -20,6 +23,7 @@ RUN apt-get update \ COPY --from=build /usr/local/go /usr/local/go COPY --from=build /out/derrick-web-crawler /usr/local/bin/derrick-web-crawler COPY --from=build /out/derrick-file-extractor /usr/local/bin/derrick-file-extractor +COPY --from=build /out/derrick-news-reader /usr/local/bin/derrick-news-reader RUN mkdir -p /data/in /data/out && chown -R worker:worker /data ENV PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/master-todo.md b/master-todo.md index c6e09ec3..9cd98b98 100644 --- a/master-todo.md +++ b/master-todo.md @@ -77,7 +77,7 @@ Open: binary convert (xlsx) has no “here is your file” UI yet. Do not delete Each `script_exec` / `plugin.invoke`: 1. Wait for the offline queue (max 1). -2. `docker create` a unique `derrick-guest-runtime-` from `python:3.14.7`. +2. `docker create` a unique `derrick-guest-runtime-` from `derrick-worker:go-v1`. 3. Run until the host hop loop is done (terminal envelope, error, or in-use lease TTL). 4. `docker rm -f` immediately — that is “I’m done.” 5. Release the queue slot so the next script can create at once. diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift index 1490c869..65b55953 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift @@ -12,7 +12,7 @@ public extension DBRepository { try Self.execute(""" INSERT INTO news_readers ( id, name, topics_json, sources_json, mode, max_count, schedule, - last_error, last_fetched_at, created_at, updated_at + summary_text, last_error, last_fetched_at, created_at, updated_at ) VALUES ( \(quoted(spec.id)), \(quoted(spec.name)), @@ -21,6 +21,7 @@ public extension DBRepository { \(quoted(spec.mode.rawValue)), \(spec.maxCount), \(quoted(spec.schedule.rawValue)), + \(sqlValue(spec.summaryText)), \(sqlValue(spec.lastError)), \(sqlValue(spec.lastFetchedAt.map { Self.iso8601Formatter().string(from: $0) })), \(quoted(Self.iso8601Formatter().string(from: spec.createdAt))), @@ -33,6 +34,7 @@ public extension DBRepository { mode = excluded.mode, max_count = excluded.max_count, schedule = excluded.schedule, + summary_text = excluded.summary_text, last_error = excluded.last_error, last_fetched_at = excluded.last_fetched_at, updated_at = excluded.updated_at; @@ -44,7 +46,7 @@ public extension DBRepository { try withDatabaseHandle { handle in let sql = """ SELECT id, name, topics_json, sources_json, mode, max_count, schedule, - last_error, last_fetched_at, created_at, updated_at + summary_text, last_error, last_fetched_at, created_at, updated_at FROM news_readers ORDER BY updated_at DESC; """ @@ -97,7 +99,7 @@ public extension DBRepository { SELECT id, reader_id, title, source_url, source_label, summary, published_at, fetched_at FROM news_items WHERE reader_id = \(quoted(readerID)) - ORDER BY fetched_at DESC; + ORDER BY COALESCE(published_at, fetched_at) DESC; """ var statement: OpaquePointer? guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { @@ -126,13 +128,14 @@ public extension DBRepository { name: text(1), topics: topics, sources: sources, - mode: NewsReaderMode(rawValue: text(4)) ?? .list, + mode: NewsReaderMode(rawValue: text(4)) ?? .rss, maxCount: Int(sqlite3_column_int(statement, 5)), schedule: NewsReaderSchedule(rawValue: text(6)) ?? .off, - lastError: optionalText(7), - lastFetchedAt: optionalText(8).flatMap { Self.iso8601Formatter().date(from: $0) }, - createdAt: Self.iso8601Formatter().date(from: text(9)) ?? .now, - updatedAt: Self.iso8601Formatter().date(from: text(10)) ?? .now + summaryText: optionalText(7), + lastError: optionalText(8), + lastFetchedAt: optionalText(9).flatMap { Self.iso8601Formatter().date(from: $0) }, + createdAt: Self.iso8601Formatter().date(from: text(10)) ?? .now, + updatedAt: Self.iso8601Formatter().date(from: text(11)) ?? .now ) } diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.up.sql index 6b4e5329..054ea0f3 100644 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.up.sql +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.up.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS news_readers ( mode TEXT NOT NULL, max_count INTEGER NOT NULL, schedule TEXT NOT NULL, + summary_text TEXT, last_error TEXT, last_fetched_at TEXT, created_at TEXT NOT NULL, diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift index bbc475d1..55787006 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift @@ -11,7 +11,8 @@ final class DBNewsReaderTests: XCTestCase { name: "Markets", topics: ["Financial", "rates"], sources: [NewsSource(label: "BBC", url: "https://feeds.bbci.co.uk/news/world/rss.xml")], - mode: .summaries, + mode: .summary, + summaryText: "Markets moved higher.", maxCount: 10, schedule: .daily ) @@ -20,7 +21,8 @@ final class DBNewsReaderTests: XCTestCase { XCTAssertEqual(listed.count, 1) XCTAssertEqual(listed[0].name, "Markets") XCTAssertEqual(listed[0].topics, ["Financial", "rates"]) - XCTAssertEqual(listed[0].mode, .summaries) + XCTAssertEqual(listed[0].mode, .summary) + XCTAssertEqual(listed[0].summaryText, "Markets moved higher.") let item = NewsItem( readerID: spec.id, diff --git a/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift b/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift index ce7af8ef..34a8199e 100644 --- a/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift +++ b/packages/DerrickBackend/Sources/DerrickBackend/PluginFactoryCreateWorkflow.swift @@ -36,56 +36,70 @@ enum PluginFactoryCreateWorkflow { ) async throws -> MCPToolCallResultDTO ) async throws { let input = try PluginFactoryCreateInput.decodeJSON(request.inputJSON) - guard input.pluginType == .connector else { + guard input.pluginType == .connector || input.pluginType == .custom else { try await fail( workflowID: workflowID, stage: "type", - message: "Only connector plugins can be built in the factory. News lists are created from the News reader wizard.", + message: "This plugin type cannot be built in the factory.", repositoryProvider: repositoryProvider ) return } - guard let vendor = input.vendor else { + guard let pluginID = input.pluginID, !pluginID.isEmpty else { try await fail( workflowID: workflowID, - stage: "vendor", - message: "Choose a messaging vendor for this connector.", + stage: "name", + message: "Name this plugin before creating it.", repositoryProvider: repositoryProvider ) return } - guard let pluginID = input.pluginID, !pluginID.isEmpty else { + guard !input.description.isEmpty else { try await fail( workflowID: workflowID, - stage: "name", - message: "Name this connector before creating it.", + stage: "description", + message: "Describe what the plugin should do, then try again.", repositoryProvider: repositoryProvider ) return } - guard let auth = input.auth else { + + if input.pluginType == .custom { + try await runCustomBuild( + workflowID: workflowID, + request: request, + input: input, + pluginID: pluginID, + baseContext: baseContext, + repositoryProvider: repositoryProvider, + executeTool: executeTool + ) + return + } + + guard let vendor = input.vendor else { try await fail( workflowID: workflowID, - stage: "auth", - message: "Save the connector credentials before creating it.", + stage: "vendor", + message: "Could not determine which messaging service this plugin targets.", repositoryProvider: repositoryProvider ) return } - guard auth.authScheme.isSupportedInWizard else { + guard let auth = input.auth else { try await fail( workflowID: workflowID, stage: "auth", - message: "OAuth connectors are not available yet. Use a bot token or API key.", + message: "Save the plugin credentials before creating it.", repositoryProvider: repositoryProvider ) return } - guard !input.description.isEmpty else { + guard auth.authScheme.isSupportedInWizard else { try await fail( workflowID: workflowID, - stage: "description", - message: "Choose a vendor and create the connector again.", + stage: "auth", + message: "OAuth connectors are not available yet. Use a bot token or API key.", repositoryProvider: repositoryProvider ) return @@ -194,6 +208,87 @@ enum PluginFactoryCreateWorkflow { ) } + private static func runCustomBuild( + workflowID: String, + request: WorkflowStartRequest, + input: PluginFactoryCreateInput, + pluginID: String, + baseContext: ExecutionContextWire, + repositoryProvider: @escaping @Sendable () async throws -> DBRepository, + executeTool: @escaping ( + String, + String, + ExecutionContextWire, + ServicePrincipal, + String?, + String?, + String, + String + ) async throws -> MCPToolCallResultDTO + ) async throws { + try await log( + workflowID: workflowID, + stage: "factory", + message: "Writing SKILL.md, building the guest program, and running trial tests…", + repositoryProvider: repositoryProvider + ) + let goal = input.customBuildGoal() + let buildArgs = try buildArguments(goal: goal, hostManifest: nil) + let buildResult = try await executeTool( + AllowedMCPTool.pluginFactoryBuild.rawValue, + buildArgs, + baseContext, + request.principal, + request.helperAPIKey, + request.helperReviewerModelJSON, + workflowID, + "factory" + ) + if buildResult.isError { + try await fail( + workflowID: workflowID, + stage: "factory", + message: userFacingToolError(buildResult, fallback: "Plugin factory could not finish building the plugin."), + repositoryProvider: repositoryProvider + ) + return + } + + guard let summary = decodeBuildResult(buildResult.text), + summary.ok != false, + let savedID = summary.pluginID?.trimmingCharacters(in: .whitespacesAndNewlines), + !savedID.isEmpty + else { + let decoded = decodeBuildResult(buildResult.text) + let raw = decoded?.error + ?? decoded?.reviewSummary + ?? "Plugin factory did not return a saved plugin." + try await fail( + workflowID: workflowID, + stage: "factory", + message: PluginFactoryCreateFailureMessage.userFacing(raw), + repositoryProvider: repositoryProvider + ) + return + } + + let resultJSON = try JSONEncoder.service.encode( + PluginFactoryCreateResult( + pluginID: savedID, + version: summary.version ?? "1.0.0", + vendor: "custom", + reviewSummary: summary.reviewSummary ?? "" + ) + ) + let resultText = String(decoding: resultJSON, as: UTF8.self) + try await complete( + workflowID: workflowID, + message: "Plugin saved as /\(savedID).", + resultJSON: resultText, + repositoryProvider: repositoryProvider + ) + } + private static func crawlArguments( startURL: String, vendor: PluginFactoryCreateInput.ConnectorVendor, diff --git a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift index ec7cd578..391ce1f1 100644 --- a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift +++ b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift @@ -213,6 +213,7 @@ public enum DockerRunRequestValidator: Sendable { } case DockerWorkerRuntime.crawlerBinary, DockerWorkerRuntime.extractorBinary, + DockerWorkerRuntime.newsReaderBinary, DockerWorkerRuntime.guestBinaryPath: guard args == [command] else { return .disallowedDockerFlag("exec \(command)") diff --git a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift index 7daa176c..cc7b0023 100644 --- a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift +++ b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift @@ -240,6 +240,7 @@ struct DockerRunnerXPCTests { ["rm", "-f", "c"], ["inspect", "-f", "{{.State.Running}}", "c"], ["exec", "-i", "c", "/usr/local/bin/derrick-file-extractor"], + ["exec", "-i", "c", DockerWorkerRuntime.newsReaderBinary], [ "create", "--label", @@ -261,7 +262,7 @@ struct DockerRunnerXPCTests { @Test func rejectsInvalidGoGuestExecCommands() { for args in [ - ["exec", "-i", "c", "python3", "/tmp/guest.py"], + ["exec", "-i", "c", "python3", "/tmp/guest.go"], ["exec", "-i", "c", DockerWorkerRuntime.guestBinaryPath, "extra"], ["exec", "-i", "c", "sh", "-c", "cat > /tmp/other"], ["exec", "-i", "c", "sh", "-c", "rm -rf /"], diff --git a/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift b/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift index 63835ade..57873932 100644 --- a/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift +++ b/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift @@ -33,4 +33,25 @@ public enum DockerImageInspector: Sendable { throw DockerImageDigestError.digestMismatch(tag: tag, expected: expected, actual: actual) } } + + /// Returns false when the image exists but predates required worker binaries (e.g. news reader). + public static func workerImageHasCurrentBinaries( + tag: String = DockerWorkerRuntime.image, + executor: @escaping DockerCLIExecutor + ) async -> Bool { + let format = "{{index .Config.Labels \"\(DockerWorkerRuntime.binariesLabelKey)\"}}" + do { + let response = try await executor( + ["image", "inspect", "--format", format, tag], + Data(), + 30 + ) + guard response.exitCode == 0 else { return false } + let label = String(decoding: response.stdout, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return label == DockerWorkerRuntime.binariesLabelValue + } catch { + return false + } + } } diff --git a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift index 8eca7de5..82f9d7b4 100644 --- a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift +++ b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift @@ -36,12 +36,19 @@ public enum DockerProductImagePrewarmer: Sendable { ) async throws { let inspect = try await executor(["image", "inspect", tag], Data(), 30) if inspect.exitCode == 0 { - try await DockerImageInspector.verifyPinned( + let binariesCurrent = await DockerImageInspector.workerImageHasCurrentBinaries( tag: tag, - expected: pinnedDigest, executor: executor ) - return + if binariesCurrent { + try await DockerImageInspector.verifyPinned( + tag: tag, + expected: pinnedDigest, + executor: executor + ) + return + } + // Stale worker image (missing news reader, etc.). Rebuild overwrites the tag. } guard let repoRoot = DerrickRepositoryRoot.locate() else { diff --git a/packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift new file mode 100644 index 00000000..11c637ff --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift @@ -0,0 +1,115 @@ +import Foundation +import Structure + +/// Runs the prebuilt news reader in a oneshot container: create, exec, rm. +public struct NewsReaderDockerExecutor: Sendable { + public static let image = DockerWorkerRuntime.image + public static let containerPrefix = "derrick-news-reader" + public static let binaryPath = DockerWorkerRuntime.newsReaderBinary + public static let maximumTimeoutSeconds = 300 + public static let dockerNetwork = "bridge" + + private let executor: DockerCLIExecutor + private let queue: DerrickDockerRunQueue + + public init( + executor: @escaping DockerCLIExecutor, + queue: DerrickDockerRunQueue = .crawler + ) { + self.executor = executor + self.queue = queue + } + + public func run( + input: Data, + timeoutSeconds: Int + ) async throws -> DockerCLIResult { + let timeout = min(max(timeoutSeconds, 1), Self.maximumTimeoutSeconds) + try await WorkerImageGate.shared.ensureReady(executor: executor) + let prepared = try await NewsReaderDockerInputPreparer.enrich(input) + let executor = self.executor + do { + return try await queue.withPermit { + let proxyLease = try await WebCrawlerEgressProxy.shared.lease(forHosts: prepared.leaseHosts) + do { + let result = try await OneshotDockerContainer.run( + executor: executor, + prefix: Self.containerPrefix, + createArguments: { name in + Self.createArguments( + name: name, + proxyHost: proxyLease.host, + proxyPort: proxyLease.port, + proxyToken: proxyLease.clientToken + ) + }, + createStep: "create news reader container", + startStep: "start news reader container", + body: { name in + try await executor( + ["exec", "-i", name, Self.binaryPath], + prepared.data, + timeout + ) + } + ) + await WebCrawlerEgressProxy.shared.release(forHosts: prepared.leaseHosts) + return result + } catch { + await WebCrawlerEgressProxy.shared.release(forHosts: prepared.leaseHosts) + throw error + } + } + } catch let error as OneshotDockerContainerError { + throw mappedNewsReaderError(error) + } + } + + static func createArguments( + name: String, + proxyHost: String, + proxyPort: Int, + proxyToken: String + ) -> [String] { + [ + "create", + ] + DerrickDockerRuntimeIdentity.createLabelArguments + [ + "--network", dockerNetwork, + "--read-only", + "--tmpfs", "/tmp:rw,exec,nosuid,size=64m", + "--pids-limit", "128", + "--cpus", "1.0", + "--memory", "512m", + "--name", name, + "--env", "DERRICK_EGRESS_PROXY_HOST=\(proxyHost)", + "--env", "DERRICK_EGRESS_PROXY_PORT=\(proxyPort)", + "--env", "DERRICK_EGRESS_PROXY_TOKEN=\(proxyToken)", + "--entrypoint", "/bin/sleep", + image, + "infinity", + ] + } + + private func mappedNewsReaderError(_ error: OneshotDockerContainerError) -> Error { + switch error { + case .commandFailed(let step, let detail): + return NewsReaderDockerExecutorError.commandFailed(step, detail) + case .imageUnavailable(let detail): + return NewsReaderDockerExecutorError.imageUnavailable(detail) + } + } +} + +public enum NewsReaderDockerExecutorError: Error, LocalizedError, Sendable, Equatable { + case commandFailed(String, String) + case imageUnavailable(String) + + public var errorDescription: String? { + switch self { + case .commandFailed(let step, let detail): + return "\(step) failed: \(detail)" + case .imageUnavailable(let image): + return "News reader image is not installed: \(image)." + } + } +} diff --git a/packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift b/packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift new file mode 100644 index 00000000..a894dbe1 --- /dev/null +++ b/packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift @@ -0,0 +1,43 @@ +import Foundation +import Structure +import WebCrawler + +/// Host-side helpers for preparing news reader Docker input and egress policy. +enum NewsReaderDockerInputPreparer { + static func enrich(_ input: Data) async throws -> (data: Data, leaseHosts: [String]) { + let request: NewsReaderWorkerRequest + do { + request = try JSONDecoder.service.decode(NewsReaderWorkerRequest.self, from: input) + } catch { + throw NewsReaderDockerExecutorError.commandFailed( + "prepare news reader container", + "News reader input was not valid JSON." + ) + } + guard !request.sources.isEmpty else { + throw NewsReaderDockerExecutorError.commandFailed( + "prepare news reader container", + "News reader input did not include any sources." + ) + } + + var hosts = Set() + for source in request.sources { + guard let url = URL(string: source.url) else { + throw NewsReaderDockerExecutorError.commandFailed( + "prepare news reader container", + "Source URL is not valid: \(source.url)" + ) + } + let chain = await WebCrawlerRedirectResolver.hostsInRedirectChain(from: url) + hosts.formUnion(chain) + } + guard !hosts.isEmpty else { + throw NewsReaderDockerExecutorError.commandFailed( + "prepare news reader container", + "Could not determine hosts to fetch from the configured sources." + ) + } + return (input, Array(hosts).sorted()) + } +} diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index 1ce0f0aa..25576ca9 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -282,6 +282,60 @@ import WebCrawler #expect(allowed?.contains("docs.slack.dev") == true) } + @Test func workerImageLabelDetectsMissingNewsReaderBinary() async { + let recorder = DockerCallRecorder() + let executor: DockerCLIExecutor = { args, _, _ in + await recorder.append(args) + if args.first == "image", args.contains("inspect"), args.contains("--format") { + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + let current = await DockerImageInspector.workerImageHasCurrentBinaries(executor: executor) + #expect(!current) + } + + @Test func newsReaderContainerCreateAndExecPassDockerValidator() { + let createArgs = NewsReaderDockerExecutor.createArguments( + name: "derrick-news-reader-test", + proxyHost: "172.17.0.1", + proxyPort: 3128, + proxyToken: "token" + ) + #expect( + DockerRunRequestValidator.validate( + DockerHostLaunch.makeRequest(dockerArguments: createArgs, timeoutSeconds: 60) + ) == nil + ) + let execArgs = DockerHostLaunch.dockerCLIArguments([ + "exec", "-i", "derrick-news-reader-test", NewsReaderDockerExecutor.binaryPath, + ]) + #expect( + DockerRunRequestValidator.validate( + DockerHostLaunch.makeRequest(dockerArguments: execArgs, timeoutSeconds: 60) + ) == nil + ) + } + + @Test func newsReaderInputPreparerUsesSourceHostsNotCrawlerStartURL() async throws { + let input = try JSONEncoder.service.encode( + NewsReaderWorkerRequest( + mode: .rss, + sources: [ + NewsSource(label: "Google News", url: "https://news.google.com/rss?hl=en-US"), + ], + topics: ["Tech"], + maxCount: 20 + ) + ) + + let prepared = try await NewsReaderDockerInputPreparer.enrich(input) + #expect(prepared.leaseHosts.contains("news.google.com")) + let decoded = try JSONDecoder.service.decode(NewsReaderWorkerRequest.self, from: prepared.data) + #expect(decoded.sources.count == 1) + #expect(decoded.sources[0].url.contains("news.google.com")) + } + @Test func dockerProductImagePrewarmerSkipsBuildWhenImagePresent() async throws { let recorder = DockerCallRecorder() let executor: DockerCLIExecutor = { args, _, _ in @@ -895,7 +949,7 @@ import WebCrawler @Test func oneshotEnsurePulledImageSkipsPullWhenImageExists() async throws { let recorder = DockerCallRecorder() - try await OneshotDockerContainer.ensurePulledImage("python:3.14.7") { arguments, _, _ in + try await OneshotDockerContainer.ensurePulledImage(DockerWorkerRuntime.image) { arguments, _, _ in await recorder.append(arguments) return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) } @@ -906,7 +960,7 @@ import WebCrawler @Test func oneshotEnsurePulledImagePullsWhenMissing() async throws { let recorder = DockerCallRecorder() - try await OneshotDockerContainer.ensurePulledImage("python:3.14.7") { arguments, _, _ in + try await OneshotDockerContainer.ensurePulledImage(DockerWorkerRuntime.image) { arguments, _, _ in await recorder.append(arguments) if arguments.first == "image" { return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data()) @@ -915,7 +969,7 @@ import WebCrawler } let calls = await recorder.calls #expect(calls.contains { $0.starts(with: ["image", "inspect"]) }) - #expect(calls.contains { $0.first == "pull" && $0.contains("python:3.14.7") }) + #expect(calls.contains { $0.first == "pull" && $0.contains(DockerWorkerRuntime.image) }) } @Test func dockerRunQueueSerializesWhenMaxIsOne() async throws { @@ -963,7 +1017,7 @@ import WebCrawler return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) }, prefix: "derrick-guest-runtime", - createArguments: { name in ["create", "--name", name, "python:3.14.7"] }, + createArguments: { name in ["create", "--name", name, DockerWorkerRuntime.image] }, createStep: "create guest runtime container", startStep: "start guest runtime container", body: { _ in diff --git a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift index cee3ef60..046e54e2 100644 --- a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift +++ b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift @@ -240,10 +240,14 @@ public struct PluginFactory: Sendable { } let runtimeJSON = try runtimeJSON(for: manifest) + let guestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: runtimeJSON, + manifestJSON: draft.manifestJSON + ) var files: [String: Data] = [ "plugin.json": Data(draft.manifestJSON.utf8), "app.derrick/runtime.json": Data(runtimeJSON.utf8), - "app.derrick/plugin.go": Data(draft.guestSource.utf8), + guestPath: Data(draft.guestSource.utf8), "app.derrick/plugin": artifact, ] for (path, body) in draft.skillFiles { diff --git a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift index a8e3b7e1..5fa5adec 100644 --- a/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift +++ b/packages/Plugin/Tests/PluginTests/PluginFactoryTests.swift @@ -23,6 +23,39 @@ import Testing """ } + @Test func releaseVerifiesWithEntrypointGuestPath() throws { + let runtimeJSON = #"{"entrypoint":"./app.derrick/plugin.go","language":"go"}"# + let manifestJSON = """ + {"$schema":"\(PluginContract.agentPluginSchema)","name":"slack-connector-1","version":"1.0.0",\ + "extensions":{"app.derrick":{"entrypoint":"./app.derrick/plugin.go","role":"connector"}}} + """ + let guestSource = "package main\n" + let artifact = Data("binary".utf8) + let guestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: runtimeJSON, + manifestJSON: manifestJSON + ) + #expect(guestPath == "app.derrick/plugin.go") + let files: [String: Data] = [ + "plugin.json": Data(manifestJSON.utf8), + "app.derrick/runtime.json": Data(runtimeJSON.utf8), + guestPath: Data(guestSource.utf8), + "app.derrick/plugin": artifact, + ] + let release = PluginFactoryRelease( + pluginID: "slack-connector-1", + version: "1.0.0", + manifestJSON: manifestJSON, + runtimeJSON: runtimeJSON, + guestSource: guestSource, + compiledArtifact: artifact, + skillFiles: [:], + contentHash: PluginContentHash.hash(files: files), + reviewSummary: "ok" + ) + #expect(release.verifyIntegrity()) + } + @Test func guestLanguageIsGoFromRuntimeJSON() { let release = PluginFactoryRelease( pluginID: "slack-connection", @@ -121,9 +154,17 @@ import Testing draft: PluginFactoryDraft( manifestJSON: manifestJSON(), guestSource: """ - import json, sys - _ = json.load(sys.stdin) - print("not a plugin envelope") + package main + + import ( + "encoding/json" + "os" + ) + + func main() { + _ = json.NewDecoder(os.Stdin).Decode(&map[string]any{}) + os.Stdout.WriteString("not a plugin envelope") + } """, testInput: Data(#"{"kind":"manual"}"#.utf8) ), @@ -132,10 +173,7 @@ import Testing ) Issue.record("Expected invalid output") } catch let error as PluginFactoryError { - #expect( - error.localizedDescription.contains("invalid plugin output") - || error.localizedDescription.contains("Python draft test failed") - ) + #expect(error.localizedDescription.contains("invalid plugin output")) #expect(await reviewer.callCount == 0) } } @@ -457,17 +495,7 @@ import Testing ) let draft = PluginFactoryDraft( manifestJSON: manifestJSON, - guestSource: """ - import json, sys - event = json.load(sys.stdin) - for item in event.get("http_results") or []: - if item.get("request_id") == "send-1": - body = json.loads(item.get("body") or "{}") - if body.get("ok"): - json.dump([{"verb":"result.emit","sent_message":{"vendor_message_id":"1.0","created_at":"1.0"}}], sys.stdout) - sys.exit(0) - json.dump([{"verb":"result.emit","summary":"failed"}], sys.stdout) - """, + guestSource: connectorGuestGoSource(), testInput: testInput, userGoal: fullSyncGoal() ) @@ -630,20 +658,47 @@ private func connectorDraft(testInput: Data) -> PluginFactoryDraft { """ return PluginFactoryDraft( manifestJSON: manifestJSON, - guestSource: """ - import json, sys - event = json.load(sys.stdin) - def emit(v): - json.dump(v, sys.stdout, separators=(",", ":")) - if event.get("http_results"): - emit([{"verb":"result.emit","sent_message":{"vendor_message_id":"1.0","created_at":"1710000001.0"}}]) - else: - emit([{"verb":"http.request","request_id":"send-1","method":"POST","url":"https://slack.com/api/chat.postMessage"}]) - """, + guestSource: connectorGuestGoSource(), testInput: testInput ) } +private func connectorGuestGoSource() -> String { + """ + package main + + import ( + "encoding/json" + "os" + ) + + func main() { + var event map[string]any + _ = json.NewDecoder(os.Stdin).Decode(&event) + if _, ok := event["http_results"]; ok { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode([]map[string]any{{ + "verb": "result.emit", + "sent_message": map[string]any{ + "vendor_message_id": "1.0", + "created_at": "1710000001.0", + }, + }}) + return + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode([]map[string]any{{ + "verb": "http.request", + "request_id": "send-1", + "method": "POST", + "url": "https://slack.com/api/chat.postMessage", + }}) + } + """ +} + private actor SequenceFactoryBuilder: PluginFactoryBuilder { let drafts: [PluginFactoryDraft] private(set) var callCount = 0 diff --git a/packages/Structure/Sources/AppLayerServices/AppServices/DerrickBundledText.swift b/packages/Structure/Sources/AppLayerServices/AppServices/DerrickBundledText.swift index 7dace2ea..5e46fd36 100644 --- a/packages/Structure/Sources/AppLayerServices/AppServices/DerrickBundledText.swift +++ b/packages/Structure/Sources/AppLayerServices/AppServices/DerrickBundledText.swift @@ -50,7 +50,7 @@ public enum DerrickBundledText: Sendable { public static func formatCodeForModel( _ source: String, heading: String, - language: String = "python" + language: String = "go" ) -> String { """ # \(heading) diff --git a/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift b/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift index d7296f4e..cb0c5969 100644 --- a/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift +++ b/packages/Structure/Sources/AppLayerServices/AppServices/ServiceHealth.swift @@ -14,9 +14,6 @@ public enum DerrickGuestRuntime: Sendable { /// Unified Go worker image for offline guests (`script_exec` / `plugin.invoke`). public static let guestDockerImage = DockerWorkerRuntime.image - - /// Legacy Python image reported by older daemons. Hygiene retires a mismatch. - public static let legacyPythonGuestDockerImage = "python:3.14.7" } public struct ServiceHealthReport: Codable, Sendable, Hashable { diff --git a/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift b/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift index d8e8e745..eab2002f 100644 --- a/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift +++ b/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift @@ -38,4 +38,7 @@ public enum InProcessServiceBridges: Sendable { public typealias RouteMessagingAgent = @Sendable (MessagingAgentRoute) async throws -> Void nonisolated(unsafe) public static var messagingAgentRoute: RouteMessagingAgent? + + public typealias RunNewsReader = @Sendable (Data) async throws -> NewsReaderRunResult + nonisolated(unsafe) public static var runNewsReader: RunNewsReader? } diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift b/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift index 5d3808f0..9d732142 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift @@ -19,6 +19,8 @@ import CryptoKit func callTool(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) /// Signed `searchTools` envelope. Reply is `MCPToolSearchResultDTO`. func searchTools(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) + /// Runs the Docker news reader worker (stdin JSON request). Reply is `NewsReaderRunResult`. + func runNewsReader(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) } public struct MCPServiceBootstrapResult: Codable, Sendable, Hashable { @@ -348,4 +350,12 @@ public enum MCPServiceXPCCodec { public static func decodeString(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" } + + public static func encodeNewsReaderRunResult(_ result: NewsReaderRunResult) throws -> Data { + try JSONEncoder.service.encode(result) + } + + public static func decodeNewsReaderRunResult(_ data: Data) throws -> NewsReaderRunResult { + try JSONDecoder.service.decode(NewsReaderRunResult.self, from: data) + } } diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift b/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift index 94eec536..ed48b99e 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift @@ -12,6 +12,9 @@ public enum MCPToolCallTimeouts { /// Must be at least as long as `MCPServiceDockerHelperRunner` call timeout. public static let pluginInvokeNanoseconds: UInt64 = 120_000_000_000 + /// News reader Docker worker (RSS fetch + optional summary prep). + public static let newsReaderNanoseconds: UInt64 = 200_000_000_000 + public static func nanoseconds(forToolName toolName: String) -> UInt64 { switch toolName { case "web.crawl", "plugin_factory_build", "script_exec": diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift index 6452b0ee..923fdff3 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift @@ -93,6 +93,7 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { public let description: String public let pluginID: String? public let auth: ConnectorAuthDiscovery? + public let skillMarkdown: String? public init( pluginType: PluginType, @@ -101,7 +102,8 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { scope: ConnectorScope = .fullSync, description: String, pluginID: String? = nil, - auth: ConnectorAuthDiscovery? = nil + auth: ConnectorAuthDiscovery? = nil, + skillMarkdown: String? = nil ) { self.pluginType = pluginType self.vendor = vendor @@ -109,6 +111,7 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { self.scope = scope self.pluginID = pluginID?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty self.auth = auth + self.skillMarkdown = skillMarkdown?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty self.description = Self.resolvedDescription( userDescription: description, vendor: vendor, @@ -117,6 +120,39 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { ) } + public static func makeFromSkillDraft( + _ draft: PluginSkillDraft, + auth: ConnectorAuthDiscovery? = nil + ) throws -> PluginFactoryCreateInput { + let pluginID = try draft.normalizedPluginID() + let description = draft.factoryDescription() + let skillMarkdown = draft.skillMarkdown() + switch draft.plannedKind { + case .messagingConnector: + guard let vendor = draft.inferredConnectorVendor else { + throw PluginSkillDraftError.missingConnectorVendor + } + return PluginFactoryCreateInput( + pluginType: .connector, + vendor: vendor, + scope: .fullSync, + description: description, + pluginID: pluginID, + auth: auth, + skillMarkdown: skillMarkdown + ) + case .customCapability: + return PluginFactoryCreateInput( + pluginType: .custom, + description: description, + pluginID: pluginID, + skillMarkdown: skillMarkdown + ) + case .newsDigest: + throw PluginSkillDraftError.newsUsesReaderPath + } + } + /// Builds connector workflow input. The factory goal uses the fixed scope sentence, not free-text extras. public static func makeConnector( vendor: ConnectorVendor, @@ -137,7 +173,8 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { scope: scope, description: userDescription, pluginID: resolvedID, - auth: auth ?? (try? ConnectorAuthDiscovery.slackBotTokenFallback()) + auth: auth ?? (try? ConnectorAuthDiscovery.slackBotTokenFallback()), + skillMarkdown: nil ) } @@ -182,6 +219,7 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { case description case pluginID case auth + case skillMarkdown } public init(from decoder: Decoder) throws { @@ -195,6 +233,8 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { pluginID = try container.decodeIfPresent(String.self, forKey: .pluginID)? .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty auth = try container.decodeIfPresent(ConnectorAuthDiscovery.self, forKey: .auth) + skillMarkdown = try container.decodeIfPresent(String.self, forKey: .skillMarkdown)? + .trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty description = Self.resolvedDescription( userDescription: rawDescription, vendor: vendor, @@ -212,6 +252,7 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { try container.encode(description, forKey: .description) try container.encodeIfPresent(pluginID, forKey: .pluginID) try container.encodeIfPresent(auth, forKey: .auth) + try container.encodeIfPresent(skillMarkdown, forKey: .skillMarkdown) } public func encodedJSON() throws -> String { @@ -247,6 +288,9 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { extra.append("Host permission labels: \(auth.permissions.joined(separator: ", "))") } } + if let skillMarkdown, !skillMarkdown.isEmpty { + extra.append("SKILL.md draft:\n\(skillMarkdown)") + } extra.append( "The host writes plugin.json. Return go_source and test_input_json only. Do not invent a plugin_id or secrets list." ) @@ -267,31 +311,46 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { } } - /// Failure stage hint for returning the wizard to the right step. + public func customBuildGoal() -> String { + var lines = [ + "Create an Agent Plugin capability.", + description, + ] + if let skillMarkdown, !skillMarkdown.isEmpty { + lines.append("SKILL.md draft:\n\(skillMarkdown)") + } + lines.append( + "Return go_source, test_input_json, and skill_files. Include a valid plugin.json via the builder contract when no host manifest is supplied." + ) + return lines.joined(separator: "\n\n") + } + + /// Failure stage hint for returning the plugin studio to the right step. public enum FailureStep: String, Sendable { - case type - case vendor - case name - case auth + case goal + case skill + case preview + case credentials + case build case news - case description - case creating } public static func failureStep(forStage stage: String?) -> FailureStep { switch stage?.lowercased() { - case "type": - return .type - case "name": - return .name - case "auth", "discover": - return .auth + case "goal": + return .goal + case "skill", "name", "description", "type", "vendor": + return .skill + case "preview": + return .preview + case "auth", "discover", "credentials": + return .credentials case "news", "paywall": return .news - case "crawl", "docs", "vendor", "factory", "build", "review", "description": - return .vendor + case "crawl", "docs", "factory", "build", "review": + return .build default: - return .creating + return .build } } } diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsFeedParser.swift b/packages/Structure/Sources/AppLayerServices/News/NewsFeedParser.swift deleted file mode 100644 index ebfd4f9b..00000000 --- a/packages/Structure/Sources/AppLayerServices/News/NewsFeedParser.swift +++ /dev/null @@ -1,154 +0,0 @@ -import Foundation - -public enum NewsFeedParser { - public static func parse(data: Data, sourceLabel: String, fallbackPageURL: URL) -> [ParsedNewsEntry] { - let text = String(data: data, encoding: .utf8) - ?? String(data: data, encoding: .isoLatin1) - ?? "" - if NewsPaywall.looksLikeFeed(text) { - return parseXMLFeed(text, sourceLabel: sourceLabel) - } - if let entry = htmlFallback(text: text, sourceLabel: sourceLabel, url: fallbackPageURL) { - return [entry] - } - return [] - } - - public struct ParsedNewsEntry: Sendable, Hashable { - public var title: String - public var sourceURL: String - public var summary: String? - public var publishedAt: Date? - - public init(title: String, sourceURL: String, summary: String? = nil, publishedAt: Date? = nil) { - self.title = title - self.sourceURL = sourceURL - self.summary = summary - self.publishedAt = publishedAt - } - } - - private static func parseXMLFeed(_ xml: String, sourceLabel: String) -> [ParsedNewsEntry] { - _ = sourceLabel - var entries: [ParsedNewsEntry] = [] - let itemBlocks = slices(of: xml, start: "") - + slices(of: xml, start: "") - for block in itemBlocks { - let title = firstTag(block, names: ["title"]) ?? "" - let link = firstTag(block, names: ["link"]) - ?? attribute(named: "href", in: firstRawTag(block, name: "link") ?? "") - ?? firstTag(block, names: ["guid", "id"]) - ?? "" - let summary = firstTag(block, names: ["description", "summary", "content"]) - let dateText = firstTag(block, names: ["pubDate", "published", "updated", "dc:date"]) - let cleanedTitle = stripTags(title).trimmingCharacters(in: .whitespacesAndNewlines) - let cleanedLink = stripTags(link).trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedTitle.isEmpty, let url = URL(string: cleanedLink), url.scheme != nil else { - continue - } - entries.append( - ParsedNewsEntry( - title: cleanedTitle, - sourceURL: url.absoluteString, - summary: summary.map(stripTags).flatMap { $0.isEmpty ? nil : $0 }, - publishedAt: parseDate(dateText) - ) - ) - } - return entries - } - - private static func htmlFallback(text: String, sourceLabel: String, url: URL) -> ParsedNewsEntry? { - _ = sourceLabel - let title = firstTag(text, names: ["title"]) - .map(stripTags)? - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let title, !title.isEmpty else { return nil } - return ParsedNewsEntry(title: title, sourceURL: url.absoluteString, summary: nil, publishedAt: nil) - } - - private static func slices(of text: String, start: String, end: String) -> [String] { - var result: [String] = [] - let startLower = start.lowercased() - let endLower = end.lowercased() - let lower = text.lowercased() - var idx = lower.startIndex - while let startRange = lower[idx...].range(of: startLower) { - guard let endRange = lower[startRange.upperBound...].range(of: endLower) else { break } - let sliceStart = startRange.lowerBound - let sliceEnd = endRange.upperBound - result.append(String(text[sliceStart.. String? { - for name in names { - let lower = xml.lowercased() - let open = "<\(name.lowercased())" - guard let openStart = lower.range(of: open) else { continue } - guard let tagClose = xml[openStart.upperBound...].firstIndex(of: ">") else { continue } - let innerStart = xml.index(after: tagClose) - let closeToken = "" - guard let close = lower[innerStart...].range(of: closeToken) else { continue } - return String(xml[innerStart.. String? { - let lower = xml.lowercased() - let open = "<\(name.lowercased())" - guard let openStart = lower.range(of: open) else { return nil } - guard let tagClose = xml[openStart.upperBound...].firstIndex(of: ">") else { return nil } - return String(xml[openStart.lowerBound...tagClose]) - } - - private static func attribute(named name: String, in tag: String) -> String? { - let pattern = "\(name)\\s*=\\s*\"([^\"]+)\"" - guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { - return nil - } - let range = NSRange(tag.startIndex.. 1, - let inner = Range(match.range(at: 1), in: tag) - else { - return nil - } - return String(tag[inner]) - } - - private static func stripTags(_ raw: String) -> String { - var value = raw.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - let entities: [(String, String)] = [ - ("&", "&"), - ("<", "<"), - (">", ">"), - (""", "\""), - ("'", "'"), - ("'", "'"), - (" ", " "), - ] - for (from, to) in entities { - value = value.replacingOccurrences(of: from, with: to) - } - return value - } - - private static func parseDate(_ raw: String?) -> Date? { - guard let raw, !raw.isEmpty else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - let rfc = DateFormatter() - rfc.locale = Locale(identifier: "en_US_POSIX") - rfc.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" - if let date = rfc.date(from: trimmed) { return date } - rfc.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" - if let date = rfc.date(from: trimmed) { return date } - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = iso.date(from: trimmed) { return date } - iso.formatOptions = [.withInternetDateTime] - return iso.date(from: trimmed) - } -} diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift b/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift index f09967e2..57ce6529 100644 --- a/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift +++ b/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift @@ -1,13 +1,26 @@ import Foundation public enum NewsReaderMode: String, Codable, Sendable, Hashable, CaseIterable { + case rss case list - case summaries + case summary public var displayName: String { switch self { - case .list: return "List articles" - case .summaries: return "Summaries" + case .rss: return "RSS feed" + case .list: return "Crawl site" + case .summary: return "AI summary" + } + } + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + switch raw { + case "rss": self = .rss + case "list": self = .list + case "summary", "summaries": self = .summary + default: + self = .rss } } } @@ -46,6 +59,7 @@ public struct NewsReaderSpec: Codable, Sendable, Hashable, Identifiable { public var mode: NewsReaderMode public var maxCount: Int public var schedule: NewsReaderSchedule + public var summaryText: String? public var lastError: String? public var lastFetchedAt: Date? public var createdAt: Date @@ -56,9 +70,10 @@ public struct NewsReaderSpec: Codable, Sendable, Hashable, Identifiable { name: String, topics: [String], sources: [NewsSource], - mode: NewsReaderMode = .list, + mode: NewsReaderMode = .rss, maxCount: Int = 20, schedule: NewsReaderSchedule = .off, + summaryText: String? = nil, lastError: String? = nil, lastFetchedAt: Date? = nil, createdAt: Date = .now, @@ -71,6 +86,7 @@ public struct NewsReaderSpec: Codable, Sendable, Hashable, Identifiable { self.mode = mode self.maxCount = min(50, max(1, maxCount)) self.schedule = schedule + self.summaryText = summaryText?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty self.lastError = lastError self.lastFetchedAt = lastFetchedAt self.createdAt = createdAt @@ -132,30 +148,46 @@ public enum NewsPresetTopic: String, Sendable, CaseIterable, Identifiable { } public enum NewsPresetSource: String, Sendable, CaseIterable, Identifiable { - case bbcWorld - case npr - case bbcTech - case hn case googleNews + case foxNews + case newsmax + case nationalReview + case wsj public var id: String { rawValue } public var source: NewsSource { switch self { - case .bbcWorld: - return NewsSource(id: rawValue, label: "BBC World", url: "https://feeds.bbci.co.uk/news/world/rss.xml") - case .npr: - return NewsSource(id: rawValue, label: "NPR", url: "https://feeds.npr.org/1001/rss.xml") - case .bbcTech: - return NewsSource(id: rawValue, label: "BBC Technology", url: "https://feeds.bbci.co.uk/news/technology/rss.xml") - case .hn: - return NewsSource(id: rawValue, label: "Hacker News", url: "https://hnrss.org/frontpage") case .googleNews: return NewsSource( id: rawValue, label: "Google News", url: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" ) + case .foxNews: + return NewsSource( + id: rawValue, + label: "Fox News", + url: "https://moxie.foxnews.com/google-publisher/latest.xml" + ) + case .newsmax: + return NewsSource( + id: rawValue, + label: "Newsmax", + url: "https://www.newsmax.com/rss/Newsfront/" + ) + case .nationalReview: + return NewsSource( + id: rawValue, + label: "National Review", + url: "https://www.nationalreview.com/feed/" + ) + case .wsj: + return NewsSource( + id: rawValue, + label: "Wall Street Journal", + url: "https://feeds.a.dj.com/rss/RSSWorldNews.xml" + ) } } } @@ -167,6 +199,8 @@ public enum NewsReaderError: Error, Sendable, Equatable, LocalizedError { case emptyName case fetchFailed(url: String, detail: String) case notReady + case summarizerUnavailable + case workerUnavailable(String) public var errorDescription: String? { switch self { @@ -182,6 +216,16 @@ public enum NewsReaderError: Error, Sendable, Equatable, LocalizedError { return "Could not read \(url). \(detail)" case .notReady: return "News lists are not ready yet. Try again in a moment." + case .summarizerUnavailable: + return "Add an API key in Settings before creating an AI summary list." + case .workerUnavailable(let detail): + return "News reader worker is not available. \(detail)" } } } + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift b/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift index 37c362f8..aba438eb 100644 --- a/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift +++ b/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift @@ -1,139 +1,108 @@ import Foundation -public protocol NewsHTTPClient: Sendable { - func get(url: URL) async throws -> NewsHTTPResponse -} - -public struct NewsHTTPResponse: Sendable { - public var status: Int - public var contentType: String? - public var body: Data - - public init(status: Int, contentType: String?, body: Data) { - self.status = status - self.contentType = contentType - self.body = body - } -} - -public struct URLSessionNewsHTTPClient: NewsHTTPClient { - public init() {} - - public func get(url: URL) async throws -> NewsHTTPResponse { - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = 20 - request.setValue( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", - forHTTPHeaderField: "User-Agent" - ) - request.setValue("application/rss+xml, application/atom+xml, application/xml, text/xml, text/html;q=0.8", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - let http = response as? HTTPURLResponse - return NewsHTTPResponse( - status: http?.statusCode ?? 0, - contentType: http?.value(forHTTPHeaderField: "Content-Type"), - body: data - ) - } -} - public enum NewsReaderRefresh { public static func validateAndFetch( spec: NewsReaderSpec, - client: any NewsHTTPClient - ) async throws -> [NewsItem] { + worker: any NewsWorkerRunning, + summarizer: NewsSummaryGenerating? = nil + ) async throws -> NewsReaderFetchResult { let name = spec.name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { throw NewsReaderError.emptyName } guard !spec.sources.isEmpty else { throw NewsReaderError.emptySources } - var collected: [NewsItem] = [] for source in spec.sources { - let parsed = try await fetchSource(source, readerID: spec.id, client: client) - collected.append(contentsOf: parsed) - } - - let filtered = filter(collected, topics: spec.topics) - let unique = uniqued(filtered) - let sorted = unique.sorted { lhs, rhs in - (lhs.publishedAt ?? lhs.fetchedAt) > (rhs.publishedAt ?? rhs.fetchedAt) - } - return Array(sorted.prefix(spec.maxCount)) - } - - public static func digest(from items: [NewsItem]) -> String { - let lines = items.prefix(8).map { item in - "• \(item.title) (\(item.sourceLabel))" + guard let url = URL(string: source.url), url.scheme == "http" || url.scheme == "https" else { + throw NewsReaderError.invalidURL(source.url) + } + if let reason = NewsPaywall.preflightRejection(url: url) { + throw NewsReaderError.paywalled(url: source.url, detail: reason) + } } - return lines.joined(separator: "\n") - } - private static func fetchSource( - _ source: NewsSource, - readerID: String, - client: any NewsHTTPClient - ) async throws -> [NewsItem] { - guard let url = URL(string: source.url), url.scheme == "http" || url.scheme == "https" else { - throw NewsReaderError.invalidURL(source.url) - } - let fetchURL = NewsSourceURL.canonicalFetchURL(url) - if let reason = NewsPaywall.preflightRejection(url: fetchURL) { - throw NewsReaderError.paywalled(url: source.url, detail: reason) - } - let response: NewsHTTPResponse - do { - response = try await client.get(url: fetchURL) - } catch { - throw NewsReaderError.fetchFailed(url: source.url, detail: error.localizedDescription) - } - if let reason = NewsPaywall.rejectionReason( - url: fetchURL, - status: response.status, - contentType: response.contentType, - body: response.body - ) { - throw NewsReaderError.paywalled(url: source.url, detail: reason) - } - if response.status != 0, response.status < 200 || response.status >= 400 { - throw NewsReaderError.fetchFailed(url: source.url, detail: "HTTP \(response.status)") - } - let entries = NewsFeedParser.parse(data: response.body, sourceLabel: source.label, fallbackPageURL: fetchURL) - guard !entries.isEmpty else { + let request = NewsReaderWorkerRequest( + mode: spec.mode, + sources: spec.sources, + topics: spec.topics, + maxCount: spec.maxCount, + contextHint: ([spec.name] + spec.topics).joined(separator: " ") + ) + let requestJSON = try request.encodedJSON() + let stdout = try await worker.run(requestJSON: requestJSON) + let result = try JSONDecoder.service.decode(NewsReaderWorkerResult.self, from: stdout) + guard result.ok else { + let detail = result.diagnostics.joined(separator: " ") throw NewsReaderError.fetchFailed( - url: source.url, - detail: "No articles were found. For Google News, Derrick uses the public RSS feed." + url: spec.sources.first?.url ?? name, + detail: detail.isEmpty ? "News reader returned no articles." : detail ) } - return entries.map { entry in + + let items = result.articles.map { article in NewsItem( - readerID: readerID, - title: entry.title, - sourceURL: entry.sourceURL, - sourceLabel: source.label, - summary: entry.summary, - publishedAt: entry.publishedAt + readerID: spec.id, + title: article.title, + sourceURL: article.url, + sourceLabel: sourceLabel(for: article.url, sources: spec.sources), + summary: article.detail?.nilIfEmpty, + publishedAt: parsePublishedAt(article.publishedAt) ) } - } - private static func filter(_ items: [NewsItem], topics: [String]) -> [NewsItem] { - let needles = topics.map { $0.lowercased() }.filter { !$0.isEmpty } - guard !needles.isEmpty else { return items } - let matched = items.filter { item in - let hay = "\(item.title) \(item.summary ?? "")".lowercased() - return needles.contains { hay.contains($0) } + var summaryText: String? + if spec.mode == .summary { + guard let summarizer else { + throw NewsReaderError.summarizerUnavailable + } + summaryText = try await summarizer.summarize( + listName: spec.name, + topics: spec.topics, + articles: items + ) } - return matched.isEmpty ? items : matched + + return NewsReaderFetchResult(items: items, summaryText: summaryText) } - private static func uniqued(_ items: [NewsItem]) -> [NewsItem] { - var seen = Set() - var result: [NewsItem] = [] - for item in items { - if seen.insert(item.sourceURL).inserted { - result.append(item) + private static func sourceLabel(for url: String, sources: [NewsSource]) -> String { + if let host = URL(string: url)?.host { + if let match = sources.first(where: { URL(string: $0.url)?.host == host }) { + return match.label } + return host } - return result + return sources.first?.label ?? "Source" + } + + private static func parsePublishedAt(_ raw: String?) -> Date? { + guard let raw, !raw.isEmpty else { return nil } + let rfc = DateFormatter() + rfc.locale = Locale(identifier: "en_US_POSIX") + rfc.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + if let date = rfc.date(from: raw) { return date } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { return date } + iso.formatOptions = [.withInternetDateTime] + return iso.date(from: raw) + } +} + +public struct NewsReaderFetchResult: Sendable, Hashable { + public var items: [NewsItem] + public var summaryText: String? + + public init(items: [NewsItem], summaryText: String? = nil) { + self.items = items + self.summaryText = summaryText?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } +} + +public protocol NewsSummaryGenerating: Sendable { + func summarize(listName: String, topics: [String], articles: [NewsItem]) async throws -> String +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift b/packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift new file mode 100644 index 00000000..42ddc0ef --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift @@ -0,0 +1,72 @@ +import Foundation + +public protocol NewsWorkerRunning: Sendable { + func run(requestJSON: Data) async throws -> Data +} + +public struct NewsReaderWorkerRequest: Codable, Sendable, Hashable { + public var mode: NewsReaderMode + public var sources: [NewsSource] + public var topics: [String] + public var maxCount: Int + public var contextHint: String? + + public init( + mode: NewsReaderMode, + sources: [NewsSource], + topics: [String], + maxCount: Int, + contextHint: String? = nil + ) { + self.mode = mode + self.sources = sources + self.topics = topics + self.maxCount = maxCount + self.contextHint = contextHint?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + public func encodedJSON() throws -> Data { + try JSONEncoder.service.encode(self) + } +} + +public struct NewsReaderWorkerArticle: Codable, Sendable, Hashable { + public var title: String + public var url: String + public var detail: String? + public var publishedAt: String? + + enum CodingKeys: String, CodingKey { + case title + case url + case detail + case publishedAt = "published_at" + } +} + +public struct NewsReaderWorkerResult: Codable, Sendable, Hashable { + public var ok: Bool + public var mode: NewsReaderMode + public var articles: [NewsReaderWorkerArticle] + public var diagnostics: [String] +} + +public struct NewsReaderRunResult: Codable, Sendable, Hashable { + public var ok: Bool + public var stdout: Data + public var stderr: Data + public var message: String + + public init(ok: Bool, stdout: Data = Data(), stderr: Data = Data(), message: String = "") { + self.ok = ok + self.stdout = stdout + self.stderr = stderr + self.message = message + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift b/packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift new file mode 100644 index 00000000..0d082d2d --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum NewsWorkerBridge: Sendable { + public typealias Runner = @Sendable (Data) async throws -> Data + + private final class Storage: @unchecked Sendable { + var runner: Runner? + } + + private static let storage = Storage() + + public static func install(_ runner: @escaping Runner) { + storage.runner = runner + } + + public static func run(requestJSON: Data) async throws -> Data { + guard let runner = storage.runner else { + throw NewsReaderError.workerUnavailable("Docker news reader is not ready yet.") + } + return try await runner(requestJSON) + } +} + +public struct BridgedNewsWorker: NewsWorkerRunning { + public init() {} + + public func run(requestJSON: Data) async throws -> Data { + try await NewsWorkerBridge.run(requestJSON: requestJSON) + } +} diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift new file mode 100644 index 00000000..bcdc13fa --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift @@ -0,0 +1,447 @@ +import Foundation + +/// User-authored Agent Plugin intent before the factory materializes plugin.json, SKILL.md, and guest code. +public struct PluginSkillDraft: Sendable, Hashable { + public enum Trigger: String, Sendable, CaseIterable, Codable, Hashable { + case chat + case messaging + case schedule + case mention + + public var label: String { + switch self { + case .chat: return "When I ask in chat" + case .messaging: return "From Messaging" + case .schedule: return "On a schedule" + case .mention: return "When I type /plugin-name" + } + } + } + + public struct Example: Sendable, Hashable, Identifiable { + public var id: String + public var userSays: String + public var pluginDoes: String + + public init(id: String = UUID().uuidString, userSays: String, pluginDoes: String) { + self.id = id + self.userSays = userSays + self.pluginDoes = pluginDoes + } + } + + public enum PlannedKind: String, Sendable, Hashable { + case messagingConnector + case newsDigest + case customCapability + } + + public var goal: String + public var purpose: String + public var triggers: Set + public var examples: [Example] + public var pluginName: String + public var newsTopics: [String] + public var newsSourceURLs: [String] + + public init( + goal: String = "", + purpose: String = "", + triggers: Set = [.chat], + examples: [Example] = [], + pluginName: String = "", + newsTopics: [String] = [], + newsSourceURLs: [String] = [] + ) { + self.goal = goal + self.purpose = purpose + self.triggers = triggers + self.examples = examples + self.pluginName = pluginName + self.newsTopics = newsTopics + self.newsSourceURLs = newsSourceURLs + } + + public var plannedKind: PlannedKind { + PluginSkillDraftPlanner.inferKind(from: self) + } + + public var inferredConnectorVendor: PluginFactoryCreateInput.ConnectorVendor? { + PluginSkillDraftPlanner.inferConnectorVendor(from: self) + } + + public var isBuildable: Bool { + switch plannedKind { + case .messagingConnector: + return inferredConnectorVendor?.isSelectableInWizard == true + case .newsDigest, .customCapability: + return true + } + } + + public var buildBlockedReason: String? { + if plannedKind == .messagingConnector, + inferredConnectorVendor?.isSelectableInWizard != true { + let label = inferredConnectorVendor?.displayName ?? "That service" + return "\(label) messaging connectors are not available yet. Try Slack or describe a custom capability." + } + return nil + } + + public func isTriggerAvailable(_ trigger: Trigger) -> Bool { + PluginSkillDraftPlanner.availableTriggers(for: plannedKind).contains(trigger) + } + + public func skillMarkdown() -> String { + PluginSkillDraftPlanner.skillMarkdown(for: self) + } + + public func previewScenarios() -> [String] { + examples.map { example in + "When you say “\(example.userSays)”, the plugin will \(example.pluginDoes)." + } + } + + public func packageOutline() -> [String] { + switch plannedKind { + case .newsDigest: + return [ + "plugin.json — name, schedule, and reader settings", + "skills/\(normalizedPluginFolderName())/SKILL.md — when Derrick uses this list", + "News fetcher — loads articles with source links", + ] + case .messagingConnector, .customCapability: + return [ + "plugin.json — name, permissions, and secrets", + "skills/\(normalizedPluginFolderName())/SKILL.md — purpose and examples", + "app.derrick/plugin.go — guest program (compiled in Docker)", + "app.derrick/plugin — compiled binary", + ] + } + } + + public func factoryDescription() -> String { + PluginSkillDraftPlanner.factoryDescription(for: self) + } + + public func normalizedPluginID() throws -> String { + try PluginID.normalized(pluginName).rawValue + } + + private func normalizedPluginFolderName() -> String { + let trimmed = pluginName.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "plugin" } + return trimmed + .lowercased() + .replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } +} + +public enum PluginSkillDraftPlanner { + public static func availableTriggers( + for kind: PluginSkillDraft.PlannedKind + ) -> Set { + switch kind { + case .newsDigest: + return [.chat, .schedule] + case .messagingConnector: + return [.chat, .messaging, .mention] + case .customCapability: + return [.chat, .mention, .schedule] + } + } + + public static func sanitizeTriggers(in draft: inout PluginSkillDraft) { + let allowed = availableTriggers(for: draft.plannedKind) + draft.triggers = draft.triggers.intersection(allowed) + if draft.triggers.isEmpty { + draft.triggers = defaultTriggers(for: draft) + } + } + + public static func inferNewsMode(from draft: PluginSkillDraft) -> NewsReaderMode { + let text = combinedText(draft) + if ["summary", "summarize", "summaries", "digest", "brief", "overview"] + .contains(where: { text.contains($0) }) { + return .summary + } + if ["crawl", "website", "homepage", "web page", "webpage", "site"] + .contains(where: { text.contains($0) }) { + return .list + } + return .rss + } + + public static func inferKind(from draft: PluginSkillDraft) -> PluginSkillDraft.PlannedKind { + let text = combinedText(draft) + if looksLikeNews(text) { return .newsDigest } + if looksLikeMessaging(text) { return .messagingConnector } + return .customCapability + } + + public static func inferConnectorVendor( + from draft: PluginSkillDraft + ) -> PluginFactoryCreateInput.ConnectorVendor? { + let text = combinedText(draft) + if text.contains("slack") { return .slack } + if text.contains("telegram") { return .telegram } + if text.contains("whatsapp") { return .whatsapp } + if text.contains("discord") { return .discord } + if inferKind(from: draft) == .messagingConnector { return .slack } + return nil + } + + public static func applyGoal(_ goal: String, to draft: inout PluginSkillDraft, existingPluginIDs: [String]) { + let trimmed = goal.trimmingCharacters(in: .whitespacesAndNewlines) + draft.goal = trimmed + if draft.purpose.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft.purpose = trimmed + } + if draft.pluginName.isEmpty { + draft.pluginName = suggestPluginName(for: draft, existingIDs: existingPluginIDs) + } + if draft.examples.isEmpty { + draft.examples = defaultExamples(for: draft) + } + if draft.triggers.isEmpty { + draft.triggers = defaultTriggers(for: draft) + } + sanitizeTriggers(in: &draft) + if inferKind(from: draft) == .newsDigest, draft.newsTopics.isEmpty { + draft.newsTopics = defaultNewsTopics(from: trimmed) + } + if inferKind(from: draft) == .newsDigest, draft.newsSourceURLs.isEmpty { + draft.newsSourceURLs = [NewsPresetSource.googleNews.source.url] + } + } + + public static func skillMarkdown(for draft: PluginSkillDraft) -> String { + let name = draft.pluginName.trimmingCharacters(in: .whitespacesAndNewlines) + let triggerLines = draft.triggers.sorted { $0.rawValue < $1.rawValue }.map(\.label) + let exampleBlock = draft.examples.map { example in + """ + ### User + \(example.userSays) + + ### Plugin + \(example.pluginDoes) + """ + }.joined(separator: "\n\n") + + return """ + # \(name.isEmpty ? "Plugin" : name) + + ## Purpose + \(draft.purpose.trimmingCharacters(in: .whitespacesAndNewlines)) + + ## When to use + \(triggerLines.map { "- \($0)" }.joined(separator: "\n")) + + ## Examples + \(exampleBlock.isEmpty ? "_Add at least one example before building._" : exampleBlock) + """ + } + + public static func factoryDescription(for draft: PluginSkillDraft) -> String { + let purpose = draft.purpose.trimmingCharacters(in: .whitespacesAndNewlines) + let examples = draft.examples + .map { "User: \($0.userSays) → Plugin: \($0.pluginDoes)" } + .joined(separator: "\n") + switch draft.plannedKind { + case .messagingConnector: + let vendor = inferConnectorVendor(from: draft)?.displayName ?? "messaging" + return """ + \(purpose) + + Messaging connector for \(vendor). List conversations as tabs, load messages including reply threads, and send messages. + + Confirmed behavior: + \(examples) + """ + case .newsDigest: + return """ + \(purpose) + + News reader that fetches articles from configured sources and includes source links. + + Topics: \(draft.newsTopics.joined(separator: ", ")) + Sources: \(draft.newsSourceURLs.joined(separator: ", ")) + """ + case .customCapability: + return """ + \(purpose) + + Confirmed behavior: + \(examples) + """ + } + } + + public static func factoryGoal( + for draft: PluginSkillDraft, + crawlSummary: String?, + hostNotes: [String] + ) throws -> String { + let description = factoryDescription(for: draft) + switch draft.plannedKind { + case .messagingConnector: + guard let vendor = inferConnectorVendor(from: draft) else { + throw PluginSkillDraftError.missingConnectorVendor + } + var extra = hostNotes + extra.append("SKILL.md draft:\n\(skillMarkdown(for: draft))") + return try ConnectorContractPrompts.factoryGoal( + vendorLabel: vendor.displayName, + scope: .fullSync, + vendor: vendor, + crawlSummary: crawlSummary, + reference: extra.joined(separator: "\n"), + includeVendorBindings: true + ) + case .customCapability: + return """ + Create an Agent Plugin for this user goal. + + \(description) + + Host notes: + \(hostNotes.joined(separator: "\n")) + + SKILL.md draft (write this into skills/): + \(skillMarkdown(for: draft)) + + Return go_source, test_input_json, and skill_files. The host writes plugin.json when a host manifest is supplied; otherwise include a valid manifest in your output path via the builder contract. + """ + case .newsDigest: + throw PluginSkillDraftError.newsUsesReaderPath + } + } + + private static func combinedText(_ draft: PluginSkillDraft) -> String { + [draft.goal, draft.purpose, draft.pluginName] + .joined(separator: " ") + .lowercased() + } + + private static func looksLikeNews(_ text: String) -> Bool { + ["news", "rss", "headline", "digest", "articles", "reader"].contains { text.contains($0) } + } + + private static func looksLikeMessaging(_ text: String) -> Bool { + ["slack", "telegram", "whatsapp", "discord", "messaging", "channel", "inbox", "dm", "chat app"] + .contains { text.contains($0) } + } + + private static func suggestPluginName(for draft: PluginSkillDraft, existingIDs: [String]) -> String { + switch inferKind(from: draft) { + case .messagingConnector: + if let vendor = inferConnectorVendor(from: draft) { + return ConnectorPluginNaming.defaultPluginID(vendor: vendor, existingIDs: existingIDs) + } + return ConnectorPluginNaming.defaultPluginID(vendor: .slack, existingIDs: existingIDs) + case .newsDigest: + let base = "news-list" + if !existingIDs.contains(base) { return base } + var index = 2 + while existingIDs.contains("\(base)-\(index)") { index += 1 } + return "\(base)-\(index)" + case .customCapability: + let words = draft.goal + .lowercased() + .split { !$0.isLetter && !$0.isNumber } + .filter { $0.count > 2 } + .prefix(3) + let stem = words.isEmpty ? "custom-plugin" : String(words.joined(separator: "-")) + if !existingIDs.contains(stem) { return stem } + return "\(stem)-2" + } + } + + private static func defaultTriggers(for draft: PluginSkillDraft) -> Set { + switch inferKind(from: draft) { + case .messagingConnector: + return [.messaging, .chat] + case .newsDigest: + return [.schedule, .chat] + case .customCapability: + return [.chat] + } + } + + private static func defaultExamples(for draft: PluginSkillDraft) -> [PluginSkillDraft.Example] { + switch inferKind(from: draft) { + case .messagingConnector: + let vendor = inferConnectorVendor(from: draft)?.displayName ?? "Slack" + return [ + PluginSkillDraft.Example( + userSays: "Show my \(vendor) channels", + pluginDoes: "list conversations you can access as tabs in Messaging" + ), + PluginSkillDraft.Example( + userSays: "Send “hello” to #general", + pluginDoes: "post the message in that channel" + ), + ] + case .newsDigest: + return [ + PluginSkillDraft.Example( + userSays: "What's in my news list?", + pluginDoes: "fetch the latest articles with links to the original sources" + ), + ] + case .customCapability: + let snippet = draft.goal.trimmingCharacters(in: .whitespacesAndNewlines) + return [ + PluginSkillDraft.Example( + userSays: snippet.isEmpty ? "Do the thing I described" : snippet, + pluginDoes: "run the guest program and return a clear result" + ), + ] + } + } + + private static func defaultNewsTopics(from goal: String) -> [String] { + let lower = goal.lowercased() + var topics: [String] = [] + if lower.contains("financ") || lower.contains("market") { + topics.append(NewsPresetTopic.financial.displayName) + } + if lower.contains("tech") { + topics.append(NewsPresetTopic.tech.displayName) + } + if lower.contains("world") || lower.contains("international") { + topics.append(NewsPresetTopic.international.displayName) + } + if lower.contains("politic") { + topics.append(NewsPresetTopic.politics.displayName) + } + if lower.contains("science") { + topics.append(NewsPresetTopic.science.displayName) + } + if lower.contains("sport") { + topics.append(NewsPresetTopic.sports.displayName) + } + if topics.isEmpty { + topics.append(NewsPresetTopic.tech.displayName) + } + return topics + } +} + +public enum PluginSkillDraftError: Error, LocalizedError { + case missingConnectorVendor + case newsUsesReaderPath + case invalidPluginName + + public var errorDescription: String? { + switch self { + case .missingConnectorVendor: + return "Could not determine which messaging service this plugin targets." + case .newsUsesReaderPath: + return "News lists use the reader path, not the plugin factory." + case .invalidPluginName: + return "Choose a valid plugin name using letters, numbers, and hyphens." + } + } +} diff --git a/packages/Structure/Sources/Contract/GuestContract.swift b/packages/Structure/Sources/Contract/GuestContract.swift index fdf034ad..4385f61c 100644 --- a/packages/Structure/Sources/Contract/GuestContract.swift +++ b/packages/Structure/Sources/Contract/GuestContract.swift @@ -15,6 +15,7 @@ public enum GuestContract: Sendable { case workerProduct = "worker-product.schema.json" case webCrawlerResult = "web-crawler-result.schema.json" case fileExtractorResult = "file-extractor-result.schema.json" + case newsReaderResult = "news-reader-result.schema.json" case scriptExecContract = "script-exec-contract.schema.json" } diff --git a/packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json new file mode 100644 index 00000000..6632851d --- /dev/null +++ b/packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/news-reader-result.json", + "title": "News reader worker stdout", + "description": "JSON object written to stdout by derrick-news-reader.", + "$ref": "worker-product.schema.json#/$defs/news_reader_result" +} diff --git a/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json index 874f6bfb..611ef5fe 100644 --- a/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json +++ b/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://derrick.local/schemas/worker-product.json", "title": "Derrick trusted worker product contracts", - "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler and file extractor). Swift host and Go workers must match these schemas.", + "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler, file extractor, and news reader). Swift host and Go workers must match these schemas.", "$defs": { "string_list": { "type": "array", @@ -92,6 +92,35 @@ }, "diagnostics": { "$ref": "#/$defs/string_list" } } + }, + "news_reader_mode": { + "type": "string", + "enum": ["rss", "list", "summary"] + }, + "news_reader_article": { + "type": "object", + "required": ["title", "url"], + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "url": { "type": "string" }, + "detail": { "type": "string" }, + "published_at": { "type": "string" } + } + }, + "news_reader_result": { + "type": "object", + "required": ["ok", "mode", "articles", "diagnostics"], + "additionalProperties": false, + "properties": { + "ok": { "type": "boolean" }, + "mode": { "$ref": "#/$defs/news_reader_mode" }, + "articles": { + "type": "array", + "items": { "$ref": "#/$defs/news_reader_article" } + }, + "diagnostics": { "$ref": "#/$defs/string_list" } + } } } } diff --git a/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift b/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift index 489d77ac..040be5a6 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift @@ -14,6 +14,7 @@ public enum DerrickDockerRuntimeIdentity: Sendable { /// Name prefixes for current and unlabeled leftover containers. public static let namePrefixes = [ "derrick-web-crawler", + "derrick-news-reader", "derrick-guest-runtime", "derrick-swift-runtime", "derrick-file-extractor", diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift index cd1299f4..666ff6bf 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerProductImageDigests.generated.swift @@ -2,5 +2,5 @@ import Foundation /// Generated by scripts/record-docker-image-digests.sh — do not edit. public enum DockerProductImageDigests: Sendable { - public static let worker = DockerImageDigest(rawValue: "sha256:d686d16d5fb8fe0a54a1eb9bb9c8a27763b5d80145ad330c5205536d73afdda6") + public static let worker = DockerImageDigest(rawValue: "sha256:686c54118b75056b7ca9af17697516fc242e65b7a8da0e43bb6c0a35f31e8052") } diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift index 7a8f1f3a..9a5c9d52 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift @@ -8,6 +8,14 @@ public enum DockerWorkerRuntime: Sendable { public static let crawlerBinary = "/usr/local/bin/derrick-web-crawler" public static let extractorBinary = "/usr/local/bin/derrick-file-extractor" + public static let newsReaderBinary = "/usr/local/bin/derrick-news-reader" + /// Binaries that must exist in the unified worker image. + public static let requiredBinaries: [String] = [ + crawlerBinary, + extractorBinary, + newsReaderBinary, + ] + public static let guestBinaryPath = "/tmp/guest" public static let guestSourcePath = "/tmp/plugin.go" public static let goBinaryPath = "/usr/local/go/bin/go" @@ -19,4 +27,8 @@ public enum DockerWorkerRuntime: Sendable { public static let guestReadBinaryShell = "cat /tmp/guest" public static let pinnedDigest = DockerProductImageDigests.worker + + /// OCI label written by `docker/worker/Dockerfile`; used to detect stale local images. + public static let binariesLabelKey = "derrick.worker.binaries" + public static let binariesLabelValue = "crawler,extractor,news-reader" } diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift index 4a812e69..20586073 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryRuntimeTypes.swift @@ -26,4 +26,32 @@ public struct PluginFactoryRuntime: Sendable, Equatable { let language = PluginGuestLanguage(rawValue: languageRaw) ?? .go return PluginFactoryRuntime(language: language, entrypoint: entrypoint) } + + /// Package-relative guest source path used when hashing and verifying releases. + public static func guestSourcePackagePath( + runtimeJSON: String, + manifestJSON: String, + defaultPath: String = "app.derrick/plugin.go" + ) -> String { + if let runtime = decode(from: runtimeJSON) { + return normalizePackageRelativePath(runtime.entrypoint) + } + if let data = manifestJSON.data(using: .utf8), + let manifest = try? AgentPluginManifest.decode(data), + let entrypoint = manifest.derrick?.entrypoint { + return normalizePackageRelativePath(entrypoint) + } + return defaultPath + } + + private static func normalizePackageRelativePath(_ entrypoint: String) -> String { + var path = entrypoint.trimmingCharacters(in: .whitespacesAndNewlines) + if path.hasPrefix("./") { + path = String(path.dropFirst(2)) + } + while path.hasPrefix("/") { + path = String(path.dropFirst()) + } + return path + } } diff --git a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift index 2b37ff71..fdecbf55 100644 --- a/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift +++ b/packages/Structure/Sources/Plugin/Factory/PluginFactoryTypes.swift @@ -289,7 +289,6 @@ public struct PluginFactoryBuilderResponse: Codable, Sendable, Hashable { case pluginID = "plugin_id" case version, description case guestSource = "go_source" - case legacyPythonSource = "python_source" case legacySwiftSource = "swift_source" case testInputJSON = "test_input_json" case skillFiles = "skill_files" @@ -304,7 +303,6 @@ public struct PluginFactoryBuilderResponse: Codable, Sendable, Hashable { version = try container.decode(String.self, forKey: .version) description = try container.decode(String.self, forKey: .description) guestSource = try container.decodeIfPresent(String.self, forKey: .guestSource) - ?? container.decodeIfPresent(String.self, forKey: .legacyPythonSource) ?? container.decode(String.self, forKey: .legacySwiftSource) testInputJSON = try container.decode(String.self, forKey: .testInputJSON) skillFiles = try container.decodeIfPresent([PluginFactorySkillFile].self, forKey: .skillFiles) ?? [] @@ -533,10 +531,14 @@ public struct PluginFactoryRelease: Sendable, Hashable { } public func packageFiles() -> [String: Data] { + let guestPath = PluginFactoryRuntime.guestSourcePackagePath( + runtimeJSON: runtimeJSON, + manifestJSON: manifestJSON + ) var files: [String: Data] = [ "plugin.json": Data(manifestJSON.utf8), "app.derrick/runtime.json": Data(runtimeJSON.utf8), - "app.derrick/plugin.go": Data(guestSource.utf8), + guestPath: Data(guestSource.utf8), "app.derrick/plugin": compiledArtifact, ] for (path, body) in skillFiles { diff --git a/packages/Structure/Sources/Plugin/Manifest/DerrickRuntime.swift b/packages/Structure/Sources/Plugin/Manifest/DerrickRuntime.swift index bcfe23ef..9b95c902 100644 --- a/packages/Structure/Sources/Plugin/Manifest/DerrickRuntime.swift +++ b/packages/Structure/Sources/Plugin/Manifest/DerrickRuntime.swift @@ -1,6 +1,6 @@ import Foundation -/// Derrick runtime metadata (`app.derrick/runtime.json`) for a standalone Python entrypoint. +/// Derrick runtime metadata (`app.derrick/runtime.json`) for a standalone Go entrypoint. public struct DerrickRuntime: Codable, Sendable, Hashable { public var entrypoint: String public var dependencies: [String: String] @@ -66,7 +66,7 @@ public struct DerrickRuntime: Codable, Sendable, Hashable { if trimmed.hasPrefix("./") { return try PluginPath.validateRuntimeEntrypoint(trimmed) } - guard trimmed.hasSuffix(".py"), + guard trimmed.hasSuffix(".go"), !trimmed.contains("/"), !trimmed.contains("\\") else { throw PluginManifestError.invalidEntrypoint(raw) diff --git a/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift b/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift index 8a08c898..d575c57b 100644 --- a/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift +++ b/packages/Structure/Sources/Plugin/Manifest/PluginManifestError.swift @@ -47,7 +47,7 @@ public enum PluginManifestError: Error, Equatable, LocalizedError { case .invalidFieldType(let f): return "plugin.json field has the wrong type: \(f)" case .invalidEntrypoint(let p): - return "Entrypoint must be a plugin-relative .py path: \(p)" + return "Entrypoint must be a plugin-relative .go path: \(p)" case .pathNotRelative(let p): return "Path must be plugin-relative and start with ./: \(p)" case .pathEscapesRoot(let p): diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index f79aa322..b3996871 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -1205,6 +1205,7 @@ import Testing #expect(DerrickDockerRuntimeIdentity.createLabelArguments == ["--label", "app.derrick=runtime"]) #expect(DerrickDockerRuntimeIdentity.namePrefixes == [ "derrick-web-crawler", + "derrick-news-reader", "derrick-guest-runtime", "derrick-swift-runtime", "derrick-file-extractor", @@ -1214,10 +1215,10 @@ import Testing #expect(!DerrickDockerRuntimeIdentity.isAllowedPsFilter("name=nginx")) #expect( DerrickDockerRuntimeIdentity.createHasRuntimeLabel( - ["create"] + DerrickDockerRuntimeIdentity.createLabelArguments + ["python:3.14.7"] + ["create"] + DerrickDockerRuntimeIdentity.createLabelArguments + [DockerWorkerRuntime.image] ) ) - #expect(!DerrickDockerRuntimeIdentity.createHasRuntimeLabel(["create", "--name", "x", "python:3.14.7"])) + #expect(!DerrickDockerRuntimeIdentity.createHasRuntimeLabel(["create", "--name", "x", DockerWorkerRuntime.image])) } @Test func webCrawlerProductImageBuildUsesPackagesContext() { @@ -1476,17 +1477,18 @@ import Testing #expect(!PluginFactoryValidationExpectations.isSendOnlyConnector(manifestJSON: sendAndReceive)) } - @Test func connectorFactoryFailureReturnsToVendorStep() { - #expect(PluginFactoryCreateInput.failureStep(forStage: "docs") == .vendor) - #expect(PluginFactoryCreateInput.failureStep(forStage: "factory") == .vendor) - #expect(PluginFactoryCreateInput.failureStep(forStage: "review") == .vendor) - #expect(PluginFactoryCreateInput.failureStep(forStage: "description") == .vendor) - #expect(PluginFactoryCreateInput.failureStep(forStage: "type") == .type) - #expect(PluginFactoryCreateInput.failureStep(forStage: "name") == .name) - #expect(PluginFactoryCreateInput.failureStep(forStage: "auth") == .auth) - #expect(PluginFactoryCreateInput.failureStep(forStage: "discover") == .auth) + @Test func pluginStudioFailureMapsToSkillFirstSteps() { + #expect(PluginFactoryCreateInput.failureStep(forStage: "docs") == .build) + #expect(PluginFactoryCreateInput.failureStep(forStage: "factory") == .build) + #expect(PluginFactoryCreateInput.failureStep(forStage: "review") == .build) + #expect(PluginFactoryCreateInput.failureStep(forStage: "description") == .skill) + #expect(PluginFactoryCreateInput.failureStep(forStage: "type") == .skill) + #expect(PluginFactoryCreateInput.failureStep(forStage: "name") == .skill) + #expect(PluginFactoryCreateInput.failureStep(forStage: "auth") == .credentials) + #expect(PluginFactoryCreateInput.failureStep(forStage: "discover") == .credentials) #expect(PluginFactoryCreateInput.failureStep(forStage: "paywall") == .news) #expect(PluginFactoryCreateInput.failureStep(forStage: "news") == .news) + #expect(PluginFactoryCreateInput.failureStep(forStage: "goal") == .goal) } @Test func connectorWizardOffersFullSyncOnly() { diff --git a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift index 56235d1f..1af33245 100644 --- a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift +++ b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift @@ -19,179 +19,50 @@ import Structure url: URL(string: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml")! ) == nil ) - #expect( - NewsPaywall.preflightRejection( - url: URL(string: "https://feeds.bbci.co.uk/news/rss.xml")! - ) == nil - ) } - @Test func knownPaywallHostIsDetected() { - #expect(NewsPaywall.hostLooksPaywalled(URL(string: "https://www.nytimes.com/2024/01/01/world.html")!)) - #expect(!NewsPaywall.hostLooksPaywalled(URL(string: "https://feeds.bbci.co.uk/news/rss.xml")!)) - } + @Test func inferNewsModeMapsSummaryCrawlAndRSS() { + let summary = PluginSkillDraft(goal: "Give me a summary of tech news") + #expect(PluginSkillDraftPlanner.inferNewsMode(from: summary) == .summary) - @Test func htmlPaywallIsRejected() { - let html = "Subscribe to continue reading this article" - let reason = NewsPaywall.rejectionReason( - url: URL(string: "https://www.nytimes.com/story")!, - status: 200, - contentType: "text/html", - body: Data(html.utf8) - ) - #expect(reason != nil) - #expect(reason?.localizedCaseInsensitiveContains("paywall") == true) - } + let crawl = PluginSkillDraft(goal: "Crawl the BBC homepage for headlines") + #expect(PluginSkillDraftPlanner.inferNewsMode(from: crawl) == .list) - @Test func rssFromPaywalledPublisherIsAllowed() { - let rss = """ - Feed - """ - let reason = NewsPaywall.rejectionReason( - url: URL(string: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml")!, - status: 200, - contentType: "application/rss+xml", - body: Data(rss.utf8) - ) - #expect(reason == nil) + let rss = PluginSkillDraft(goal: "Fetch tech headlines from Google News RSS") + #expect(PluginSkillDraftPlanner.inferNewsMode(from: rss) == .rss) } - @Test func parserReadsRssItemsWithLinks() { - let rss = """ - - - Hello worldhttps://example.com/helloBody - - """ - let entries = NewsFeedParser.parse( - data: Data(rss.utf8), - sourceLabel: "Example", - fallbackPageURL: URL(string: "https://example.com/feed")! + @Test func legacySummariesModeDecodesToSummary() throws { + struct Wrapper: Decodable { let mode: NewsReaderMode } + let wrapper = try JSONDecoder.service.decode( + Wrapper.self, + from: Data(#"{"mode":"summaries"}"#.utf8) ) - #expect(entries.count == 1) - #expect(entries[0].title == "Hello world") - #expect(entries[0].sourceURL == "https://example.com/hello") + #expect(wrapper.mode == .summary) } - @Test func refreshFailsPaywalledURL() async throws { - let client = StubNewsClient(response: NewsHTTPResponse( - status: 200, - contentType: "text/html", - body: Data("This article is for subscribers only".utf8) - )) - let spec = NewsReaderSpec( - name: "Test", - topics: [], - sources: [NewsSource(label: "NYT", url: "https://www.nytimes.com/story")] - ) - do { - _ = try await NewsReaderRefresh.validateAndFetch(spec: spec, client: client) - Issue.record("expected paywall failure") - } catch let error as NewsReaderError { - guard case .paywalled = error else { - Issue.record("expected paywalled, got \(error)") - return - } + @Test func presetSourcesUsePublicFeedsNotPaywalledArticlePages() { + for preset in NewsPresetSource.allCases { + let url = URL(string: preset.source.url)! + #expect( + NewsPaywall.preflightRejection(url: url) == nil, + "Expected \(preset.source.label) feed to pass paywall preflight" + ) } } - @Test func refreshReturnsLinkedItemsCapped() async throws { - let rss = """ - - Onehttps://example.com/1 - Twohttps://example.com/2 - Threehttps://example.com/3 - - """ - let client = StubNewsClient(response: NewsHTTPResponse( - status: 200, - contentType: "application/rss+xml", - body: Data(rss.utf8) - )) - let spec = NewsReaderSpec( - name: "Cap", - topics: [], - sources: [NewsSource(label: "Ex", url: "https://example.com/rss.xml")], - maxCount: 2 - ) - let items = try await NewsReaderRefresh.validateAndFetch(spec: spec, client: client) - #expect(items.count == 2) - #expect(items.allSatisfy { !$0.sourceURL.isEmpty }) - } - - @Test func googleNewsHomepageMapsToRSS() { - let mapped = NewsSourceURL.canonicalFetchURL(URL(string: "https://news.google.com/")!) - #expect(mapped.host == "news.google.com") - #expect(mapped.path == "/rss") - let already = NewsSourceURL.canonicalFetchURL( - URL(string: "https://news.google.com/rss?hl=en-US")! - ) - #expect(already.path.contains("rss")) - } - - @Test func googleNewsTopicPageMapsToSectionRSSWhenHintMentionsTech() { - let topic = URL( - string: "https://news.google.com/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGx1YlY4U0FtVnVHZ0pWVXlnQVAB" - )! - let mapped = NewsSourceURL.canonicalFetchURL( - topic, - contextHint: "today's technology headlines" - ) - #expect(mapped.path == "/rss/headlines/section/topic/TECHNOLOGY") - } - - @Test func googleNewsTopicPageFallsBackToGeneralRSSWithoutHint() { - let topic = URL( - string: "https://news.google.com/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGx1YlY4U0FtVnVHZ0pWVXlnQVAB" - )! - let mapped = NewsSourceURL.canonicalFetchURL(topic) - #expect(mapped.path == "/rss") - } - - @Test func refreshParsesGoogleNewsHomepageViaRSS() async throws { - let rss = """ - - World headlinehttps://news.google.com/articles/abcSummary bit - - """ - let client = StubNewsClient(response: NewsHTTPResponse( - status: 200, - contentType: "application/rss+xml", - body: Data(rss.utf8) - )) - let spec = NewsReaderSpec( - name: "Google", - topics: [], - sources: [NewsSource(label: "Google News", url: "https://news.google.com")], - mode: .summaries, - maxCount: 10 + @Test func workerRequestEncodesModeAndSources() throws { + let request = NewsReaderWorkerRequest( + mode: .rss, + sources: [NewsSource(label: "BBC", url: "https://feeds.bbci.co.uk/news/rss.xml")], + topics: ["Tech"], + maxCount: 10, + contextHint: "tech-news" ) - let items = try await NewsReaderRefresh.validateAndFetch(spec: spec, client: client) - #expect(items.count == 1) - #expect(items[0].title == "World headline") - #expect(items[0].sourceURL.contains("news.google.com")) - #expect(NewsReaderRefresh.digest(from: items).contains("World headline")) - } - - @Test func liveGoogleNewsRSSHasLinkedSummaries() async throws { - let spec = NewsReaderSpec( - name: "Google live", - topics: [], - sources: [NewsPresetSource.googleNews.source], - mode: .summaries, - maxCount: 8 - ) - let items = try await NewsReaderRefresh.validateAndFetch( - spec: spec, - client: URLSessionNewsHTTPClient() - ) - #expect(!items.isEmpty) - #expect(items.allSatisfy { !$0.sourceURL.isEmpty && !$0.title.isEmpty }) - #expect(!NewsReaderRefresh.digest(from: items).isEmpty) - } - - private struct StubNewsClient: NewsHTTPClient { - let response: NewsHTTPResponse - func get(url: URL) async throws -> NewsHTTPResponse { response } + let json = try request.encodedJSON() + let decoded = try JSONDecoder.service.decode(NewsReaderWorkerRequest.self, from: json) + #expect(decoded.mode == .rss) + #expect(decoded.sources.count == 1) + #expect(decoded.topics == ["Tech"]) } } diff --git a/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift b/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift new file mode 100644 index 00000000..561a0a5b --- /dev/null +++ b/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import Structure + +@Suite struct PluginSkillDraftTests { + @Test func infersSummariesModeFromSummaryGoal() { + var draft = PluginSkillDraft(goal: "Give me a summary of tech headlines") + PluginSkillDraftPlanner.applyGoal(draft.goal, to: &draft, existingPluginIDs: []) + #expect(PluginSkillDraftPlanner.inferNewsMode(from: draft) == .summary) + } + + @Test func infersNewsFromGoal() { + var draft = PluginSkillDraft(goal: "Fetch tech news headlines daily") + PluginSkillDraftPlanner.applyGoal(draft.goal, to: &draft, existingPluginIDs: []) + #expect(draft.plannedKind == .newsDigest) + #expect(!draft.newsTopics.isEmpty) + #expect(!draft.newsSourceURLs.isEmpty) + } + + @Test func infersSlackConnectorFromGoal() { + var draft = PluginSkillDraft(goal: "Send messages in Slack from Messaging") + PluginSkillDraftPlanner.applyGoal(draft.goal, to: &draft, existingPluginIDs: []) + #expect(draft.plannedKind == .messagingConnector) + #expect(draft.inferredConnectorVendor == .slack) + #expect(draft.examples.count >= 1) + } + + @Test func infersCustomCapabilityFromGenericGoal() { + var draft = PluginSkillDraft(goal: "Summarize my clipboard when I ask") + PluginSkillDraftPlanner.applyGoal(draft.goal, to: &draft, existingPluginIDs: []) + #expect(draft.plannedKind == .customCapability) + } + + @Test func skillMarkdownIncludesPurposeAndExamples() { + var draft = PluginSkillDraft( + goal: "Do something", + purpose: "Help with tasks", + triggers: [.chat], + examples: [ + PluginSkillDraft.Example(userSays: "run it", pluginDoes: "returns a result"), + ], + pluginName: "my-plugin" + ) + let markdown = draft.skillMarkdown() + #expect(markdown.contains("my-plugin")) + #expect(markdown.contains("Help with tasks")) + #expect(markdown.contains("run it")) + } + + @Test func makeFromSkillDraftBuildsConnectorInput() throws { + var draft = PluginSkillDraft( + goal: "Slack connector", + purpose: "Messaging", + examples: [ + PluginSkillDraft.Example(userSays: "hi", pluginDoes: "send"), + ], + pluginName: "slack-connector-1" + ) + let input = try PluginFactoryCreateInput.makeFromSkillDraft(draft) + #expect(input.pluginType == .connector) + #expect(input.vendor == .slack) + #expect(input.pluginID == "slack-connector-1") + #expect(input.skillMarkdown != nil) + } + + @Test func makeFromSkillDraftBuildsCustomInput() throws { + var draft = PluginSkillDraft( + goal: "Summarize text", + purpose: "Summarize", + examples: [ + PluginSkillDraft.Example(userSays: "summarize", pluginDoes: "returns summary"), + ], + pluginName: "summarizer" + ) + let input = try PluginFactoryCreateInput.makeFromSkillDraft(draft) + #expect(input.pluginType == .custom) + #expect(input.vendor == nil) + #expect(input.pluginID == "summarizer") + } + + @Test func newsDigestOnlyAllowsChatAndScheduleTriggers() { + let allowed = PluginSkillDraftPlanner.availableTriggers(for: .newsDigest) + #expect(allowed == Set([.chat, .schedule])) + var draft = PluginSkillDraft( + goal: "tech news", + triggers: [.chat, .schedule, .mention, .messaging], + pluginName: "tech-news" + ) + PluginSkillDraftPlanner.sanitizeTriggers(in: &draft) + #expect(draft.triggers == Set([.chat, .schedule])) + } + + @Test func messagingConnectorDisallowsScheduleTrigger() { + let allowed = PluginSkillDraftPlanner.availableTriggers(for: .messagingConnector) + #expect(allowed == Set([.chat, .messaging, .mention])) + var draft = PluginSkillDraft( + goal: "Slack connector", + triggers: [.schedule, .chat, .messaging], + pluginName: "slack-1" + ) + PluginSkillDraftPlanner.sanitizeTriggers(in: &draft) + #expect(draft.triggers == Set([.chat, .messaging])) + } + + @Test func newsDraftRejectsFactoryInput() { + let draft = PluginSkillDraft( + goal: "news digest", + purpose: "News", + examples: [PluginSkillDraft.Example(userSays: "news", pluginDoes: "fetch")], + pluginName: "news-list", + newsTopics: ["Tech"], + newsSourceURLs: ["https://news.google.com/rss"] + ) + #expect(throws: PluginSkillDraftError.newsUsesReaderPath) { + try PluginFactoryCreateInput.makeFromSkillDraft(draft) + } + } +} diff --git a/scripts/reset-local-state.sh b/scripts/reset-local-state.sh new file mode 100755 index 00000000..fb845c65 --- /dev/null +++ b/scripts/reset-local-state.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_GROUP="$HOME/Library/Group Containers/VUSK4B2YKQ.derrick.shared" + +echo "==> Quit Derrick before resetting local state." +echo "==> This removes all local SQLite data (chats, plugins, messaging, credentials in DB)." +echo "==> Keychain plugin secrets are not removed." + +removed_dbs=0 +if [[ -d "$APP_GROUP" ]]; then + while IFS= read -r db; do + rm -f "$db" "${db}-wal" "${db}-shm" + echo "removed $(basename "$db") at ${db%/*}" + removed_dbs=$((removed_dbs + 1)) + done < <(find "$APP_GROUP" -name 'derrick.sqlite3' 2>/dev/null) +fi + +if [[ "$removed_dbs" -eq 0 ]]; then + echo "no derrick.sqlite3 files found under $APP_GROUP" +fi + +if command -v docker >/dev/null 2>&1; then + echo "==> Removing Derrick runtime containers" + for filter in 'name=derrick-guest-runtime' 'name=derrick-swift-runtime' 'label=app.derrick=runtime'; do + ids="$(docker ps -aq --filter "$filter" || true)" + if [[ -n "$ids" ]]; then + docker rm -f $ids >/dev/null 2>&1 || true + fi + done + + echo "==> Removing obsolete guest images (Python / legacy guest-runtime)" + for image in \ + 'derrick-guest-runtime:python-v1' \ + 'python:3.14.7'; do + if docker image inspect "$image" >/dev/null 2>&1; then + docker rmi -f "$image" >/dev/null + echo "removed image $image" + fi + done +else + echo "docker not available — skipped container/image cleanup" +fi + +echo +echo "Done. Reopen Derrick from $ROOT (go-workers) to recreate an empty database." +echo "Policy rules seed automatically on first UI launch." diff --git a/ui/JobKeepAlive/DaemonModuleBootstrap.swift b/ui/JobKeepAlive/DaemonModuleBootstrap.swift index 638ec769..df0c8d87 100644 --- a/ui/JobKeepAlive/DaemonModuleBootstrap.swift +++ b/ui/JobKeepAlive/DaemonModuleBootstrap.swift @@ -37,6 +37,9 @@ enum DaemonModuleBootstrap { let tools = try await MCPServiceToolHost.shared.searchTools(query: query, principal: principal) return MCPToolSearchResultDTO(ok: true, tools: tools, message: "ok") } + InProcessServiceBridges.runNewsReader = { requestJSON in + try await MCPServiceToolHost.shared.runNewsReader(requestJSON: requestJSON) + } InProcessServiceBridges.jobLocalProxy = JobServiceExportedObject() InProcessServiceBridges.jobNetworkPreflight = { toolName, argumentsJSON, jobID in let repo = try await JobServiceStore.shared.sharedRepository() @@ -99,7 +102,7 @@ enum DaemonModuleBootstrap { } /// Remove leftover runtime containers before the job scheduler starts. - /// Guest image pull stays in the background so a cold Python pull does not block jobs. + /// Guest image pull stays in the background so a cold worker image pull does not block jobs. private static func sweepEmbeddedDockerLeftovers() async { guard DerrickProcessRole.isDaemon else { return } do { diff --git a/ui/JobKeepAlive/DaemonUnifiedXPC.swift b/ui/JobKeepAlive/DaemonUnifiedXPC.swift index 4806931a..1bd52e6d 100644 --- a/ui/JobKeepAlive/DaemonUnifiedXPC.swift +++ b/ui/JobKeepAlive/DaemonUnifiedXPC.swift @@ -234,6 +234,10 @@ final class DaemonUnifiedExportedObject: NSObject, DerrickDaemonServiceXPC, @unc mcp.searchTools(requestJSON: requestJSON, withReply: reply) } + func runNewsReader(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) { + mcp.runNewsReader(requestJSON: requestJSON, withReply: reply) + } + // MARK: - Workflow runtime func startWorkflow(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) { diff --git a/ui/MCPService/MCPServiceExportedObject.swift b/ui/MCPService/MCPServiceExportedObject.swift index e1a90b7e..d7dc7540 100644 --- a/ui/MCPService/MCPServiceExportedObject.swift +++ b/ui/MCPService/MCPServiceExportedObject.swift @@ -177,4 +177,21 @@ final class MCPServiceExportedObject: NSObject, MCPServiceXPC { } } } + + func runNewsReader(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) { + let data = requestJSON as Data + Task { + do { + let result = try await MCPServiceToolHost.shared.runNewsReader(requestJSON: data) + reply((try MCPServiceXPCCodec.encodeNewsReaderRunResult(result)) as NSData) + } catch { + fputs("[MCPService] runNewsReader failed: \(error.localizedDescription)\n", stderr) + let result = NewsReaderRunResult(ok: false, message: error.localizedDescription) + reply( + (try? MCPServiceXPCCodec.encodeNewsReaderRunResult(result)) as NSData? + ?? Data("{}".utf8) as NSData + ) + } + } + } } diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index d3aa0aff..7a7e5335 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -195,6 +195,36 @@ actor MCPServiceToolHost { return made } + func runNewsReader(requestJSON: Data, timeoutSeconds: Int = 180) async throws -> NewsReaderRunResult { + _ = try await ensureReady() + let executor = NewsReaderDockerExecutor( + executor: MCPServiceDockerHelperRunner.shared.makeStdinCLIExecutor() + ) + let result = try await executor.run(input: requestJSON, timeoutSeconds: timeoutSeconds) + let stderrText = String(decoding: result.stderr, as: UTF8.self) + if result.exitCode != 0 { + let detail = newsReaderFailureDetail(exitCode: result.exitCode, stderr: stderrText) + await MCPServiceStore.shared.log( + level: .error, + message: "news reader failed: \(detail)", + code: "news_reader_failed" + ) + return NewsReaderRunResult( + ok: false, + stdout: result.stdout, + stderr: result.stderr, + message: detail + ) + } + guard !result.stdout.isEmpty else { + return NewsReaderRunResult( + ok: false, + message: "News reader returned no output." + ) + } + return NewsReaderRunResult(ok: true, stdout: result.stdout, stderr: result.stderr) + } + func searchTools(query: String, principal: ServicePrincipal) async throws -> [MCPToolDescriptorDTO] { let client = try await ensureReady().client await MCPServiceStore.shared.log( @@ -358,3 +388,15 @@ private func pluginFactoryFailureDetail(for error: Error) -> String { } return error.localizedDescription } + +private func newsReaderFailureDetail(exitCode: Int32, stderr: String) -> String { + if exitCode == 126 { + return """ + The worker image on this Mac is missing the news reader binary. Quit Derrick completely and reopen it so the worker image can rebuild, then try again. + """ + } + if !stderr.isEmpty { + return stderr + } + return "exit \(exitCode)" +} diff --git a/ui/SharedAgentRuntime/Services/MCPServiceClient.swift b/ui/SharedAgentRuntime/Services/MCPServiceClient.swift index 048f7cec..4f4dbd1e 100644 --- a/ui/SharedAgentRuntime/Services/MCPServiceClient.swift +++ b/ui/SharedAgentRuntime/Services/MCPServiceClient.swift @@ -176,6 +176,25 @@ public final class MCPServiceClient: @unchecked Sendable { } } + public func runNewsReader(requestJSON: Data, timeoutSeconds: Int = 180) async throws -> NewsReaderRunResult { + if DerrickProcessRole.isDaemon, let run = InProcessServiceBridges.runNewsReader { + return try await run(requestJSON) + } + nonisolated(unsafe) let proxy = try remoteProxy() + let payload = requestJSON as NSData + return try await invoke(timeout: MCPToolCallTimeouts.newsReaderNanoseconds) { + try await withCheckedThrowingContinuation { cont in + proxy.runNewsReader(requestJSON: payload) { data in + do { + cont.resume(returning: try MCPServiceXPCCodec.decodeNewsReaderRunResult(data as Data)) + } catch { + cont.resume(throwing: error) + } + } + } + } + } + public func searchTools(principal: ServicePrincipal, query: String = "") async throws -> MCPToolSearchResultDTO { if DerrickProcessRole.isDaemon, let search = InProcessServiceBridges.mcpSearchTools { return try await search(principal, query) diff --git a/ui/ui/News/MCPServiceNewsWorker.swift b/ui/ui/News/MCPServiceNewsWorker.swift new file mode 100644 index 00000000..54c8277f --- /dev/null +++ b/ui/ui/News/MCPServiceNewsWorker.swift @@ -0,0 +1,23 @@ +import Foundation +import Structure + +/// Runs the Docker news reader in MCPService where the egress proxy can bind. +struct MCPServiceNewsWorker: NewsWorkerRunning { + func run(requestJSON: Data) async throws -> Data { + _ = try await MCPServiceClient.shared.ensureUpAndHealth(retries: 2) + let result = try await MCPServiceClient.shared.runNewsReader(requestJSON: requestJSON) + guard result.ok else { + let detail = result.message.nilIfEmpty + ?? String(decoding: result.stderr, as: UTF8.self).nilIfEmpty + ?? "News reader failed." + throw NewsReaderError.workerUnavailable(detail) + } + return result.stdout + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/ui/ui/News/NewsReaderStore.swift b/ui/ui/News/NewsReaderStore.swift index e5ea5ddc..dde776d8 100644 --- a/ui/ui/News/NewsReaderStore.swift +++ b/ui/ui/News/NewsReaderStore.swift @@ -14,18 +14,30 @@ final class NewsReaderStore: ObservableObject { @Published private(set) var lastError: String? private var repository: DBRepository? - private let client: any NewsHTTPClient + private let worker: any NewsWorkerRunning + private var summarizer: NewsReaderSummarizer? - init(client: any NewsHTTPClient = URLSessionNewsHTTPClient()) { - self.client = client + init( + worker: any NewsWorkerRunning = MCPServiceNewsWorker(), + summarizer: NewsReaderSummarizer? = nil + ) { + self.worker = worker + self.summarizer = summarizer + } + + func attachSummarizer(_ settings: LLMModelSettings) { + self.summarizer = NewsReaderSummarizer(settings: settings) } var selectedReader: NewsReaderSpec? { readers.first { $0.id == selectedReaderID } } - func configure(repository: DBRepository) async { + func configure(repository: DBRepository, summarizerSettings: LLMModelSettings? = nil) async { self.repository = repository + if let summarizerSettings { + attachSummarizer(summarizerSettings) + } await reload() } @@ -62,14 +74,19 @@ final class NewsReaderStore: ObservableObject { } var next = spec next.updatedAt = .now - let items = try await NewsReaderRefresh.validateAndFetch(spec: next, client: client) + let fetched = try await NewsReaderRefresh.validateAndFetch( + spec: next, + worker: worker, + summarizer: summarizer + ) next.lastFetchedAt = .now next.lastError = nil + next.summaryText = fetched.summaryText try await repository.upsertNewsReader(next) - try await repository.replaceNewsItems(readerID: next.id, items: items) - await reload() + try await repository.replaceNewsItems(readerID: next.id, items: fetched.items) selectedReaderID = next.id - self.items = items + self.items = fetched.items + await reload() return next } @@ -78,13 +95,18 @@ final class NewsReaderStore: ObservableObject { isRefreshing = true defer { isRefreshing = false } do { - let fetched = try await NewsReaderRefresh.validateAndFetch(spec: reader, client: client) + let fetched = try await NewsReaderRefresh.validateAndFetch( + spec: reader, + worker: worker, + summarizer: summarizer + ) reader.lastFetchedAt = .now reader.lastError = nil reader.updatedAt = .now + reader.summaryText = fetched.summaryText try await repository.upsertNewsReader(reader) - try await repository.replaceNewsItems(readerID: reader.id, items: fetched) - items = fetched + try await repository.replaceNewsItems(readerID: reader.id, items: fetched.items) + items = fetched.items lastError = nil await reload() } catch { diff --git a/ui/ui/News/NewsReaderSummarizer.swift b/ui/ui/News/NewsReaderSummarizer.swift new file mode 100644 index 00000000..74b441c0 --- /dev/null +++ b/ui/ui/News/NewsReaderSummarizer.swift @@ -0,0 +1,65 @@ +import Foundation +import LLMAgentClient +import Structure + +struct NewsReaderSummarizer: NewsSummaryGenerating { + let settings: LLMModelSettings + + func summarize(listName: String, topics: [String], articles: [NewsItem]) async throws -> String { + let model = await MainActor.run { settings.summarizerModel } + guard let apiKey = await LLMProviderCredentialGate.resolveAPIKey(for: model) else { + throw NewsReaderError.summarizerUnavailable + } + + let prompt = Self.prompt(listName: listName, topics: topics, articles: articles) + let request = AgentRequest.prompt( + prompt, + system: """ + You summarize news for the user. Write clear prose in plain English. + Include markdown links to the original articles using the URLs provided. + Do not invent stories or URLs. + """, + temperature: 0.2 + ) + let text: String + switch model { + case .gemini(let geminiModel): + let client = GeminiAgentClient(provider: GeminiProvider(apiKey: apiKey)) + text = try await collect(client.stream(request, model: geminiModel)) + case .openai(let openAIModel): + let client = OpenAIAgentClient(provider: OpenAIProvider(apiKey: apiKey)) + text = try await collect(client.stream(request, model: openAIModel)) + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NewsReaderError.fetchFailed(url: listName, detail: "Summarizer returned an empty response.") + } + return trimmed + } + + private func collect(_ stream: AsyncThrowingStream) async throws -> String { + let (text, usage) = try await collectAgentStream(stream) + if let usage { + _ = await UsageLimitsService.shared.recordAPIUsage(usage) + } + return text + } + + private static func prompt(listName: String, topics: [String], articles: [NewsItem]) -> String { + var lines = [ + "Summarize these articles for the list \"\(listName)\".", + ] + if !topics.isEmpty { + lines.append("Topics: \(topics.joined(separator: ", "))") + } + lines.append("Articles:") + for article in articles.prefix(12) { + var entry = "- \(article.title) (\(article.sourceURL))" + if let detail = article.summary, !detail.isEmpty { + entry += "\n \(detail)" + } + lines.append(entry) + } + return lines.joined(separator: "\n") + } +} diff --git a/ui/ui/News/NewsWorkspaceView.swift b/ui/ui/News/NewsWorkspaceView.swift index ff8a4ab5..43a54e19 100644 --- a/ui/ui/News/NewsWorkspaceView.swift +++ b/ui/ui/News/NewsWorkspaceView.swift @@ -56,7 +56,7 @@ struct NewsWorkspaceView: View { .foregroundStyle(.secondary) Text("No news lists yet") .font(.title3.weight(.semibold)) - Text("Create a News reader from Plugins. You can pick several topics and several sources.") + Text("Create a news list from Plugins. Derrick fetches articles in Docker, then shows them here.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -77,11 +77,14 @@ struct NewsWorkspaceView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) } - if store.selectedReader?.mode == .summaries, !store.items.isEmpty { + + if store.selectedReader?.mode == .summary, + let summary = store.selectedReader?.summaryText, + !summary.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Summary") .font(.subheadline.weight(.semibold)) - Text(NewsReaderRefresh.digest(from: store.items)) + Text(LocalizedStringKey(summary)) .font(.body) .textSelection(.enabled) } @@ -89,31 +92,34 @@ struct NewsWorkspaceView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) } - ForEach(store.items) { item in - VStack(alignment: .leading, spacing: 6) { - Link(destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) { - Text(item.title) - .font(.body.weight(.semibold)) - .multilineTextAlignment(.leading) - } - HStack(spacing: 8) { - Text(item.sourceLabel) - .font(.caption) - .foregroundStyle(.secondary) - Link("Source", destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) - .font(.caption.weight(.semibold)) - } - if let summary = item.summary, !summary.isEmpty, store.selectedReader?.mode == .list { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(4) + + if store.selectedReader?.mode != .summary { + ForEach(store.items) { item in + articleCard(item) + } + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Sources") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(store.items) { item in + HStack(alignment: .top, spacing: 8) { + Text("•") + VStack(alignment: .leading, spacing: 2) { + Link(item.title, destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) + .font(.caption.weight(.semibold)) + Text(item.sourceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + } } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) } + if store.items.isEmpty, store.lastError == nil, store.selectedReader?.lastError == nil { Text("No articles yet. Refresh this list.") .font(.subheadline) @@ -124,6 +130,36 @@ struct NewsWorkspaceView: View { } } + @ViewBuilder + private func articleCard(_ item: NewsItem) -> some View { + VStack(alignment: .leading, spacing: 6) { + Link(destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) { + Text(item.title) + .font(.body.weight(.semibold)) + .multilineTextAlignment(.leading) + } + HStack(spacing: 8) { + Text(item.sourceLabel) + .font(.caption) + .foregroundStyle(.secondary) + if let host = URL(string: item.sourceURL)?.host { + Link(host, destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + } + if let summary = item.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(4) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) + } + private func headerSubtitle(_ reader: NewsReaderSpec) -> String { let topics = reader.topics.isEmpty ? "All topics" : reader.topics.joined(separator: ", ") let sources = "\(reader.sources.count) source\(reader.sources.count == 1 ? "" : "s")" diff --git a/ui/ui/Plugins/PluginCreationController.swift b/ui/ui/Plugins/PluginCreationController.swift index 8888d399..fb654916 100644 --- a/ui/ui/Plugins/PluginCreationController.swift +++ b/ui/ui/Plugins/PluginCreationController.swift @@ -2,21 +2,25 @@ import Combine import DBRepository import Foundation import Structure +import SwiftUI @MainActor final class PluginCreationController: ObservableObject { enum Phase: Equatable { case intro - case chooseType - case chooseVendor - case chooseName - case chooseNews + case goal + case skill + case preview case discoveringAuth case creating case collectCredentials(pluginID: String) case failed(step: PluginFactoryCreateInput.FailureStep, message: String, technicalDetail: String? = nil) - case succeeded(pluginID: String) - case succeededNews(readerID: String) + case succeeded(pluginID: String, outcome: SuccessOutcome) + } + + enum SuccessOutcome: Equatable { + case plugin + case newsList } struct ProgressStepState: Identifiable, Equatable { @@ -35,22 +39,7 @@ final class PluginCreationController: ObservableObject { @Published private(set) var phase: Phase = .intro @Published private(set) var statusMessage = "" @Published private(set) var progressSteps: [ProgressStepState] = [] - @Published var selectedType: PluginFactoryCreateInput.PluginType = .connector - @Published var selectedVendor: PluginFactoryCreateInput.ConnectorVendor = .slack - @Published var selectedScope: PluginFactoryCreateInput.ConnectorScope = .fullSync - @Published var customVendorName = "" - @Published var connectorName = "" - @Published private(set) var defaultNameReady = false - @Published var newsName = "" - @Published var selectedNewsTopics: Set = [] - @Published var extraNewsTopics: [String] = [] - @Published var newsTopicDraft = "" - @Published var selectedNewsSources: Set = [] - @Published var extraNewsURLs: [String] = [] - @Published var newsURLDraft = "" - @Published var newsMode: NewsReaderMode = .list - @Published var newsMaxCount = 20 - @Published var newsSchedule: NewsReaderSchedule = .off + @Published var skillDraft = PluginSkillDraft() @Published private(set) var credentialFields: [PluginCredentialFieldPresentation] = [] @Published var credentialDrafts: [String: String] = [:] @@ -59,8 +48,6 @@ final class PluginCreationController: ObservableObject { private var pollAfterSeq = 0 private var pollTask: Task? private var discoverTask: Task? - private var namePrepareTask: Task? - private var generatedConnectorName = "" private var pendingAuth: ConnectorAuthDiscovery? private var creationAPIKey: String? private var creationReviewerModelJSON: String? @@ -69,27 +56,33 @@ final class PluginCreationController: ObservableObject { deinit { pollTask?.cancel() discoverTask?.cancel() - namePrepareTask?.cancel() } - var canConfirmName: Bool { - guard defaultNameReady else { return false } - let trimmed = connectorName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - return (try? PluginID.normalized(trimmed)) != nil + var canContinueFromGoal: Bool { + !skillDraft.goal.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - var canConfirmVendor: Bool { - guard selectedVendor.isSelectableInWizard else { return false } - if selectedVendor == .custom { - return !customVendorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + var canContinueFromSkill: Bool { + guard canConfirmPluginName else { return false } + guard !skillDraft.purpose.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + guard !skillDraft.examples.isEmpty else { return false } + guard skillDraft.examples.allSatisfy({ + !$0.userSays.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !$0.pluginDoes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) else { return false } + if skillDraft.plannedKind == .newsDigest { + return !skillDraft.newsTopics.isEmpty && !skillDraft.newsSourceURLs.isEmpty } - return true + return skillDraft.buildBlockedReason == nil } - var canConfirmNews: Bool { - let nameOK = !newsName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - return nameOK && !builtNewsSources().isEmpty + var canConfirmPluginName: Bool { + let trimmed = skillDraft.pluginName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + if skillDraft.plannedKind == .newsDigest { + return true + } + return (try? PluginID.normalized(trimmed)) != nil } var canSaveCredentials: Bool { @@ -104,281 +97,168 @@ final class PluginCreationController: ObservableObject { self.repository = repository } + func skillDraftBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { self.skillDraft[keyPath: keyPath] }, + set: { newValue in + var draft = self.skillDraft + draft[keyPath: keyPath] = newValue + self.skillDraft = draft + } + ) + } + func showIntro() { cancelPolling() discoverTask?.cancel() - namePrepareTask?.cancel() pendingAuth = nil phase = .intro statusMessage = "" progressSteps = [] credentialFields = [] credentialDrafts = [:] - selectedScope = .fullSync - connectorName = "" - generatedConnectorName = "" - defaultNameReady = false + skillDraft = PluginSkillDraft() } func beginCreate() { cancelPolling() - phase = .chooseType + phase = .goal statusMessage = "" progressSteps = [] } - func selectType(_ type: PluginFactoryCreateInput.PluginType) { - selectedType = type - } - - func confirmTypeSelection() { - if selectedType == .newsReader { - resetNewsDraft() - phase = .chooseNews - return - } - guard selectedType == .connector else { - phase = .failed( - step: .type, - message: "Custom plugins are not available yet." - ) - return - } - selectedVendor = .slack - refreshDefaultConnectorName() - phase = .chooseVendor - } - - func confirmVendor( - sessionID: String, - helperAPIKey: String?, - helperReviewerModelJSON: String? - ) { - selectedScope = .fullSync - creationSessionID = sessionID - creationAPIKey = helperAPIKey - creationReviewerModelJSON = helperReviewerModelJSON - defaultNameReady = false - phase = .chooseName - namePrepareTask?.cancel() - namePrepareTask = Task { @MainActor in + func continueFromGoal() { + guard canContinueFromGoal else { return } + Task { @MainActor in await PluginFactoryListStore.shared.reload() - guard !Task.isCancelled else { return } - refreshDefaultConnectorName() - defaultNameReady = true - startAuthDiscovery() + var draft = skillDraft + PluginSkillDraftPlanner.applyGoal( + draft.goal, + to: &draft, + existingPluginIDs: PluginFactoryListStore.shared.pluginIDs + ) + skillDraft = draft + phase = .skill } } - func confirmConnectorName() { - guard canConfirmName else { return } - if let pendingAuth { - presentCredentialsOrFail(auth: pendingAuth) - } else { - phase = .discoveringAuth - statusMessage = "Reading how this service authenticates…" - } + func continueToPreview() { + guard canContinueFromSkill else { return } + phase = .preview } - func goBackToTypeSelection() { - namePrepareTask?.cancel() + func goBackToGoal() { discoverTask?.cancel() pendingAuth = nil - defaultNameReady = false - phase = .chooseType + phase = .goal } - func goBackToVendor() { - namePrepareTask?.cancel() + func goBackToSkill() { discoverTask?.cancel() pendingAuth = nil - defaultNameReady = false - phase = .chooseVendor + phase = .skill } - func addNewsTopic() { - let topic = newsTopicDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !topic.isEmpty else { return } - if !extraNewsTopics.contains(where: { $0.compare(topic, options: .caseInsensitive) == .orderedSame }) { - extraNewsTopics.append(topic) + func addExample() { + mutateSkillDraft { + $0.examples.append(PluginSkillDraft.Example(userSays: "", pluginDoes: "")) } - newsTopicDraft = "" } - func removeNewsTopic(_ topic: String) { - extraNewsTopics.removeAll { $0 == topic } + func removeExample(id: String) { + mutateSkillDraft { $0.examples.removeAll { $0.id == id } } } - func addNewsURL() { - let url = newsURLDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !url.isEmpty else { return } - extraNewsURLs.append(url) - newsURLDraft = "" + func updateExample(id: String, userSays: String? = nil, pluginDoes: String? = nil) { + mutateSkillDraft { draft in + guard let index = draft.examples.firstIndex(where: { $0.id == id }) else { return } + if let userSays { draft.examples[index].userSays = userSays } + if let pluginDoes { draft.examples[index].pluginDoes = pluginDoes } + } } - func removeNewsURL(_ url: String) { - extraNewsURLs.removeAll { $0 == url } + func toggleTrigger(_ trigger: PluginSkillDraft.Trigger) { + mutateSkillDraft { draft in + guard draft.isTriggerAvailable(trigger) else { return } + if draft.triggers.contains(trigger) { + draft.triggers.remove(trigger) + } else { + draft.triggers.insert(trigger) + } + } } - func startNewsCreation() { - let spec = NewsReaderSpec( - name: newsName, - topics: builtNewsTopics(), - sources: builtNewsSources(), - mode: newsMode, - maxCount: newsMaxCount, - schedule: newsSchedule - ) - if let blocked = spec.sources.compactMap({ source -> (NewsSource, String)? in - guard let url = URL(string: source.url), - let reason = NewsPaywall.preflightRejection(url: url) else { return nil } - return (source, reason) - }).first { - phase = .failed( - step: .news, - message: NewsReaderError.paywalled(url: blocked.0.url, detail: blocked.1).errorDescription - ?? "This source is behind a paywall, which is not supported yet." - ) - return - } - phase = .creating - statusMessage = "Checking sources…" - progressSteps = [ - ProgressStepState(id: "sources", title: "Check sources for paywalls", status: .active), - ProgressStepState(id: "fetch", title: "Fetch articles with source links", status: .pending), - ] - pollTask?.cancel() - pollTask = Task { @MainActor in - do { - let saved = try await NewsReaderStore.shared.create(spec) - setProgressStep("sources", status: .completed) - setProgressStep("fetch", status: .completed) - phase = .succeededNews(readerID: saved.id) - } catch let error as NewsReaderError { - setProgressStep("sources", status: .failed) - phase = .failed( - step: .news, - message: error.localizedDescription, - technicalDetail: String(describing: error) - ) - } catch { - setProgressStep("sources", status: .failed) - phase = .failed( - step: .news, - message: error.localizedDescription - ) + func addNewsTopic() { + let topic = skillDraft.goal.trimmingCharacters(in: .whitespacesAndNewlines) + guard !topic.isEmpty else { return } + mutateSkillDraft { draft in + if !draft.newsTopics.contains(where: { $0.compare(topic, options: .caseInsensitive) == .orderedSame }) { + draft.newsTopics.append(topic) } } } - func builtNewsTopics() -> [String] { - selectedNewsTopics.map(\.displayName) + extraNewsTopics + func removeNewsTopic(_ topic: String) { + mutateSkillDraft { $0.newsTopics.removeAll { $0 == topic } } } - func builtNewsSources() -> [NewsSource] { - var sources = selectedNewsSources.map(\.source) - for raw in extraNewsURLs + [newsURLDraft] { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let normalized = NewsSourceURL.canonicalFetchURL( - URL(string: trimmed.contains("://") ? trimmed : "https://\(trimmed)") - ?? URL(string: "https://news.google.com/rss")! - ).absoluteString - sources.append(NewsSource(label: hostLabel(normalized), url: normalized)) + func addNewsTopicFromPreset(_ topic: String) { + let trimmed = topic.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + mutateSkillDraft { draft in + if !draft.newsTopics.contains(trimmed) { + draft.newsTopics.append(trimmed) + } } - var seen = Set() - return sources.filter { seen.insert($0.url).inserted } } - private func resetNewsDraft() { - newsName = "" - selectedNewsTopics = [] - extraNewsTopics = [] - newsTopicDraft = "" - selectedNewsSources = [] - extraNewsURLs = [] - newsURLDraft = "" - newsMode = .list - newsMaxCount = 20 - newsSchedule = .off + func addNewsURL(_ raw: String) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let normalized = NewsSourceURL.canonicalFetchURL( + URL(string: trimmed.contains("://") ? trimmed : "https://\(trimmed)") + ?? URL(string: "https://news.google.com/rss")! + ).absoluteString + mutateSkillDraft { draft in + if !draft.newsSourceURLs.contains(normalized) { + draft.newsSourceURLs.append(normalized) + } + } + } + + func removeNewsURL(_ url: String) { + mutateSkillDraft { $0.newsSourceURLs.removeAll { $0 == url } } } - private func hostLabel(_ urlString: String) -> String { - URL(string: urlString)?.host ?? urlString + private func mutateSkillDraft(_ transform: (inout PluginSkillDraft) -> Void) { + var draft = skillDraft + transform(&draft) + skillDraft = draft } - func startCreation( + func confirmPreview( sessionID: String, helperAPIKey: String?, helperReviewerModelJSON: String? ) { - guard selectedVendor.isSelectableInWizard else { - phase = .failed( - step: .vendor, - message: "Only Slack connectors can be created right now." - ) - return - } - guard let helperAPIKey, !helperAPIKey.isEmpty else { - phase = .failed( - step: .vendor, - message: "Add an API key in Settings before creating a plugin." - ) - return - } - guard let pluginID = normalizedConnectorName(), - let auth = pendingAuth - else { - phase = .failed( - step: .name, - message: "Name this connector and save its credentials before creating it." - ) + creationSessionID = sessionID + creationAPIKey = helperAPIKey + creationReviewerModelJSON = helperReviewerModelJSON + + if skillDraft.plannedKind == .newsDigest { + startNewsCreation() return } - guard auth.authScheme.isSupportedInWizard else { - phase = .failed( - step: .auth, - message: "OAuth connectors are not available yet. Use a bot token or API key." - ) + + if skillDraft.plannedKind == .messagingConnector { + phase = .discoveringAuth + statusMessage = "Reading how this service authenticates…" + resetProgressSteps() + startAuthDiscovery() return } - selectedScope = .fullSync - cancelPolling() - phase = .creating - statusMessage = "Starting connector creation…" - resetProgressSteps() - pollAfterSeq = 0 - - let input = PluginFactoryCreateInput.makeConnector( - vendor: selectedVendor, - pluginID: pluginID, - auth: auth, - customVendorName: selectedVendor == .custom ? customVendorName : nil, - scope: selectedScope, - userDescription: "" - ) - - pollTask = Task { @MainActor in - do { - let inputJSON = try input.encodedJSON() - let handle = try await WorkflowRuntimeClient.shared.startWorkflow( - WorkflowStartRequest( - kind: .pluginFactoryCreate, - sessionID: sessionID, - agentID: "ui", - inputJSON: inputJSON, - principal: .agent(sessionID: sessionID, agentID: "ui"), - helperAPIKey: helperAPIKey, - helperReviewerModelJSON: helperReviewerModelJSON - ) - ) - workflowID = handle.workflowID - await pollUntilTerminal() - } catch { - phase = .failed(step: .creating, message: error.localizedDescription) - } - } + startFactoryCreation() } func saveCredentialsAndFinish() { @@ -390,14 +270,10 @@ final class PluginCreationController: ObservableObject { drafts: credentialDrafts ) markProgressCompleted("credentials") - startCreation( - sessionID: creationSessionID, - helperAPIKey: creationAPIKey, - helperReviewerModelJSON: creationReviewerModelJSON - ) + startFactoryCreation() } catch { phase = .failed( - step: .auth, + step: .credentials, message: "Could not save credentials: \(error.localizedDescription)" ) } @@ -407,13 +283,11 @@ final class PluginCreationController: ObservableObject { switch phase { case .failed(let step, _, _): switch step { - case .type: phase = .chooseType - case .news: phase = .chooseNews - case .name: phase = .chooseName - case .auth: phase = .chooseName - case .vendor, .description, .creating: - selectedVendor = .slack - phase = .chooseVendor + case .goal: phase = .goal + case .skill, .news: phase = .skill + case .preview: phase = .preview + case .credentials: phase = .preview + case .build: phase = .preview } default: phase = .intro @@ -424,13 +298,119 @@ final class PluginCreationController: ObservableObject { showIntro() } + private func startNewsCreation() { + let sources = skillDraft.newsSourceURLs.map { url in + NewsSource(label: URL(string: url)?.host ?? url, url: url) + } + let spec = NewsReaderSpec( + name: skillDraft.pluginName, + topics: skillDraft.newsTopics, + sources: sources, + mode: PluginSkillDraftPlanner.inferNewsMode(from: skillDraft), + maxCount: 20, + schedule: .off + ) + if let blocked = spec.sources.compactMap({ source -> (NewsSource, String)? in + guard let url = URL(string: source.url), + let reason = NewsPaywall.preflightRejection(url: url) else { return nil } + return (source, reason) + }).first { + phase = .failed( + step: .news, + message: NewsReaderError.paywalled(url: blocked.0.url, detail: blocked.1).errorDescription + ?? "This source is behind a paywall, which is not supported yet." + ) + return + } + phase = .creating + statusMessage = "Starting news reader in Docker…" + progressSteps = [ + ProgressStepState(id: "skill", title: "Write SKILL.md", status: .completed), + ProgressStepState(id: "sources", title: "Check sources", status: .active), + ProgressStepState(id: "fetch", title: "Run news reader", status: .pending), + ] + if spec.mode == .summary { + progressSteps.append( + ProgressStepState(id: "summary", title: "Summarize with AI", status: .pending) + ) + } + pollTask?.cancel() + pollTask = Task { @MainActor in + do { + let saved = try await NewsReaderStore.shared.create(spec) + setProgressStep("sources", status: .completed) + setProgressStep("fetch", status: .completed) + if spec.mode == .summary { + setProgressStep("summary", status: .completed) + } + phase = .succeeded(pluginID: saved.id, outcome: .newsList) + } catch let error as NewsReaderError { + setProgressStep("sources", status: .failed) + phase = .failed( + step: .news, + message: error.localizedDescription, + technicalDetail: String(describing: error) + ) + } catch { + setProgressStep("sources", status: .failed) + phase = .failed(step: .news, message: error.localizedDescription) + } + } + } + + private func startFactoryCreation() { + guard let creationAPIKey, !creationAPIKey.isEmpty else { + phase = .failed( + step: .build, + message: "Add an API key in Settings before creating a plugin." + ) + return + } + do { + let input = try PluginFactoryCreateInput.makeFromSkillDraft(skillDraft, auth: pendingAuth) + cancelPolling() + phase = .creating + statusMessage = "Building your plugin…" + resetProgressSteps() + pollAfterSeq = 0 + + pollTask = Task { @MainActor in + do { + let inputJSON = try input.encodedJSON() + let handle = try await WorkflowRuntimeClient.shared.startWorkflow( + WorkflowStartRequest( + kind: .pluginFactoryCreate, + sessionID: creationSessionID, + agentID: "ui", + inputJSON: inputJSON, + principal: .agent(sessionID: creationSessionID, agentID: "ui"), + helperAPIKey: creationAPIKey, + helperReviewerModelJSON: creationReviewerModelJSON + ) + ) + workflowID = handle.workflowID + await pollUntilTerminal() + } catch { + phase = .failed(step: .build, message: error.localizedDescription) + } + } + } catch { + phase = .failed(step: .skill, message: error.localizedDescription) + } + } + private func resetProgressSteps() { progressSteps = [ - ProgressStepState(id: "docs", title: "Read vendor API docs", status: .pending), - ProgressStepState(id: "factory", title: "Build and test plugin", status: .pending), + ProgressStepState(id: "skill", title: "Write SKILL.md", status: .completed), + ProgressStepState(id: "docs", title: "Read API docs", status: .pending), + ProgressStepState(id: "factory", title: "Build guest program", status: .pending), ProgressStepState(id: "review", title: "Safety review", status: .pending), - ProgressStepState(id: "credentials", title: "Save credentials to Keychain", status: .pending), + ProgressStepState(id: "trial", title: "Trial run", status: .pending), + ProgressStepState(id: "credentials", title: "Save credentials", status: .pending), ] + if skillDraft.plannedKind == .customCapability { + setProgressStep("docs", status: .completed) + } } private func setProgressStep(_ id: String, status: ProgressStepState.Status) { @@ -450,16 +430,15 @@ final class PluginCreationController: ObservableObject { switch stage?.lowercased() { case "crawl", "docs": setProgressStep("docs", status: .failed) - case "factory", "build", "review": + case "factory", "build": markProgressCompleted("docs") setProgressStep("factory", status: .failed) + case "review": + markProgressCompleted("docs") + markProgressCompleted("factory") setProgressStep("review", status: .failed) default: - if progressSteps.first(where: { $0.id == "docs" })?.status == .completed { - setProgressStep("factory", status: .failed) - } else { - setProgressStep("docs", status: .failed) - } + setProgressStep("factory", status: .failed) } } @@ -476,6 +455,7 @@ final class PluginCreationController: ObservableObject { markProgressCompleted("docs") markProgressCompleted("factory") markProgressCompleted("review") + markProgressCompleted("trial") default: break } @@ -488,10 +468,11 @@ final class PluginCreationController: ObservableObject { if message.contains("review decision=approved") { markProgressCompleted("factory") markProgressCompleted("review") + markProgressActive("trial") } if message.contains("review decision=rejected") || message.contains("review rejected=") { markProgressCompleted("factory") - markProgressActive("review") + setProgressStep("review", status: .failed) } default: break @@ -520,30 +501,27 @@ final class PluginCreationController: ObservableObject { markProgressCompleted("docs") markProgressCompleted("factory") markProgressCompleted("review") + markProgressCompleted("trial") await PluginFactoryListStore.shared.reload() if let pluginID = parseSuccessPluginID(result.resultJSON) { markProgressCompleted("credentials") - phase = .succeeded(pluginID: pluginID) + phase = .succeeded(pluginID: pluginID, outcome: .plugin) + } else if let saved = PluginFactoryListStore.shared.releases.first { + markProgressCompleted("credentials") + phase = .succeeded(pluginID: saved.pluginID, outcome: .plugin) } else { - if let saved = PluginFactoryListStore.shared.releases.first { - markProgressCompleted("credentials") - phase = .succeeded(pluginID: saved.pluginID) - } else { - phase = .failed( - step: .creating, - message: """ - The connector was not saved. Creation reported success but no plugin release was found. - """, - technicalDetail: result.resultJSON - ) - } + phase = .failed( + step: .build, + message: "The plugin was not saved. Creation reported success but no release was found.", + technicalDetail: result.resultJSON + ) } cancelPolling() return case .failed: let stage = result.events.last(where: { $0.kind == "log" })?.stage markProgressFailed(fromStage: stage) - let raw = result.errorMessage ?? "Connector creation failed." + let raw = result.errorMessage ?? "Plugin creation failed." let presentation = PluginFactoryCreateFailureMessage.presentation(raw) phase = .failed( step: PluginFactoryCreateInput.failureStep(forStage: stage), @@ -555,8 +533,8 @@ final class PluginCreationController: ObservableObject { case .cancelled: markProgressFailed(fromStage: "factory") phase = .failed( - step: .creating, - message: "The connector was not saved. Creation was cancelled before it finished." + step: .build, + message: "Plugin creation was cancelled before it finished." ) cancelPolling() return @@ -564,7 +542,7 @@ final class PluginCreationController: ObservableObject { break } } catch { - phase = .failed(step: .creating, message: error.localizedDescription) + phase = .failed(step: .build, message: error.localizedDescription) cancelPolling() return } @@ -581,29 +559,13 @@ final class PluginCreationController: ObservableObject { return result.pluginID } - private func refreshDefaultConnectorName() { - let existing = PluginFactoryListStore.shared.pluginIDs - let generated = ConnectorPluginNaming.defaultPluginID( - vendor: selectedVendor, - existingIDs: existing - ) - if connectorName.isEmpty - || ConnectorPluginNaming.isGeneratedDefault(pluginID: connectorName, vendor: selectedVendor) - || connectorName == generatedConnectorName { - connectorName = generated - } - generatedConnectorName = generated - } - - private func normalizedConnectorName() -> String? { - let trimmed = connectorName.trimmingCharacters(in: .whitespacesAndNewlines) - return try? PluginID.normalized(trimmed).rawValue - } - private func startAuthDiscovery() { discoverTask?.cancel() pendingAuth = nil - let vendor = selectedVendor + guard let vendor = skillDraft.inferredConnectorVendor else { + phase = .failed(step: .skill, message: "Could not determine which messaging service this plugin targets.") + return + } let sessionID = creationSessionID let apiKey = creationAPIKey let reviewerJSON = creationReviewerModelJSON @@ -674,20 +636,20 @@ final class PluginCreationController: ObservableObject { private func presentCredentialsOrFail(auth: ConnectorAuthDiscovery) { guard auth.authScheme.isSupportedInWizard else { phase = .failed( - step: .auth, + step: .credentials, message: "OAuth connectors are not available yet. Use a bot token or API key." ) return } - guard let pluginID = normalizedConnectorName() else { - phase = .chooseName + guard let pluginID = try? skillDraft.normalizedPluginID() else { + phase = .skill return } let descriptors = auth.secrets.map(\.descriptor) guard !descriptors.isEmpty else { phase = .failed( - step: .auth, - message: "Could not determine which credentials this connector needs." + step: .credentials, + message: "Could not determine which credentials this plugin needs." ) return } @@ -704,7 +666,7 @@ final class PluginCreationController: ObservableObject { let env = PluginSecretDevelopmentSource.resolve(pluginID: pluginID, fieldID: field.id) ?? "" return (field.id, env) }) - statusMessage = auth.setupHint ?? "Enter the credentials this connector needs. They are stored in Keychain on your Mac." + statusMessage = auth.setupHint ?? "Enter the credentials this plugin needs. They are stored in Keychain on your Mac." phase = .collectCredentials(pluginID: pluginID) } diff --git a/ui/ui/Plugins/PluginsWorkspaceView.swift b/ui/ui/Plugins/PluginsWorkspaceView.swift index 5034315c..11467e67 100644 --- a/ui/ui/Plugins/PluginsWorkspaceView.swift +++ b/ui/ui/Plugins/PluginsWorkspaceView.swift @@ -10,6 +10,9 @@ struct PluginsWorkspaceView: View { let onOpenMessagingConnector: (String) -> Void var onOpenNewsReader: (String) -> Void = { _ in } + @State private var newsTopicDraft = "" + @State private var newsURLDraft = "" + var body: some View { ZStack { Color(red: 252.0 / 255.0, green: 252.0 / 255.0, blue: 250.0 / 255.0) @@ -33,8 +36,8 @@ struct PluginsWorkspaceView: View { isPresented: true, minWidth: 400, minHeight: 0, - maxWidth: 520, - maxHeight: controller.phase == .chooseNews ? 720 : 560, + maxWidth: 560, + maxHeight: modalMaxHeight, onBackdropDismiss: canDismiss ? { controller.showIntro() } : nil, onEscape: canDismiss ? { controller.showIntro() } : nil, header: { @@ -57,6 +60,13 @@ struct PluginsWorkspaceView: View { ) } + private var modalMaxHeight: CGFloat { + switch controller.phase { + case .skill, .preview: return 720 + default: return 560 + } + } + private var canDismiss: Bool { switch controller.phase { case .creating, .discoveringAuth: return false @@ -67,17 +77,31 @@ struct PluginsWorkspaceView: View { private var modalTitle: String { switch controller.phase { case .intro: return "Create a plugin" - case .chooseType: return "Create a plugin" - case .chooseVendor: return "Choose a vendor" - case .chooseName: return "Name this connector" - case .chooseNews: return "News list" + case .goal: return "What should it do?" + case .skill: return "Define the skill" + case .preview: return "Preview" case .discoveringAuth: return "Reading authentication docs" - case .creating: return controller.selectedType == .newsReader ? "Creating news list" : "Creating connector" - case .collectCredentials: return "Connector credentials" - case .failed: return controller.selectedType == .newsReader ? "Could not create news list" : "Could not create connector" - case .succeeded: return "Connector ready" - case .succeededNews: return "News list ready" + case .creating: return creatingTitle + case .collectCredentials: return "Plugin credentials" + case .failed: return failureTitle + case .succeeded(_, let outcome): + return outcome == .newsList ? "News list ready" : "Plugin ready" + } + } + + private var creatingTitle: String { + switch controller.skillDraft.plannedKind { + case .newsDigest: return "Creating news list" + case .messagingConnector: return "Creating connector" + case .customCapability: return "Building plugin" + } + } + + private var failureTitle: String { + if case .failed(let step, _, _) = controller.phase, step == .news { + return "Could not create news list" } + return "Could not create plugin" } @ViewBuilder @@ -85,101 +109,31 @@ struct PluginsWorkspaceView: View { switch controller.phase { case .intro: Text(""" - A plugin is a small program that extends the capabilities of Derrick. This form will guide you through the process of building your own unique and secure plugins. + Describe what you want Derrick to do. Derrick will draft a skill, show you a preview, and build a secure plugin package. """) .font(.body) .fixedSize(horizontal: false, vertical: true) - case .chooseType: + case .goal: VStack(alignment: .leading, spacing: 10) { - Text("What kind of plugin do you want?") + Text("What do you want Derrick to do?") .font(.subheadline) .foregroundStyle(.secondary) - typeButton( - title: "Connector", - subtitle: "Messaging integration (Slack)", - type: .connector, - enabled: true - ) - typeButton( - title: "News reader", - subtitle: "Saved lists from topics and sources", - type: .newsReader, - enabled: true - ) - typeButton( - title: "Custom", - subtitle: "Coming soon", - type: .custom, - enabled: false + TextField( + "e.g. Send Slack messages from Messaging, or fetch tech headlines", + text: controller.skillDraftBinding(\.goal), + axis: .vertical ) + .textFieldStyle(.roundedBorder) + .lineLimit(3...6) + .accessibilityIdentifier("plugin-goal-field") } - case .chooseVendor: - VStack(alignment: .leading, spacing: 10) { - Text("Which service should this connector use?") - .font(.subheadline) - .foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], spacing: 8) { - ForEach(PluginFactoryCreateInput.ConnectorVendor.allCases, id: \.self) { vendor in - let enabled = vendor.isSelectableInWizard - Button { - guard enabled else { return } - controller.selectedVendor = vendor - } label: { - VStack(spacing: 4) { - Text(vendor.displayName) - .font(.subheadline.weight(.medium)) - if !enabled { - Text("Soon") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background( - enabled && controller.selectedVendor == vendor - ? Color.accentColor.opacity(0.15) - : Color.primary.opacity(enabled ? 0.05 : 0.03) - ) - .foregroundStyle(enabled ? .primary : .secondary) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - .disabled(!enabled) - .accessibilityLabel(enabled ? vendor.displayName : "\(vendor.displayName), coming soon") - } - } - if controller.selectedVendor == .custom { - TextField("Vendor name", text: $controller.customVendorName) - .textFieldStyle(.roundedBorder) - } - Text("This connector lists conversations as tabs, including reply threads, then sends and receives new messages.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - if controller.selectedVendor == .slack { - Text(ConnectorReplyThreadAccessMessage.slackSetupHint) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - case .chooseName: - VStack(alignment: .leading, spacing: 10) { - Text("Give this connector a name. You can change the default.") - .font(.subheadline) - .foregroundStyle(.secondary) - TextField("Connector name", text: $controller.connectorName) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("connector-plugin-name") - } + case .skill: + skillBuilderForm - case .chooseNews: - newsReaderForm + case .preview: + previewForm case .discoveringAuth, .creating: VStack(alignment: .leading, spacing: 14) { @@ -198,7 +152,7 @@ struct PluginsWorkspaceView: View { .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - if controller.selectedVendor == .slack { + if controller.skillDraft.inferredConnectorVendor == .slack { Text(ConnectorReplyThreadAccessMessage.slackSetupHint) .font(.caption) .foregroundStyle(.secondary) @@ -210,58 +164,10 @@ struct PluginsWorkspaceView: View { } case .failed(_, let message, let technicalDetail): - VStack(alignment: .leading, spacing: 10) { - if controller.selectedType == .newsReader { - Label( - message.localizedCaseInsensitiveContains("paywall") - ? "Blocked because of a paywall" - : "News list was not created", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(message.localizedCaseInsensitiveContains("paywall") ? Color.orange : Color.secondary) - .accessibilityIdentifier( - message.localizedCaseInsensitiveContains("paywall") - ? "news-paywall-blocked" - : "news-create-failed" - ) - if message.localizedCaseInsensitiveContains("paywall") { - Text(NewsPaywall.userWarning) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } else { - Label("Nothing was installed", systemImage: "minus.circle") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - Text("Your sidebar and Messaging are unchanged.") - .font(.caption) - .foregroundStyle(.secondary) - } - Text(message) - .font(.body) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - if let technicalDetail, !technicalDetail.isEmpty { - DisclosureGroup("Technical details") { - Text(technicalDetail) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - } - } - } + failureBody(message: message, technicalDetail: technicalDetail) - case .succeeded(let pluginID): - Text("Your connector /\(pluginID) is ready. Open it to start talking in Messaging.") - .font(.body) - .fixedSize(horizontal: false, vertical: true) - case .succeededNews: - Text("Your news list is ready. Every article includes a source link.") - .font(.body) - .fixedSize(horizontal: false, vertical: true) + case .succeeded(let pluginID, let outcome): + successBody(pluginID: pluginID, outcome: outcome) } } @@ -271,62 +177,48 @@ struct PluginsWorkspaceView: View { case .intro: HStack { Spacer() - Button("Create plugin") { controller.beginCreate() } + Button("Begin") { controller.beginCreate() } .buttonStyle(ModalPrimaryButtonStyle()) .keyboardShortcut(.defaultAction) } - case .chooseType: + case .goal: HStack { Button("Back") { controller.showIntro() } .buttonStyle(ModalSecondaryButtonStyle()) Spacer() - Button("Continue") { controller.confirmTypeSelection() } + Button("Continue") { controller.continueFromGoal() } .buttonStyle(ModalPrimaryButtonStyle()) - .disabled( - controller.selectedType != .connector - && controller.selectedType != .newsReader - ) + .disabled(!controller.canContinueFromGoal) .keyboardShortcut(.defaultAction) } - case .chooseVendor: - HStack { - Button("Back") { controller.goBackToTypeSelection() } - .buttonStyle(ModalSecondaryButtonStyle()) - Spacer() - Button("Continue") { - controller.confirmVendor( - sessionID: sessionID, - helperAPIKey: helperAPIKey, - helperReviewerModelJSON: helperReviewerModelJSON - ) - } - .buttonStyle(ModalPrimaryButtonStyle()) - .disabled(!sessionReady || !controller.canConfirmVendor) - .keyboardShortcut(.defaultAction) - } - - case .chooseName: + case .skill: HStack { - Button("Back") { controller.goBackToVendor() } + Button("Back") { controller.goBackToGoal() } .buttonStyle(ModalSecondaryButtonStyle()) Spacer() - Button("Continue") { controller.confirmConnectorName() } + Button("Preview") { controller.continueToPreview() } .buttonStyle(ModalPrimaryButtonStyle()) - .disabled(!controller.canConfirmName) + .disabled(!controller.canContinueFromSkill) .keyboardShortcut(.defaultAction) } - case .chooseNews: + case .preview: HStack { - Button("Back") { controller.goBackToTypeSelection() } + Button("Back") { controller.goBackToSkill() } .buttonStyle(ModalSecondaryButtonStyle()) Spacer() - Button("Create") { controller.startNewsCreation() } - .buttonStyle(ModalPrimaryButtonStyle()) - .disabled(!controller.canConfirmNews) - .keyboardShortcut(.defaultAction) + Button(buildButtonTitle) { + controller.confirmPreview( + sessionID: sessionID, + helperAPIKey: helperAPIKey, + helperReviewerModelJSON: helperReviewerModelJSON + ) + } + .buttonStyle(ModalPrimaryButtonStyle()) + .disabled(!sessionReady || !controller.canContinueFromSkill) + .keyboardShortcut(.defaultAction) } case .discoveringAuth, .creating: @@ -352,28 +244,448 @@ struct PluginsWorkspaceView: View { .buttonStyle(ModalSecondaryButtonStyle()) } - case .succeeded(let pluginID): + case .succeeded(let pluginID, let outcome): HStack { - Button("Done") { controller.dismissSuccess() } - .buttonStyle(ModalSecondaryButtonStyle()) + if outcome != .plugin || controller.skillDraft.plannedKind == .messagingConnector { + Button("Done") { controller.dismissSuccess() } + .buttonStyle(ModalSecondaryButtonStyle()) + } Spacer() - Button("Open connector") { - onOpenMessagingConnector(pluginID) + switch outcome { + case .newsList: + Button("Open news list") { + onOpenNewsReader(pluginID) + } + .buttonStyle(ModalPrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + case .plugin where controller.skillDraft.plannedKind == .messagingConnector: + Button("Open connector") { + onOpenMessagingConnector(pluginID) + } + .buttonStyle(ModalPrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + case .plugin: + Button("Done") { controller.dismissSuccess() } + .buttonStyle(ModalPrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) } - .buttonStyle(ModalPrimaryButtonStyle()) - .keyboardShortcut(.defaultAction) } - case .succeededNews(let readerID): + } + } + + private var buildButtonTitle: String { + switch controller.skillDraft.plannedKind { + case .newsDigest: return "Create" + default: return "Build plugin" + } + } + + private var skillBuilderForm: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + plannedKindBadge + + VStack(alignment: .leading, spacing: 6) { + Text("Purpose") + .font(.caption) + .foregroundStyle(.secondary) + TextField("What this plugin does", text: controller.skillDraftBinding(\.purpose), axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(2...4) + } + + VStack(alignment: .leading, spacing: 6) { + Text("When to use") + .font(.caption) + .foregroundStyle(.secondary) + FlowLayout(spacing: 8) { + ForEach(PluginSkillDraft.Trigger.allCases, id: \.self) { trigger in + triggerChip(trigger) + } + } + } + + examplesSection + + if controller.skillDraft.plannedKind == .newsDigest { + newsFieldsSection + } + + nameFieldSection + + if let blocked = controller.skillDraft.buildBlockedReason { + Text(blocked) + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private var plannedKindBadge: some View { + let (label, icon) = plannedKindPresentation + return Label(label, systemImage: icon) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.05), in: Capsule()) + } + + private var plannedKindPresentation: (String, String) { + switch controller.skillDraft.plannedKind { + case .messagingConnector: + let vendor = controller.skillDraft.inferredConnectorVendor?.displayName ?? "Messaging" + return ("\(vendor) connector", "bubble.left.and.bubble.right") + case .newsDigest: + return ("News list", "newspaper") + case .customCapability: + return ("Custom capability", "wand.and.stars") + } + } + + private var examplesSection: some View { + VStack(alignment: .leading, spacing: 8) { HStack { - Button("Done") { controller.dismissSuccess() } - .buttonStyle(ModalSecondaryButtonStyle()) + Text("Examples") + .font(.caption) + .foregroundStyle(.secondary) Spacer() - Button("Open news list") { - onOpenNewsReader(readerID) + Button("Add example") { controller.addExample() } + .font(.caption) + } + ForEach(controller.skillDraft.examples) { example in + VStack(alignment: .leading, spacing: 6) { + TextField("You say…", text: exampleBinding(example.id, field: .userSays)) + .textFieldStyle(.roundedBorder) + TextField("Plugin does…", text: exampleBinding(example.id, field: .pluginDoes)) + .textFieldStyle(.roundedBorder) + if controller.skillDraft.examples.count > 1 { + Button("Remove") { controller.removeExample(id: example.id) } + .font(.caption) + } + } + .padding(10) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + + private enum ExampleField { case userSays, pluginDoes } + + private func exampleBinding(_ id: String, field: ExampleField) -> Binding { + Binding( + get: { + guard let index = controller.skillDraft.examples.firstIndex(where: { $0.id == id }) else { + return "" + } + switch field { + case .userSays: return controller.skillDraft.examples[index].userSays + case .pluginDoes: return controller.skillDraft.examples[index].pluginDoes + } + }, + set: { newValue in + switch field { + case .userSays: controller.updateExample(id: id, userSays: newValue) + case .pluginDoes: controller.updateExample(id: id, pluginDoes: newValue) + } + } + ) + } + + private var newsFieldsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Topics") + .font(.caption) + .foregroundStyle(.secondary) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 8)], spacing: 8) { + ForEach(NewsPresetTopic.allCases) { topic in + let on = controller.skillDraft.newsTopics.contains(topic.displayName) + Button { + if on { + controller.removeNewsTopic(topic.displayName) + } else if !controller.skillDraft.newsTopics.contains(topic.displayName) { + controller.addNewsTopicFromPreset(topic.displayName) + } + } label: { + Text(topic.displayName) + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + HStack { + TextField("Add a custom topic", text: $newsTopicDraft) + .textFieldStyle(.roundedBorder) + .onSubmit { addNewsTopicFromDraft() } + Button("Add") { addNewsTopicFromDraft() } + } + Text("Sources") + .font(.caption) + .foregroundStyle(.secondary) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 8)], spacing: 8) { + ForEach(NewsPresetSource.allCases) { source in + let url = source.source.url + let on = controller.skillDraft.newsSourceURLs.contains(url) + Button { + if on { + controller.removeNewsURL(url) + } else { + controller.addNewsURL(url) + } + } label: { + Text(source.source.label) + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + HStack { + TextField("https://…", text: $newsURLDraft) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("news-url-field") + .onSubmit { addNewsURLFromDraft() } + Button("Add URL") { addNewsURLFromDraft() } + } + ForEach(controller.skillDraft.newsSourceURLs, id: \.self) { url in + HStack { + Text(url) + .font(.caption) + .lineLimit(1) + Spacer() + Button("Remove") { controller.removeNewsURL(url) } + .font(.caption) + } + } + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(NewsPaywall.userWarning) + .fixedSize(horizontal: false, vertical: true) + } + .font(.caption) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } + } + + private func addNewsTopicFromDraft() { + let topic = newsTopicDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !topic.isEmpty else { return } + controller.addNewsTopicFromPreset(topic) + newsTopicDraft = "" + } + + private func triggerChip(_ trigger: PluginSkillDraft.Trigger) -> some View { + let available = controller.skillDraft.isTriggerAvailable(trigger) + let on = available && controller.skillDraft.triggers.contains(trigger) + return Button { + controller.toggleTrigger(trigger) + } label: { + Text(trigger.label) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(chipBackground(on: on, available: available)) + .foregroundStyle(available ? .primary : .tertiary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!available) + .help(triggerHelp(trigger, available: available)) + } + + private func chipBackground(on: Bool, available: Bool) -> Color { + guard available else { return Color.primary.opacity(0.03) } + return on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05) + } + + private func triggerHelp( + _ trigger: PluginSkillDraft.Trigger, + available: Bool + ) -> String { + guard !available else { return "" } + switch controller.skillDraft.plannedKind { + case .newsDigest: + switch trigger { + case .mention: + return "News lists live in the sidebar, not as /slash commands." + case .messaging: + return "News lists are not messaging connectors." + default: + return "Not available for news lists." + } + case .messagingConnector: + switch trigger { + case .schedule: + return "Connectors respond in Messaging, not on a timer." + default: + return "Not available for messaging connectors." + } + case .customCapability: + switch trigger { + case .messaging: + return "Only messaging connectors use the Messaging tab." + default: + return "Not available for this plugin type." + } + } + } + + private var nameFieldSection: some View { + let isNews = controller.skillDraft.plannedKind == .newsDigest + return VStack(alignment: .leading, spacing: 6) { + Text(isNews ? "List name" : "Plugin name") + .font(.caption) + .foregroundStyle(.secondary) + TextField(isNews ? "List name" : "Plugin name", text: controller.skillDraftBinding(\.pluginName)) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("connector-plugin-name") + if isNews { + Text("This name appears in the sidebar. News lists are not slash commands.") + .font(.caption2) + .foregroundStyle(.tertiary) + } else if controller.canConfirmPluginName { + Text("Invoke this plugin in chat as /\(normalizedPluginID()).") + .font(.caption2) + .foregroundStyle(.tertiary) + } else { + Text("Use letters, numbers, and hyphens (for example tech-news).") + .font(.caption2) + .foregroundStyle(.orange) + } + } + } + + private func normalizedPluginID() -> String { + let trimmed = controller.skillDraft.pluginName.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalized = try? PluginID.normalized(trimmed) { + return normalized.rawValue + } + return trimmed.isEmpty ? "plugin" : trimmed + } + + private func addNewsURLFromDraft() { + controller.addNewsURL(newsURLDraft) + newsURLDraft = "" + } + + private var previewForm: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + plannedKindBadge + + nameFieldSection + + if controller.skillDraft.plannedKind == .newsDigest { + Text( + "Display mode: \(PluginSkillDraftPlanner.inferNewsMode(from: controller.skillDraft).displayName)" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Scenarios") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(controller.skillDraft.previewScenarios(), id: \.self) { scenario in + Text(scenario) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Package") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(controller.skillDraft.packageOutline(), id: \.self) { line in + Label(line, systemImage: "doc") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("SKILL.md") + .font(.caption) + .foregroundStyle(.secondary) + Text(controller.skillDraft.skillMarkdown()) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) + .textSelection(.enabled) + } + } + } + } + + @ViewBuilder + private func failureBody(message: String, technicalDetail: String?) -> some View { + VStack(alignment: .leading, spacing: 10) { + if case .failed(let step, _, _) = controller.phase, step == .news { + Label( + message.localizedCaseInsensitiveContains("paywall") + ? "Blocked because of a paywall" + : "News list was not created", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(message.localizedCaseInsensitiveContains("paywall") ? Color.orange : Color.secondary) + } else { + Label("Nothing was installed", systemImage: "minus.circle") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + Text("Your sidebar and Messaging are unchanged.") + .font(.caption) + .foregroundStyle(.secondary) + } + Text(message) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + if let technicalDetail, !technicalDetail.isEmpty { + DisclosureGroup("Technical details") { + Text(technicalDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) } - .buttonStyle(ModalPrimaryButtonStyle()) - .keyboardShortcut(.defaultAction) + } + } + } + + @ViewBuilder + private func successBody(pluginID: String, outcome: PluginCreationController.SuccessOutcome) -> some View { + switch outcome { + case .newsList: + Text("Your news list is ready. Every article includes a source link.") + .font(.body) + .fixedSize(horizontal: false, vertical: true) + case .plugin: + if controller.skillDraft.plannedKind == .messagingConnector { + Text("Your connector /\(pluginID) is ready. Open it to start talking in Messaging.") + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("Your plugin /\(pluginID) is ready.") + .font(.body) + .fixedSize(horizontal: false, vertical: true) } } } @@ -443,169 +755,47 @@ struct PluginsWorkspaceView: View { set: { controller.credentialDrafts[id] = $0 } ) } +} - private func typeButton( - title: String, - subtitle: String, - type: PluginFactoryCreateInput.PluginType, - enabled: Bool - ) -> some View { - Button { - guard enabled else { return } - controller.selectType(type) - } label: { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.body.weight(.semibold)) - .foregroundStyle(enabled ? .primary : .secondary) - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if !enabled { - Text("Soon") - .font(.caption2) - .foregroundStyle(.secondary) - } else if controller.selectedType == type { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Color.accentColor) - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - controller.selectedType == type && enabled - ? Color.accentColor.opacity(0.12) - : Color.primary.opacity(enabled ? 0.05 : 0.03) +/// Simple horizontal flow for trigger chips when `Layout` is unavailable. +private struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = arrange(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrange(proposal: proposal, subviews: subviews) + for (index, frame) in result.frames.enumerated() { + subviews[index].place( + at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY), + proposal: ProposedViewSize(frame.size) ) - .clipShape(RoundedRectangle(cornerRadius: 10)) } - .buttonStyle(.plain) - .disabled(!enabled) } - private var newsReaderForm: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - TextField("Name this list", text: $controller.newsName) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("news-list-name") - Text("Topics") - .font(.caption) - .foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 8)], spacing: 8) { - ForEach(NewsPresetTopic.allCases) { topic in - let on = controller.selectedNewsTopics.contains(topic) - Button { - if on { - controller.selectedNewsTopics.remove(topic) - } else { - controller.selectedNewsTopics.insert(topic) - } - } label: { - Text(topic.displayName) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - HStack { - TextField("Add a custom topic", text: $controller.newsTopicDraft) - .textFieldStyle(.roundedBorder) - .onSubmit { controller.addNewsTopic() } - Button("Add") { controller.addNewsTopic() } - } - if !controller.extraNewsTopics.isEmpty { - Text(controller.extraNewsTopics.joined(separator: ", ")) - .font(.caption) - .foregroundStyle(.secondary) - } - Text("Sources") - .font(.caption) - .foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 8)], spacing: 8) { - ForEach(NewsPresetSource.allCases) { source in - let on = controller.selectedNewsSources.contains(source) - Button { - if on { - controller.selectedNewsSources.remove(source) - } else { - controller.selectedNewsSources.insert(source) - } - } label: { - Text(source.source.label) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - HStack { - TextField("https://…", text: $controller.newsURLDraft) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("news-url-field") - .onSubmit { controller.addNewsURL() } - Button("Add URL") { controller.addNewsURL() } - } - ForEach(controller.extraNewsURLs, id: \.self) { url in - VStack(alignment: .leading, spacing: 2) { - HStack { - Text(url) - .font(.caption) - .lineLimit(1) - Spacer() - Button("Remove") { controller.removeNewsURL(url) } - .font(.caption) - } - if let reason = newsURLPaywallReason(url) { - Text(reason) - .font(.caption2) - .foregroundStyle(.orange) - } - } - } - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text(NewsPaywall.userWarning) - .fixedSize(horizontal: false, vertical: true) - } - .font(.caption) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("news-paywall-warning") - Picker("Mode", selection: $controller.newsMode) { - ForEach(NewsReaderMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - Stepper("Up to \(controller.newsMaxCount) items", value: $controller.newsMaxCount, in: 5...50, step: 5) - Picker("Schedule", selection: $controller.newsSchedule) { - ForEach(NewsReaderSchedule.allCases, id: \.self) { schedule in - Text(schedule.displayName).tag(schedule) - } - } + private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var frames: [CGRect] = [] + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth, x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 } + frames.append(CGRect(x: x, y: y, width: size.width, height: size.height)) + rowHeight = max(rowHeight, size.height) + x += size.width + spacing } - } - private func newsURLPaywallReason(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed.contains("://") ? trimmed : "https://\(trimmed)" - guard let url = URL(string: normalized) else { return nil } - return NewsPaywall.preflightRejection(url: url) + return (CGSize(width: maxWidth, height: y + rowHeight), frames) } } diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 1380c2d2..9254ea3d 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -846,7 +846,10 @@ struct ContentView: View { await ContainerLifecycleSettingsService.shared.configure(repository: repository) await OrchestrationLimitsSettingsService.shared.configure(repository: repository) await PluginFactoryListStore.shared.configure(repository: repository) - await NewsReaderStore.shared.configure(repository: repository) + await NewsReaderStore.shared.configure( + repository: repository, + summarizerSettings: helperModelSettings + ) await AgentProfileStore.shared.configure(repository: repository) pluginCreationController.configure(repository: repository) } diff --git a/ui/uiTests/NewsReaderFlowTests.swift b/ui/uiTests/NewsReaderFlowTests.swift new file mode 100644 index 00000000..6ee53646 --- /dev/null +++ b/ui/uiTests/NewsReaderFlowTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing +import Structure + +@Suite struct NewsReaderFlowTests { + @Test func skillDraftForTechNewsSummaryRequest() { + var draft = PluginSkillDraft() + PluginSkillDraftPlanner.applyGoal( + "Give me summaries of today's tech news", + to: &draft, + existingPluginIDs: [] + ) + draft.pluginName = "tech-news" + draft.newsSourceURLs = [ + NewsPresetSource.googleNews.source.url, + NewsPresetSource.wsj.source.url, + ] + draft.newsTopics = ["Tech"] + draft.examples = [ + PluginSkillDraft.Example( + userSays: "Give me summaries of tech news", + pluginDoes: "fetch headlines and summarize them with source links" + ), + ] + + #expect(draft.plannedKind == .newsDigest) + #expect(PluginSkillDraftPlanner.inferNewsMode(from: draft) == .summary) + #expect(draft.pluginName == "tech-news") + #expect(draft.newsSourceURLs.contains(NewsPresetSource.googleNews.source.url)) + #expect(draft.newsSourceURLs.contains(NewsPresetSource.wsj.source.url)) + } + + @Test func newsReaderWorkerRequestMatchesWizardSpec() throws { + let sources = [ + NewsPresetSource.googleNews.source, + NewsPresetSource.wsj.source, + ] + let spec = NewsReaderSpec( + name: "tech-news", + topics: ["Tech"], + sources: sources, + mode: .summary, + maxCount: 20, + schedule: .off + ) + let request = NewsReaderWorkerRequest( + mode: spec.mode, + sources: spec.sources, + topics: spec.topics, + maxCount: spec.maxCount, + contextHint: spec.name + ) + let json = try request.encodedJSON() + let object = try JSONSerialization.jsonObject(with: json) as? [String: Any] + #expect(object?["mode"] as? String == "summary") + #expect((object?["sources"] as? [[String: Any]])?.count == 2) + } +} diff --git a/workers/go/cmd/derrick-news-reader/main.go b/workers/go/cmd/derrick-news-reader/main.go new file mode 100644 index 00000000..a078c1da --- /dev/null +++ b/workers/go/cmd/derrick-news-reader/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "strconv" + + "github.com/jsoneaday/derrick/workers/internal/contract" + "github.com/jsoneaday/derrick/workers/internal/newsreader" +) + +func main() { + input, err := io.ReadAll(os.Stdin) + if err != nil { + writeResult(blockedResult(newsreader.ModeRSS, "News reader input must be a valid JSON object.")) + return + } + + var req newsreader.Request + if err := json.Unmarshal(input, &req); err != nil { + writeResult(blockedResult(newsreader.ModeRSS, "News reader input must be a valid JSON object.")) + return + } + + validated, err := newsreader.Validate(req) + if err != nil { + writeResult(blockedResult(req.Mode, err.Error())) + return + } + + result := newsreader.Run(context.Background(), validated, readProxy()) + writeResult(result) +} + +func readProxy() *newsreader.ProxyConfig { + host := os.Getenv("DERRICK_EGRESS_PROXY_HOST") + portStr := os.Getenv("DERRICK_EGRESS_PROXY_PORT") + token := os.Getenv("DERRICK_EGRESS_PROXY_TOKEN") + if host == "" && portStr == "" && token == "" { + return nil + } + port, _ := strconv.Atoi(portStr) + return &newsreader.ProxyConfig{Host: host, Port: port, Token: token} +} + +func blockedResult(mode newsreader.Mode, message string) newsreader.Result { + if mode == "" { + mode = newsreader.ModeRSS + } + return newsreader.Result{ + OK: false, + Mode: mode, + Articles: []newsreader.Article{}, + Diagnostics: []string{message}, + } +} + +func writeResult(result newsreader.Result) { + payload, err := encodedResult(result) + if err != nil { + writeEncodedResult(blockedResult(result.Mode, "News reader output violated worker contract.")) + return + } + _, _ = os.Stdout.Write(payload) +} + +func encodedResult(result newsreader.Result) ([]byte, error) { + if result.Articles == nil { + result.Articles = []newsreader.Article{} + } + if result.Diagnostics == nil { + result.Diagnostics = []string{} + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(result); err != nil { + return nil, err + } + data := bytes.TrimSpace(buf.Bytes()) + if err := contract.ValidateNewsReaderResultJSON(data); err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func writeEncodedResult(result newsreader.Result) { + payload, err := encodedResult(result) + if err != nil { + fallback := blockedResult(newsreader.ModeRSS, "News reader failed to produce schema-compliant output.") + payload, _ = encodedResult(fallback) + } + _, _ = os.Stdout.Write(payload) +} diff --git a/workers/go/internal/contract/contract.go b/workers/go/internal/contract/contract.go index ba341c0b..e36ecc36 100644 --- a/workers/go/internal/contract/contract.go +++ b/workers/go/internal/contract/contract.go @@ -34,6 +34,11 @@ func ValidateFileExtractorResultJSON(data []byte) error { return validate("file-extractor-result.schema.json", data) } +// ValidateNewsReaderResultJSON checks news reader stdout against news-reader-result.schema.json. +func ValidateNewsReaderResultJSON(data []byte) error { + return validate("news-reader-result.schema.json", data) +} + func validate(schemaName string, data []byte) error { compiler := jsonschema.NewCompiler() if err := loadSchemas(compiler); err != nil { diff --git a/workers/go/internal/contract/schemas/news-reader-result.schema.json b/workers/go/internal/contract/schemas/news-reader-result.schema.json new file mode 100644 index 00000000..6632851d --- /dev/null +++ b/workers/go/internal/contract/schemas/news-reader-result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://derrick.local/schemas/news-reader-result.json", + "title": "News reader worker stdout", + "description": "JSON object written to stdout by derrick-news-reader.", + "$ref": "worker-product.schema.json#/$defs/news_reader_result" +} diff --git a/workers/go/internal/contract/schemas/worker-product.schema.json b/workers/go/internal/contract/schemas/worker-product.schema.json index 874f6bfb..611ef5fe 100644 --- a/workers/go/internal/contract/schemas/worker-product.schema.json +++ b/workers/go/internal/contract/schemas/worker-product.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://derrick.local/schemas/worker-product.json", "title": "Derrick trusted worker product contracts", - "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler and file extractor). Swift host and Go workers must match these schemas.", + "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler, file extractor, and news reader). Swift host and Go workers must match these schemas.", "$defs": { "string_list": { "type": "array", @@ -92,6 +92,35 @@ }, "diagnostics": { "$ref": "#/$defs/string_list" } } + }, + "news_reader_mode": { + "type": "string", + "enum": ["rss", "list", "summary"] + }, + "news_reader_article": { + "type": "object", + "required": ["title", "url"], + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "url": { "type": "string" }, + "detail": { "type": "string" }, + "published_at": { "type": "string" } + } + }, + "news_reader_result": { + "type": "object", + "required": ["ok", "mode", "articles", "diagnostics"], + "additionalProperties": false, + "properties": { + "ok": { "type": "boolean" }, + "mode": { "$ref": "#/$defs/news_reader_mode" }, + "articles": { + "type": "array", + "items": { "$ref": "#/$defs/news_reader_article" } + }, + "diagnostics": { "$ref": "#/$defs/string_list" } + } } } } diff --git a/workers/go/internal/newsreader/engine.go b/workers/go/internal/newsreader/engine.go new file mode 100644 index 00000000..74e60814 --- /dev/null +++ b/workers/go/internal/newsreader/engine.go @@ -0,0 +1,34 @@ +package newsreader + +import ( + "context" +) + +func Run(ctx context.Context, req Request, proxy *ProxyConfig) Result { + client := newHTTPClient(proxy) + collected := make([]Article, 0, req.MaxCount) + diagnostics := make([]string, 0) + + for _, source := range req.Sources { + articles, notes := fetchSource(ctx, client, source, req.Mode, req.ContextHint) + diagnostics = append(diagnostics, notes...) + collected = append(collected, articles...) + } + + filtered := filterTopics(collected, req.Topics) + unique := uniqueArticles(filtered) + if len(unique) > req.MaxCount { + unique = unique[:req.MaxCount] + } + + ok := len(unique) > 0 + if !ok && len(diagnostics) == 0 { + diagnostics = append(diagnostics, "No articles were found for the configured sources.") + } + return Result{ + OK: ok, + Mode: req.Mode, + Articles: unique, + Diagnostics: diagnostics, + } +} diff --git a/workers/go/internal/newsreader/fetch.go b/workers/go/internal/newsreader/fetch.go new file mode 100644 index 00000000..904a82f1 --- /dev/null +++ b/workers/go/internal/newsreader/fetch.go @@ -0,0 +1,110 @@ +package newsreader + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +func fetchSource( + ctx context.Context, + client *http.Client, + source Source, + mode Mode, + contextHint string, +) ([]Article, []string) { + fetchURL := CanonicalFetchURL(source.URL, contextHint) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return nil, []string{fmt.Sprintf("%s: %v", source.Label, err)} + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DerrickNewsReader/1.0)") + req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, text/html;q=0.8") + + resp, err := client.Do(req) + if err != nil { + return nil, []string{fmt.Sprintf("Could not read %s: %v", source.URL, err)} + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return nil, []string{fmt.Sprintf("Could not read %s: HTTP %d", source.URL, resp.StatusCode)} + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return nil, []string{fmt.Sprintf("Could not read %s: %v", source.URL, err)} + } + text := string(body) + if looksLikeFeed(text) { + articles := parseFeed(text, source.Label) + if len(articles) > 0 { + return articles, nil + } + } + if mode == ModeRSS { + articles := parseFeed(text, source.Label) + if len(articles) > 0 { + return articles, nil + } + return nil, []string{fmt.Sprintf("No articles were found in %s", source.URL)} + } + if mode == ModeList || mode == ModeSummary { + articles := parseHTMLArticles(text, fetchURL, source.Label) + if len(articles) > 0 { + return articles, nil + } + return nil, []string{fmt.Sprintf("No articles were found on %s", source.URL)} + } + return nil, []string{fmt.Sprintf("No articles were found in %s", source.URL)} +} + +func newHTTPClient(proxy *ProxyConfig) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + return &http.Client{ + Timeout: time.Duration(DefaultTimeoutSeconds) * time.Second, + Transport: transport, + } +} + +func filterTopics(articles []Article, topics []string) []Article { + needles := make([]string, 0, len(topics)) + for _, topic := range topics { + trimmed := strings.ToLower(strings.TrimSpace(topic)) + if trimmed != "" { + needles = append(needles, trimmed) + } + } + if len(needles) == 0 { + return articles + } + matched := make([]Article, 0, len(articles)) + for _, article := range articles { + hay := strings.ToLower(article.Title + " " + article.Detail) + for _, needle := range needles { + if strings.Contains(hay, needle) { + matched = append(matched, article) + break + } + } + } + if len(matched) == 0 { + return articles + } + return matched +} + +func uniqueArticles(articles []Article) []Article { + seen := map[string]bool{} + out := make([]Article, 0, len(articles)) + for _, article := range articles { + if seen[article.URL] { + continue + } + seen[article.URL] = true + out = append(out, article) + } + return out +} diff --git a/workers/go/internal/newsreader/list.go b/workers/go/internal/newsreader/list.go new file mode 100644 index 00000000..9d3c3735 --- /dev/null +++ b/workers/go/internal/newsreader/list.go @@ -0,0 +1,88 @@ +package newsreader + +import ( + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +func parseHTMLArticles(body string, pageURL string, sourceLabel string) []Article { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) + if err != nil { + return nil + } + articles := make([]Article, 0, 32) + seen := map[string]bool{} + + add := func(title string, href string, detail string) { + title = SanitizeDetail(title, 240) + href = strings.TrimSpace(href) + if title == "" || href == "" || seen[href] { + return + } + seen[href] = true + articles = append(articles, Article{ + Title: title, + URL: href, + Detail: SanitizeDetail(detail, 280), + }) + } + + doc.Find("article a[href]").Each(func(_ int, sel *goquery.Selection) { + href := resolveHref(pageURL, sel.AttrOr("href", "")) + if href == "" { + return + } + title := strings.TrimSpace(sel.Text()) + if title == "" { + title = strings.TrimSpace(sel.Closest("article").Find("h1,h2,h3").First().Text()) + } + add(title, href, sel.Closest("article").Text()) + }) + + doc.Find("h1 a[href], h2 a[href], h3 a[href]").Each(func(_ int, sel *goquery.Selection) { + href := resolveHref(pageURL, sel.AttrOr("href", "")) + title := strings.TrimSpace(sel.Text()) + add(title, href, sel.Parent().Text()) + }) + + if len(articles) == 0 { + doc.Find("a[href]").Each(func(_ int, sel *goquery.Selection) { + href := resolveHref(pageURL, sel.AttrOr("href", "")) + title := strings.TrimSpace(sel.Text()) + if len(title) < 12 || len(title) > 200 { + return + } + add(title, href, "") + }) + } + + if len(articles) == 0 { + title := strings.TrimSpace(doc.Find("title").First().Text()) + if title != "" { + add(title, pageURL, doc.Find("meta[name=description]").AttrOr("content", "")) + } + } + return articles +} + +func resolveHref(pageURL string, href string) string { + href = strings.TrimSpace(href) + if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") { + return "" + } + base, err := url.Parse(pageURL) + if err != nil { + return href + } + ref, err := url.Parse(href) + if err != nil { + return "" + } + resolved := base.ResolveReference(ref) + if resolved.Scheme != "http" && resolved.Scheme != "https" { + return "" + } + return resolved.String() +} diff --git a/workers/go/internal/newsreader/newsreader_test.go b/workers/go/internal/newsreader/newsreader_test.go new file mode 100644 index 00000000..1e5e0247 --- /dev/null +++ b/workers/go/internal/newsreader/newsreader_test.go @@ -0,0 +1,30 @@ +package newsreader + +import "testing" + +func TestValidateRequiresSources(t *testing.T) { + _, err := Validate(Request{Mode: ModeRSS, Sources: []Source{}}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestParseFeedReadsRSSItem(t *testing.T) { + rss := ` + Hellohttps://example.com/aBody + ` + articles := parseFeed(rss, "Example") + if len(articles) != 1 { + t.Fatalf("expected 1 article, got %d", len(articles)) + } + if articles[0].Title != "Hello" { + t.Fatalf("unexpected title %q", articles[0].Title) + } +} + +func TestCanonicalFetchURLRewritesGoogleNewsHomepage(t *testing.T) { + got := CanonicalFetchURL("https://news.google.com/", "tech news") + if got == "https://news.google.com/" { + t.Fatalf("expected RSS rewrite, got %q", got) + } +} diff --git a/workers/go/internal/newsreader/rss.go b/workers/go/internal/newsreader/rss.go new file mode 100644 index 00000000..3d8372e0 --- /dev/null +++ b/workers/go/internal/newsreader/rss.go @@ -0,0 +1,207 @@ +package newsreader + +import ( + "encoding/xml" + "regexp" + "strings" +) + +type rssFeed struct { + Channel rssChannel `xml:"channel"` +} + +type rssChannel struct { + Items []rssItem `xml:"item"` +} + +type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` +} + +type atomFeed struct { + Entries []atomEntry `xml:"entry"` +} + +type atomEntry struct { + Title string `xml:"title"` + Link atomLink `xml:"link"` + Summary string `xml:"summary"` + Updated string `xml:"updated"` + Content atomContent `xml:"content"` +} + +type atomLink struct { + Href string `xml:"href,attr"` +} + +type atomContent struct { + Value string `xml:",chardata"` +} + +func looksLikeFeed(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "") + blocks = append(blocks, splitBlocks(body, "")...) + articles := make([]Article, 0, len(blocks)) + for _, block := range blocks { + title := firstTag(block, "title") + link := firstTag(block, "link") + if link == "" { + link = firstAttr(block, "link", "href") + } + if link == "" { + link = firstTag(block, "guid") + } + description := firstTag(block, "description") + if description == "" { + description = firstTag(block, "summary") + } + if description == "" { + description = firstTag(block, "content") + } + cleanTitle := SanitizeDetail(title, 240) + cleanLink := strings.TrimSpace(StripTags(link)) + if cleanTitle == "" || cleanLink == "" { + continue + } + resolved := PreferredArticleURL(cleanLink, description) + detail := SanitizeDetail(description, 280) + articles = append(articles, Article{ + Title: cleanTitle, + URL: resolved, + Detail: detail, + PublishedAt: strings.TrimSpace(firstTag(block, "pubDate")), + }) + } + return articles +} + +func splitBlocks(body string, startToken string, endToken string) []string { + lower := strings.ToLower(body) + startLower := strings.ToLower(startToken) + endLower := strings.ToLower(endToken) + var blocks []string + idx := 0 + for { + start := strings.Index(lower[idx:], startLower) + if start < 0 { + break + } + start += idx + end := strings.Index(lower[start:], endLower) + if end < 0 { + break + } + end += start + len(endToken) + blocks = append(blocks, body[start:end]) + idx = end + } + return blocks +} + +func firstTag(block string, name string) string { + open := "<" + strings.ToLower(name) + lower := strings.ToLower(block) + start := strings.Index(lower, open) + if start < 0 { + return "" + } + closeTag := strings.Index(block[start:], ">") + if closeTag < 0 { + return "" + } + contentStart := start + closeTag + 1 + endToken := "" + end := strings.Index(strings.ToLower(block[contentStart:]), endToken) + if end < 0 { + return "" + } + return block[contentStart : contentStart+end] +} + +func firstAttr(block string, tagName string, attr string) string { + open := "<" + strings.ToLower(tagName) + lower := strings.ToLower(block) + start := strings.Index(lower, open) + if start < 0 { + return "" + } + closeTag := strings.Index(block[start:], ">") + if closeTag < 0 { + return "" + } + tag := block[start : start+closeTag+1] + pattern := regexp.MustCompile(`(?i)` + attr + `\s*=\s*"([^"]+)"`) + match := pattern.FindStringSubmatch(tag) + if len(match) < 2 { + return "" + } + return match[1] +} diff --git a/workers/go/internal/newsreader/text.go b/workers/go/internal/newsreader/text.go new file mode 100644 index 00000000..9f8e952c --- /dev/null +++ b/workers/go/internal/newsreader/text.go @@ -0,0 +1,76 @@ +package newsreader + +import ( + "net/url" + "regexp" + "strings" +) + +var tagPattern = regexp.MustCompile(`<[^>]+>`) +var hrefPattern = regexp.MustCompile(`(?i)href\s*=\s*"([^"]+)"`) +var whitespacePattern = regexp.MustCompile(`\s+`) + +func StripTags(raw string) string { + value := tagPattern.ReplaceAllString(raw, " ") + value = strings.ReplaceAll(value, "", "") + return DecodeEntities(value) +} + +func DecodeEntities(raw string) string { + replacements := []struct { + from string + to string + }{ + {"&", "&"}, + {"<", "<"}, + {">", ">"}, + {""", "\""}, + {"'", "'"}, + {"'", "'"}, + {" ", " "}, + } + value := raw + for _, pair := range replacements { + value = strings.ReplaceAll(value, pair.from, pair.to) + } + return value +} + +func CollapseWhitespace(raw string) string { + return strings.TrimSpace(whitespacePattern.ReplaceAllString(raw, " ")) +} + +func SanitizeDetail(raw string, maxLen int) string { + cleaned := CollapseWhitespace(StripTags(raw)) + if cleaned == "" { + return "" + } + if len(cleaned) <= maxLen { + return cleaned + } + return strings.TrimSpace(cleaned[:maxLen]) + "…" +} + +func PreferredArticleURL(link string, htmlSnippet string) string { + link = strings.TrimSpace(link) + for _, match := range hrefPattern.FindAllStringSubmatch(htmlSnippet, -1) { + if len(match) < 2 { + continue + } + href := strings.TrimSpace(match[1]) + if !strings.HasPrefix(href, "http") { + continue + } + parsed, err := url.Parse(href) + if err != nil { + continue + } + host := strings.ToLower(parsed.Hostname()) + if strings.Contains(host, "google.com") || strings.Contains(host, "googleusercontent.com") { + continue + } + return href + } + return link +} diff --git a/workers/go/internal/newsreader/types.go b/workers/go/internal/newsreader/types.go new file mode 100644 index 00000000..3738664b --- /dev/null +++ b/workers/go/internal/newsreader/types.go @@ -0,0 +1,49 @@ +package newsreader + +const ( + DefaultMaxCount = 20 + MaximumMaxCount = 50 + DefaultTimeoutSeconds = 120 + MaximumTimeoutSeconds = 300 +) + +type Mode string + +const ( + ModeRSS Mode = "rss" + ModeList Mode = "list" + ModeSummary Mode = "summary" +) + +type Source struct { + Label string `json:"label"` + URL string `json:"url"` +} + +type Request struct { + Mode Mode `json:"mode"` + Sources []Source `json:"sources"` + Topics []string `json:"topics"` + MaxCount int `json:"maxCount"` + ContextHint string `json:"contextHint,omitempty"` +} + +type Article struct { + Title string `json:"title"` + URL string `json:"url"` + Detail string `json:"detail,omitempty"` + PublishedAt string `json:"published_at,omitempty"` +} + +type Result struct { + OK bool `json:"ok"` + Mode Mode `json:"mode"` + Articles []Article `json:"articles"` + Diagnostics []string `json:"diagnostics"` +} + +type ProxyConfig struct { + Host string + Port int + Token string +} diff --git a/workers/go/internal/newsreader/urls.go b/workers/go/internal/newsreader/urls.go new file mode 100644 index 00000000..871c1b65 --- /dev/null +++ b/workers/go/internal/newsreader/urls.go @@ -0,0 +1,57 @@ +package newsreader + +import ( + "net/url" + "strings" +) + +func CanonicalFetchURL(raw string, contextHint string) string { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + host := strings.ToLower(parsed.Hostname()) + if !strings.Contains(host, "news.google.com") { + return raw + } + path := strings.ToLower(parsed.Path) + if strings.Contains(path, "/rss") || strings.HasSuffix(path, ".xml") { + return raw + } + if strings.Contains(path, "/topics/") { + if section := googleNewsSection(contextHint); section != "" { + return "https://news.google.com/rss/headlines/section/topic/" + section + "?hl=en-US&gl=US&ceid=US:en" + } + return "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" + } + if parsed.RawQuery == "" { + return "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" + } + return raw +} + +func googleNewsSection(hint string) string { + lower := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(hint, "-", " "), "_", " ")) + if strings.Contains(lower, "tech") { + return "TECHNOLOGY" + } + if strings.Contains(lower, "business") || strings.Contains(lower, "finance") || strings.Contains(lower, "market") { + return "BUSINESS" + } + if strings.Contains(lower, "science") { + return "SCIENCE" + } + if strings.Contains(lower, "sport") { + return "SPORTS" + } + if strings.Contains(lower, "health") { + return "HEALTH" + } + if strings.Contains(lower, "entertainment") { + return "ENTERTAINMENT" + } + if strings.Contains(lower, "world") { + return "WORLD" + } + return "" +} diff --git a/workers/go/internal/newsreader/validate.go b/workers/go/internal/newsreader/validate.go new file mode 100644 index 00000000..a9ec8196 --- /dev/null +++ b/workers/go/internal/newsreader/validate.go @@ -0,0 +1,38 @@ +package newsreader + +import ( + "fmt" + "net/url" + "strings" +) + +func Validate(req Request) (Request, error) { + out := req + if out.Mode != ModeRSS && out.Mode != ModeList && out.Mode != ModeSummary { + return out, fmt.Errorf("mode must be rss, list, or summary") + } + if len(out.Sources) == 0 { + return out, fmt.Errorf("add at least one source URL") + } + if out.MaxCount <= 0 { + out.MaxCount = DefaultMaxCount + } + if out.MaxCount > MaximumMaxCount { + out.MaxCount = MaximumMaxCount + } + for i, source := range out.Sources { + trimmed := strings.TrimSpace(source.URL) + if trimmed == "" { + return out, fmt.Errorf("source %d is missing a URL", i+1) + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme != "http" && parsed.Scheme != "https" { + return out, fmt.Errorf("source %d is not a usable web address", i+1) + } + out.Sources[i].URL = trimmed + if strings.TrimSpace(out.Sources[i].Label) == "" { + out.Sources[i].Label = parsed.Host + } + } + return out, nil +} From fae578116196be8f40ff8cca18e8d31866c3a805 Mon Sep 17 00:00:00 2001 From: David Choi Date: Thu, 10 Sep 2026 23:56:05 -0400 Subject: [PATCH 07/17] fix news view --- .../AppLayerServices/News/NewsSourceURL.swift | 4 +++ .../StructureTests/NewsReaderTests.swift | 6 ++++ ui/ui/News/NewsReaderSummarizer.swift | 4 ++- ui/ui/News/NewsWorkspaceView.swift | 17 +++++---- ui/ui/Views/MarkdownView.swift | 36 +++++++++++++++++++ workers/go/internal/newsreader/fetch.go | 27 +++++++++++--- .../go/internal/newsreader/newsreader_test.go | 26 ++++++++++++++ workers/go/internal/newsreader/urls.go | 5 +++ 8 files changed, 112 insertions(+), 13 deletions(-) diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift b/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift index 7317b3e8..454b7d6b 100644 --- a/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift +++ b/packages/Structure/Sources/AppLayerServices/News/NewsSourceURL.swift @@ -9,6 +9,10 @@ public enum NewsSourceURL { guard isGoogleNewsHost(host) else { return url } let path = url.path.lowercased() if path.contains("/rss") || path.hasSuffix(".xml") { + if !path.contains("/headlines/section/topic/"), + let section = googleNewsSection(from: contextHint) { + return googleNewsSectionRSS(section: section) + } return url } if path.contains("/topics/") { diff --git a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift index 1af33245..7077ce03 100644 --- a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift +++ b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift @@ -51,6 +51,12 @@ import Structure } } + @Test func canonicalFetchURLUpgradesGeneralGoogleNewsRSSForTechHint() { + let url = URL(string: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en")! + let canonical = NewsSourceURL.canonicalFetchURL(url, contextHint: "tech-news Tech") + #expect(canonical.path.contains("/headlines/section/topic/TECHNOLOGY")) + } + @Test func workerRequestEncodesModeAndSources() throws { let request = NewsReaderWorkerRequest( mode: .rss, diff --git a/ui/ui/News/NewsReaderSummarizer.swift b/ui/ui/News/NewsReaderSummarizer.swift index 74b441c0..e0b04047 100644 --- a/ui/ui/News/NewsReaderSummarizer.swift +++ b/ui/ui/News/NewsReaderSummarizer.swift @@ -16,7 +16,9 @@ struct NewsReaderSummarizer: NewsSummaryGenerating { prompt, system: """ You summarize news for the user. Write clear prose in plain English. - Include markdown links to the original articles using the URLs provided. + Use bullet points. Include markdown links to the original articles using the URLs provided. + Do not add a title or markdown heading for the list name. + Cover every article provided when possible. Do not invent stories or URLs. """, temperature: 0.2 diff --git a/ui/ui/News/NewsWorkspaceView.swift b/ui/ui/News/NewsWorkspaceView.swift index 43a54e19..82285709 100644 --- a/ui/ui/News/NewsWorkspaceView.swift +++ b/ui/ui/News/NewsWorkspaceView.swift @@ -82,11 +82,12 @@ struct NewsWorkspaceView: View { let summary = store.selectedReader?.summaryText, !summary.isEmpty { VStack(alignment: .leading, spacing: 8) { - Text("Summary") + Text("AI summary") .font(.subheadline.weight(.semibold)) - Text(LocalizedStringKey(summary)) - .font(.body) - .textSelection(.enabled) + Text("A digest of the articles below.") + .font(.caption) + .foregroundStyle(.secondary) + MarkdownResponseView(text: summary, allowsCSVExport: false) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) @@ -97,10 +98,12 @@ struct NewsWorkspaceView: View { ForEach(store.items) { item in articleCard(item) } - } else { + } else if !store.items.isEmpty { VStack(alignment: .leading, spacing: 8) { - Text("Sources") - .font(.caption.weight(.semibold)) + Text("Articles (\(store.items.count))") + .font(.subheadline.weight(.semibold)) + Text("Stories fetched from your feeds. The header feed count is how many RSS sources are configured, not how many articles appear here.") + .font(.caption) .foregroundStyle(.secondary) ForEach(store.items) { item in HStack(alignment: .top, spacing: 8) { diff --git a/ui/ui/Views/MarkdownView.swift b/ui/ui/Views/MarkdownView.swift index d39b0670..c5b9f787 100644 --- a/ui/ui/Views/MarkdownView.swift +++ b/ui/ui/Views/MarkdownView.swift @@ -2,6 +2,7 @@ import AppKit import SwiftUI enum MarkdownBlock: Identifiable { + case heading(level: Int, text: String) case paragraph(String) case bullet(String) case numbered(number: Int, text: String) @@ -10,6 +11,8 @@ enum MarkdownBlock: Identifiable { var id: String { switch self { + case .heading(let level, let text): + return "h\(level)-\(text.hashValue)" case .paragraph(let text): return "p-\(text.hashValue)" case .bullet(let text): @@ -66,6 +69,22 @@ enum MarkdownBlock: Identifiable { continue } + if trimmed.hasPrefix("#") { + var level = 0 + var index = trimmed.startIndex + while index < trimmed.endIndex, trimmed[index] == "#", level < 6 { + level += 1 + index = trimmed.index(after: index) + } + if level > 0, index < trimmed.endIndex, trimmed[index] == " " { + flushParagraph() + let headingText = String(trimmed[trimmed.index(after: index)...]) + .trimmingCharacters(in: .whitespaces) + blocks.append(.heading(level: level, text: headingText)) + continue + } + } + // Check bullet if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { flushParagraph() @@ -290,6 +309,13 @@ struct MarkdownResponseView: View { @ViewBuilder private func blockView(for block: MarkdownBlock) -> some View { switch block { + case .heading(let level, let text): + Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + .font(headingFont(level: level)) + .fontWeight(.semibold) + .padding(.horizontal, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) case .paragraph(let text): Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) .lineSpacing(2) @@ -352,6 +378,16 @@ struct MarkdownResponseView: View { } } + private func headingFont(level: Int) -> Font { + switch level { + case 1: return .title + case 2: return .title2 + case 3: return .title3 + case 4: return .headline + default: return .subheadline + } + } + @ViewBuilder private func csvTableView(table: CSVTable, source: String) -> some View { VStack(alignment: .leading, spacing: 10) { diff --git a/workers/go/internal/newsreader/fetch.go b/workers/go/internal/newsreader/fetch.go index 904a82f1..7c2b047d 100644 --- a/workers/go/internal/newsreader/fetch.go +++ b/workers/go/internal/newsreader/fetch.go @@ -81,21 +81,38 @@ func filterTopics(articles []Article, topics []string) []Article { return articles } matched := make([]Article, 0, len(articles)) + unmatched := make([]Article, 0, len(articles)) for _, article := range articles { hay := strings.ToLower(article.Title + " " + article.Detail) - for _, needle := range needles { - if strings.Contains(hay, needle) { - matched = append(matched, article) - break - } + if topicMatches(hay, needles) { + matched = append(matched, article) + } else { + unmatched = append(unmatched, article) } } if len(matched) == 0 { return articles } + // Prefer topic matches, but keep other articles when filtering would drop too many + // (for example WSJ headlines that do not literally contain "tech"). + if len(matched) < 3 && len(unmatched) > 0 { + out := make([]Article, 0, len(articles)) + out = append(out, matched...) + out = append(out, unmatched...) + return out + } return matched } +func topicMatches(hay string, needles []string) bool { + for _, needle := range needles { + if strings.Contains(hay, needle) { + return true + } + } + return false +} + func uniqueArticles(articles []Article) []Article { seen := map[string]bool{} out := make([]Article, 0, len(articles)) diff --git a/workers/go/internal/newsreader/newsreader_test.go b/workers/go/internal/newsreader/newsreader_test.go index 1e5e0247..3bee2d75 100644 --- a/workers/go/internal/newsreader/newsreader_test.go +++ b/workers/go/internal/newsreader/newsreader_test.go @@ -28,3 +28,29 @@ func TestCanonicalFetchURLRewritesGoogleNewsHomepage(t *testing.T) { t.Fatalf("expected RSS rewrite, got %q", got) } } + +func TestCanonicalFetchURLUpgradesGeneralGoogleNewsRSSForTechHint(t *testing.T) { + got := CanonicalFetchURL( + "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en", + "tech-news Tech", + ) + want := "https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=en-US&gl=US&ceid=US:en" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestFilterTopicsKeepsUnmatchedArticlesWhenFewMatches(t *testing.T) { + articles := []Article{ + {Title: "TechCrunch story", URL: "https://example.com/a"}, + {Title: "Wall Street earnings", URL: "https://example.com/b"}, + {Title: "Another market report", URL: "https://example.com/c"}, + } + got := filterTopics(articles, []string{"Tech"}) + if len(got) != 3 { + t.Fatalf("expected all articles when only one matches, got %d", len(got)) + } + if got[0].Title != "TechCrunch story" { + t.Fatalf("expected matched article first, got %q", got[0].Title) + } +} diff --git a/workers/go/internal/newsreader/urls.go b/workers/go/internal/newsreader/urls.go index 871c1b65..599fe7d9 100644 --- a/workers/go/internal/newsreader/urls.go +++ b/workers/go/internal/newsreader/urls.go @@ -16,6 +16,11 @@ func CanonicalFetchURL(raw string, contextHint string) string { } path := strings.ToLower(parsed.Path) if strings.Contains(path, "/rss") || strings.HasSuffix(path, ".xml") { + if !strings.Contains(path, "/headlines/section/topic/") { + if section := googleNewsSection(contextHint); section != "" { + return "https://news.google.com/rss/headlines/section/topic/" + section + "?hl=en-US&gl=US&ceid=US:en" + } + } return raw } if strings.Contains(path, "/topics/") { From d487084bb8e54834fb2d3c335db26c54f7db8858 Mon Sep 17 00:00:00 2001 From: David Choi Date: Fri, 11 Sep 2026 21:48:05 -0400 Subject: [PATCH 08/17] Fix NewsReaderSpec argument order in DB tests so CI compiles. Co-authored-by: Cursor --- .../Tests/DBRepositoryTests/DBNewsReaderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift index 55787006..c87e1959 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift @@ -12,9 +12,9 @@ final class DBNewsReaderTests: XCTestCase { topics: ["Financial", "rates"], sources: [NewsSource(label: "BBC", url: "https://feeds.bbci.co.uk/news/world/rss.xml")], mode: .summary, - summaryText: "Markets moved higher.", maxCount: 10, - schedule: .daily + schedule: .daily, + summaryText: "Markets moved higher." ) try await repository.upsertNewsReader(spec) let listed = try await repository.listNewsReaders() From b34c262399baa57eb70c300a3ef10e21bf7b6858 Mon Sep 17 00:00:00 2001 From: David Choi Date: Fri, 11 Sep 2026 22:15:00 -0400 Subject: [PATCH 09/17] remove ci issue; old news reader --- docker/worker/Dockerfile | 4 +- .../DBRepository/DBRepositoryNews.swift | 160 ------------ .../Sources/DBRepository/DatabaseSchema.swift | 4 +- .../0009_drop_news_readers.down.sql | 30 +++ .../Migrations/0009_drop_news_readers.up.sql | 2 + .../DBRepositoryTests/DBNewsReaderTests.swift | 62 ----- .../DBRepositoryTests/DBRepositoryTests.swift | 2 +- .../DockerRunRequestValidator.swift | 1 - .../DockerRunnerXPCTests.swift | 1 - .../MCPServer/NewsReaderDockerExecutor.swift | 115 --------- .../NewsReaderDockerInputPreparer.swift | 43 ---- .../Tests/MCPServerTests/MCPServerTests.swift | 43 +--- .../InProcessServiceBridges.swift | 3 - .../MCPService/MCPServiceXPC.swift | 10 - .../MCPService/MCPToolCallTimeouts.swift | 3 - .../MCPService/PluginFactoryCreateInput.swift | 6 - .../News/NewsReaderModels.swift | 231 ------------------ .../News/NewsReaderRefresh.swift | 108 -------- .../News/NewsReaderWorkerTypes.swift | 72 ------ .../News/NewsWorkerBridge.swift | 30 --- .../Plugin/PluginSkillDraft.swift | 99 +------- .../Sources/Contract/GuestContract.swift | 1 - .../schemas/news-reader-result.schema.json | 7 - .../schemas/worker-product.schema.json | 31 +-- .../DerrickDockerRuntimeIdentity.swift | 5 +- .../DockerRunnerXPC/DockerWorkerRuntime.swift | 4 +- .../AppLayerServicesWireTests.swift | 3 - .../StructureTests/NewsPaywallTests.swift | 29 +++ .../StructureTests/NewsReaderTests.swift | 74 ------ .../PluginSkillDraftTests.swift | 38 +-- ui/JobKeepAlive/DaemonModuleBootstrap.swift | 3 - ui/JobKeepAlive/DaemonUnifiedXPC.swift | 4 - ui/MCPService/MCPServiceExportedObject.swift | 17 -- ui/MCPService/MCPServiceToolHost.swift | 42 ---- .../Services/MCPServiceClient.swift | 19 -- ui/ui/Messaging/AppWorkspace.swift | 1 - ui/ui/News/MCPServiceNewsWorker.swift | 23 -- ui/ui/News/NewsReaderStore.swift | 148 ----------- ui/ui/News/NewsReaderSummarizer.swift | 67 ----- ui/ui/News/NewsWorkspaceView.swift | 171 ------------- ui/ui/Plugins/PluginCreationController.swift | 116 +-------- ui/ui/Plugins/PluginsWorkspaceView.swift | 179 +------------- ui/ui/Views/ContentView.swift | 17 +- ui/ui/Views/SidebarView.swift | 55 ----- ui/uiTests/NewsReaderFlowTests.swift | 58 ----- workers/go/cmd/derrick-news-reader/main.go | 97 -------- workers/go/internal/contract/contract.go | 5 - .../schemas/news-reader-result.schema.json | 7 - .../schemas/worker-product.schema.json | 31 +-- workers/go/internal/newsreader/engine.go | 34 --- workers/go/internal/newsreader/fetch.go | 127 ---------- workers/go/internal/newsreader/list.go | 88 ------- .../go/internal/newsreader/newsreader_test.go | 56 ----- workers/go/internal/newsreader/rss.go | 207 ---------------- workers/go/internal/newsreader/text.go | 76 ------ workers/go/internal/newsreader/types.go | 49 ---- workers/go/internal/newsreader/urls.go | 62 ----- workers/go/internal/newsreader/validate.go | 38 --- 58 files changed, 89 insertions(+), 2929 deletions(-) delete mode 100644 packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift create mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.down.sql create mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.up.sql delete mode 100644 packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift delete mode 100644 packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift delete mode 100644 packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift delete mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift delete mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift delete mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift delete mode 100644 packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift delete mode 100644 packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json create mode 100644 packages/Structure/Tests/StructureTests/NewsPaywallTests.swift delete mode 100644 packages/Structure/Tests/StructureTests/NewsReaderTests.swift delete mode 100644 ui/ui/News/MCPServiceNewsWorker.swift delete mode 100644 ui/ui/News/NewsReaderStore.swift delete mode 100644 ui/ui/News/NewsReaderSummarizer.swift delete mode 100644 ui/ui/News/NewsWorkspaceView.swift delete mode 100644 ui/uiTests/NewsReaderFlowTests.swift delete mode 100644 workers/go/cmd/derrick-news-reader/main.go delete mode 100644 workers/go/internal/contract/schemas/news-reader-result.schema.json delete mode 100644 workers/go/internal/newsreader/engine.go delete mode 100644 workers/go/internal/newsreader/fetch.go delete mode 100644 workers/go/internal/newsreader/list.go delete mode 100644 workers/go/internal/newsreader/newsreader_test.go delete mode 100644 workers/go/internal/newsreader/rss.go delete mode 100644 workers/go/internal/newsreader/text.go delete mode 100644 workers/go/internal/newsreader/types.go delete mode 100644 workers/go/internal/newsreader/urls.go delete mode 100644 workers/go/internal/newsreader/validate.go diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile index 7098c076..0e11fc1a 100644 --- a/docker/worker/Dockerfile +++ b/docker/worker/Dockerfile @@ -9,11 +9,10 @@ COPY workers/go ./ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-web-crawler ./cmd/derrick-crawler RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-file-extractor ./cmd/derrick-file-extractor -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/derrick-news-reader ./cmd/derrick-news-reader FROM debian:bookworm-slim -LABEL derrick.worker.binaries="crawler,extractor,news-reader" +LABEL derrick.worker.binaries="crawler,extractor" RUN apt-get update \ && apt-get install -y --no-install-recommends poppler-utils ca-certificates \ @@ -23,7 +22,6 @@ RUN apt-get update \ COPY --from=build /usr/local/go /usr/local/go COPY --from=build /out/derrick-web-crawler /usr/local/bin/derrick-web-crawler COPY --from=build /out/derrick-file-extractor /usr/local/bin/derrick-file-extractor -COPY --from=build /out/derrick-news-reader /usr/local/bin/derrick-news-reader RUN mkdir -p /data/in /data/out && chown -R worker:worker /data ENV PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift deleted file mode 100644 index 65b55953..00000000 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryNews.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Foundation -import SQLite3 -import Structure - -public extension DBRepository { - func upsertNewsReader(_ spec: NewsReaderSpec) throws { - let topicsData = try JSONEncoder().encode(spec.topics) - let sourcesData = try JSONEncoder().encode(spec.sources) - let topics = String(data: topicsData, encoding: .utf8) ?? "[]" - let sources = String(data: sourcesData, encoding: .utf8) ?? "[]" - try withDatabaseHandle { handle in - try Self.execute(""" - INSERT INTO news_readers ( - id, name, topics_json, sources_json, mode, max_count, schedule, - summary_text, last_error, last_fetched_at, created_at, updated_at - ) VALUES ( - \(quoted(spec.id)), - \(quoted(spec.name)), - \(quoted(topics)), - \(quoted(sources)), - \(quoted(spec.mode.rawValue)), - \(spec.maxCount), - \(quoted(spec.schedule.rawValue)), - \(sqlValue(spec.summaryText)), - \(sqlValue(spec.lastError)), - \(sqlValue(spec.lastFetchedAt.map { Self.iso8601Formatter().string(from: $0) })), - \(quoted(Self.iso8601Formatter().string(from: spec.createdAt))), - \(quoted(Self.iso8601Formatter().string(from: spec.updatedAt))) - ) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - topics_json = excluded.topics_json, - sources_json = excluded.sources_json, - mode = excluded.mode, - max_count = excluded.max_count, - schedule = excluded.schedule, - summary_text = excluded.summary_text, - last_error = excluded.last_error, - last_fetched_at = excluded.last_fetched_at, - updated_at = excluded.updated_at; - """, on: handle) - } - } - - func listNewsReaders() throws -> [NewsReaderSpec] { - try withDatabaseHandle { handle in - let sql = """ - SELECT id, name, topics_json, sources_json, mode, max_count, schedule, - summary_text, last_error, last_fetched_at, created_at, updated_at - FROM news_readers - ORDER BY updated_at DESC; - """ - var statement: OpaquePointer? - guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { - throw Self.sqliteError(handle: handle, fallback: "Failed to list news readers.") - } - defer { sqlite3_finalize(statement) } - var rows: [NewsReaderSpec] = [] - while sqlite3_step(statement) == SQLITE_ROW { - rows.append(try decodeNewsReader(statement: statement)) - } - return rows - } - } - - func deleteNewsReader(id: String) throws { - try withDatabaseHandle { handle in - try Self.execute("DELETE FROM news_readers WHERE id = \(quoted(id));", on: handle) - } - } - - func replaceNewsItems(readerID: String, items: [NewsItem]) throws { - try withDatabaseHandle { handle in - try Self.withImmediateTransaction(on: handle) { - try Self.execute("DELETE FROM news_items WHERE reader_id = \(quoted(readerID));", on: handle) - for item in items { - try Self.execute(""" - INSERT INTO news_items ( - id, reader_id, title, source_url, source_label, summary, published_at, fetched_at - ) VALUES ( - \(quoted(item.id)), - \(quoted(item.readerID)), - \(quoted(item.title)), - \(quoted(item.sourceURL)), - \(quoted(item.sourceLabel)), - \(sqlValue(item.summary)), - \(sqlValue(item.publishedAt.map { Self.iso8601Formatter().string(from: $0) })), - \(quoted(Self.iso8601Formatter().string(from: item.fetchedAt))) - ); - """, on: handle) - } - } - } - } - - func listNewsItems(readerID: String) throws -> [NewsItem] { - try withDatabaseHandle { handle in - let sql = """ - SELECT id, reader_id, title, source_url, source_label, summary, published_at, fetched_at - FROM news_items - WHERE reader_id = \(quoted(readerID)) - ORDER BY COALESCE(published_at, fetched_at) DESC; - """ - var statement: OpaquePointer? - guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { - throw Self.sqliteError(handle: handle, fallback: "Failed to list news items.") - } - defer { sqlite3_finalize(statement) } - var rows: [NewsItem] = [] - while sqlite3_step(statement) == SQLITE_ROW { - rows.append(try decodeNewsItem(statement: statement)) - } - return rows - } - } - - private func decodeNewsReader(statement: OpaquePointer) throws -> NewsReaderSpec { - func text(_ index: Int32) -> String { - String(cString: sqlite3_column_text(statement, index)) - } - func optionalText(_ index: Int32) -> String? { - sqlite3_column_type(statement, index) == SQLITE_NULL ? nil : text(index) - } - let topics = (try? JSONDecoder().decode([String].self, from: Data(text(2).utf8))) ?? [] - let sources = (try? JSONDecoder().decode([NewsSource].self, from: Data(text(3).utf8))) ?? [] - return NewsReaderSpec( - id: text(0), - name: text(1), - topics: topics, - sources: sources, - mode: NewsReaderMode(rawValue: text(4)) ?? .rss, - maxCount: Int(sqlite3_column_int(statement, 5)), - schedule: NewsReaderSchedule(rawValue: text(6)) ?? .off, - summaryText: optionalText(7), - lastError: optionalText(8), - lastFetchedAt: optionalText(9).flatMap { Self.iso8601Formatter().date(from: $0) }, - createdAt: Self.iso8601Formatter().date(from: text(10)) ?? .now, - updatedAt: Self.iso8601Formatter().date(from: text(11)) ?? .now - ) - } - - private func decodeNewsItem(statement: OpaquePointer) throws -> NewsItem { - func text(_ index: Int32) -> String { - String(cString: sqlite3_column_text(statement, index)) - } - func optionalText(_ index: Int32) -> String? { - sqlite3_column_type(statement, index) == SQLITE_NULL ? nil : text(index) - } - return NewsItem( - id: text(0), - readerID: text(1), - title: text(2), - sourceURL: text(3), - sourceLabel: text(4), - summary: optionalText(5), - publishedAt: optionalText(6).flatMap { Self.iso8601Formatter().date(from: $0) }, - fetchedAt: Self.iso8601Formatter().date(from: text(7)) ?? .now - ) - } -} diff --git a/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift b/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift index 6e3121c2..b2aee8fa 100644 --- a/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift +++ b/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift @@ -2,7 +2,7 @@ import Foundation import Structure public enum DatabaseSchema { - public static let latestVersion = 8 + public static let latestVersion = 9 public static func migrationSQL(version: Int, isUp: Bool) throws -> String { let migrationName = String(format: "%04d_%@", version, migrationFileBaseName(for: version)) @@ -39,6 +39,8 @@ public enum DatabaseSchema { return "messaging_agent_handled" case 8: return "messaging_thread_default_profile" + case 9: + return "drop_news_readers" default: return "unknown" } diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.down.sql new file mode 100644 index 00000000..054ea0f3 --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.down.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS news_readers ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + topics_json TEXT NOT NULL DEFAULT '[]', + sources_json TEXT NOT NULL DEFAULT '[]', + mode TEXT NOT NULL, + max_count INTEGER NOT NULL, + schedule TEXT NOT NULL, + summary_text TEXT, + last_error TEXT, + last_fetched_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS news_items ( + id TEXT PRIMARY KEY NOT NULL, + reader_id TEXT NOT NULL, + title TEXT NOT NULL, + source_url TEXT NOT NULL, + source_label TEXT NOT NULL, + summary TEXT, + published_at TEXT, + fetched_at TEXT NOT NULL, + UNIQUE(reader_id, source_url), + FOREIGN KEY(reader_id) REFERENCES news_readers(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_news_items_reader + ON news_items(reader_id, fetched_at DESC); diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.up.sql new file mode 100644 index 00000000..cbb5bff7 --- /dev/null +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.up.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS news_items; +DROP TABLE IF EXISTS news_readers; diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift deleted file mode 100644 index c87e1959..00000000 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBNewsReaderTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import XCTest -import Structure -@testable import DBRepository - -final class DBNewsReaderTests: XCTestCase { - func testNewsReaderRoundTripAndItems() async throws { - let repository = try makeRepository() - _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") - - let spec = NewsReaderSpec( - name: "Markets", - topics: ["Financial", "rates"], - sources: [NewsSource(label: "BBC", url: "https://feeds.bbci.co.uk/news/world/rss.xml")], - mode: .summary, - maxCount: 10, - schedule: .daily, - summaryText: "Markets moved higher." - ) - try await repository.upsertNewsReader(spec) - let listed = try await repository.listNewsReaders() - XCTAssertEqual(listed.count, 1) - XCTAssertEqual(listed[0].name, "Markets") - XCTAssertEqual(listed[0].topics, ["Financial", "rates"]) - XCTAssertEqual(listed[0].mode, .summary) - XCTAssertEqual(listed[0].summaryText, "Markets moved higher.") - - let item = NewsItem( - readerID: spec.id, - title: "Rates rise", - sourceURL: "https://example.com/rates", - sourceLabel: "BBC", - summary: "A summary" - ) - try await repository.replaceNewsItems(readerID: spec.id, items: [item]) - let items = try await repository.listNewsItems(readerID: spec.id) - XCTAssertEqual(items.count, 1) - XCTAssertEqual(items[0].sourceURL, "https://example.com/rates") - - try await repository.deleteNewsReader(id: spec.id) - let remainingReaders = try await repository.listNewsReaders() - let remainingItems = try await repository.listNewsItems(readerID: spec.id) - XCTAssertTrue(remainingReaders.isEmpty) - XCTAssertTrue(remainingItems.isEmpty) - } - - private func makeRepository() throws -> DBRepository { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent( - UUID().uuidString, - isDirectory: true - ) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return DBRepository( - configuration: DBRepositoryConfiguration( - applicationName: "ui", - databaseName: "derrick", - databaseDirectoryURL: directory, - username: "app-user", - password: "app-secret" - ) - ) - } -} diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift index 9adf82a5..4d2ed206 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift @@ -108,7 +108,7 @@ final class DBRepositoryTests: XCTestCase { _ = try await repository.migrateSessionMemory(username: "app-user", password: "app-secret") XCTAssertEqual(try schemaVersion(at: url), DatabaseSchema.latestVersion) - XCTAssertTrue(try tableExists(named: "news_readers", at: url)) + XCTAssertFalse(try tableExists(named: "news_readers", at: url)) XCTAssertTrue(try tableExists(named: "agent_profiles", at: url)) XCTAssertTrue(try tableExists(named: "messaging_agent_handled", at: url)) let loaded = try await repository.pluginFactoryRelease(pluginID: "keep-me", version: "1.0.0") diff --git a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift index 391ce1f1..ec7cd578 100644 --- a/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift +++ b/packages/DockerRunnerXPC/Sources/DockerRunnerXPC/DockerRunRequestValidator.swift @@ -213,7 +213,6 @@ public enum DockerRunRequestValidator: Sendable { } case DockerWorkerRuntime.crawlerBinary, DockerWorkerRuntime.extractorBinary, - DockerWorkerRuntime.newsReaderBinary, DockerWorkerRuntime.guestBinaryPath: guard args == [command] else { return .disallowedDockerFlag("exec \(command)") diff --git a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift index cc7b0023..bd387784 100644 --- a/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift +++ b/packages/DockerRunnerXPC/Tests/DockerRunnerXPCTests/DockerRunnerXPCTests.swift @@ -240,7 +240,6 @@ struct DockerRunnerXPCTests { ["rm", "-f", "c"], ["inspect", "-f", "{{.State.Running}}", "c"], ["exec", "-i", "c", "/usr/local/bin/derrick-file-extractor"], - ["exec", "-i", "c", DockerWorkerRuntime.newsReaderBinary], [ "create", "--label", diff --git a/packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift b/packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift deleted file mode 100644 index 11c637ff..00000000 --- a/packages/MCPServer/Sources/MCPServer/NewsReaderDockerExecutor.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation -import Structure - -/// Runs the prebuilt news reader in a oneshot container: create, exec, rm. -public struct NewsReaderDockerExecutor: Sendable { - public static let image = DockerWorkerRuntime.image - public static let containerPrefix = "derrick-news-reader" - public static let binaryPath = DockerWorkerRuntime.newsReaderBinary - public static let maximumTimeoutSeconds = 300 - public static let dockerNetwork = "bridge" - - private let executor: DockerCLIExecutor - private let queue: DerrickDockerRunQueue - - public init( - executor: @escaping DockerCLIExecutor, - queue: DerrickDockerRunQueue = .crawler - ) { - self.executor = executor - self.queue = queue - } - - public func run( - input: Data, - timeoutSeconds: Int - ) async throws -> DockerCLIResult { - let timeout = min(max(timeoutSeconds, 1), Self.maximumTimeoutSeconds) - try await WorkerImageGate.shared.ensureReady(executor: executor) - let prepared = try await NewsReaderDockerInputPreparer.enrich(input) - let executor = self.executor - do { - return try await queue.withPermit { - let proxyLease = try await WebCrawlerEgressProxy.shared.lease(forHosts: prepared.leaseHosts) - do { - let result = try await OneshotDockerContainer.run( - executor: executor, - prefix: Self.containerPrefix, - createArguments: { name in - Self.createArguments( - name: name, - proxyHost: proxyLease.host, - proxyPort: proxyLease.port, - proxyToken: proxyLease.clientToken - ) - }, - createStep: "create news reader container", - startStep: "start news reader container", - body: { name in - try await executor( - ["exec", "-i", name, Self.binaryPath], - prepared.data, - timeout - ) - } - ) - await WebCrawlerEgressProxy.shared.release(forHosts: prepared.leaseHosts) - return result - } catch { - await WebCrawlerEgressProxy.shared.release(forHosts: prepared.leaseHosts) - throw error - } - } - } catch let error as OneshotDockerContainerError { - throw mappedNewsReaderError(error) - } - } - - static func createArguments( - name: String, - proxyHost: String, - proxyPort: Int, - proxyToken: String - ) -> [String] { - [ - "create", - ] + DerrickDockerRuntimeIdentity.createLabelArguments + [ - "--network", dockerNetwork, - "--read-only", - "--tmpfs", "/tmp:rw,exec,nosuid,size=64m", - "--pids-limit", "128", - "--cpus", "1.0", - "--memory", "512m", - "--name", name, - "--env", "DERRICK_EGRESS_PROXY_HOST=\(proxyHost)", - "--env", "DERRICK_EGRESS_PROXY_PORT=\(proxyPort)", - "--env", "DERRICK_EGRESS_PROXY_TOKEN=\(proxyToken)", - "--entrypoint", "/bin/sleep", - image, - "infinity", - ] - } - - private func mappedNewsReaderError(_ error: OneshotDockerContainerError) -> Error { - switch error { - case .commandFailed(let step, let detail): - return NewsReaderDockerExecutorError.commandFailed(step, detail) - case .imageUnavailable(let detail): - return NewsReaderDockerExecutorError.imageUnavailable(detail) - } - } -} - -public enum NewsReaderDockerExecutorError: Error, LocalizedError, Sendable, Equatable { - case commandFailed(String, String) - case imageUnavailable(String) - - public var errorDescription: String? { - switch self { - case .commandFailed(let step, let detail): - return "\(step) failed: \(detail)" - case .imageUnavailable(let image): - return "News reader image is not installed: \(image)." - } - } -} diff --git a/packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift b/packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift deleted file mode 100644 index a894dbe1..00000000 --- a/packages/MCPServer/Sources/MCPServer/NewsReaderDockerInputPreparer.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -import Structure -import WebCrawler - -/// Host-side helpers for preparing news reader Docker input and egress policy. -enum NewsReaderDockerInputPreparer { - static func enrich(_ input: Data) async throws -> (data: Data, leaseHosts: [String]) { - let request: NewsReaderWorkerRequest - do { - request = try JSONDecoder.service.decode(NewsReaderWorkerRequest.self, from: input) - } catch { - throw NewsReaderDockerExecutorError.commandFailed( - "prepare news reader container", - "News reader input was not valid JSON." - ) - } - guard !request.sources.isEmpty else { - throw NewsReaderDockerExecutorError.commandFailed( - "prepare news reader container", - "News reader input did not include any sources." - ) - } - - var hosts = Set() - for source in request.sources { - guard let url = URL(string: source.url) else { - throw NewsReaderDockerExecutorError.commandFailed( - "prepare news reader container", - "Source URL is not valid: \(source.url)" - ) - } - let chain = await WebCrawlerRedirectResolver.hostsInRedirectChain(from: url) - hosts.formUnion(chain) - } - guard !hosts.isEmpty else { - throw NewsReaderDockerExecutorError.commandFailed( - "prepare news reader container", - "Could not determine hosts to fetch from the configured sources." - ) - } - return (input, Array(hosts).sorted()) - } -} diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index 25576ca9..c9dd3a51 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -282,7 +282,7 @@ import WebCrawler #expect(allowed?.contains("docs.slack.dev") == true) } - @Test func workerImageLabelDetectsMissingNewsReaderBinary() async { + @Test func workerImageLabelDetectsMissingBinaries() async { let recorder = DockerCallRecorder() let executor: DockerCLIExecutor = { args, _, _ in await recorder.append(args) @@ -295,47 +295,6 @@ import WebCrawler #expect(!current) } - @Test func newsReaderContainerCreateAndExecPassDockerValidator() { - let createArgs = NewsReaderDockerExecutor.createArguments( - name: "derrick-news-reader-test", - proxyHost: "172.17.0.1", - proxyPort: 3128, - proxyToken: "token" - ) - #expect( - DockerRunRequestValidator.validate( - DockerHostLaunch.makeRequest(dockerArguments: createArgs, timeoutSeconds: 60) - ) == nil - ) - let execArgs = DockerHostLaunch.dockerCLIArguments([ - "exec", "-i", "derrick-news-reader-test", NewsReaderDockerExecutor.binaryPath, - ]) - #expect( - DockerRunRequestValidator.validate( - DockerHostLaunch.makeRequest(dockerArguments: execArgs, timeoutSeconds: 60) - ) == nil - ) - } - - @Test func newsReaderInputPreparerUsesSourceHostsNotCrawlerStartURL() async throws { - let input = try JSONEncoder.service.encode( - NewsReaderWorkerRequest( - mode: .rss, - sources: [ - NewsSource(label: "Google News", url: "https://news.google.com/rss?hl=en-US"), - ], - topics: ["Tech"], - maxCount: 20 - ) - ) - - let prepared = try await NewsReaderDockerInputPreparer.enrich(input) - #expect(prepared.leaseHosts.contains("news.google.com")) - let decoded = try JSONDecoder.service.decode(NewsReaderWorkerRequest.self, from: prepared.data) - #expect(decoded.sources.count == 1) - #expect(decoded.sources[0].url.contains("news.google.com")) - } - @Test func dockerProductImagePrewarmerSkipsBuildWhenImagePresent() async throws { let recorder = DockerCallRecorder() let executor: DockerCLIExecutor = { args, _, _ in diff --git a/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift b/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift index eab2002f..d8e8e745 100644 --- a/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift +++ b/packages/Structure/Sources/AppLayerServices/JobKeepAlive/InProcessServiceBridges.swift @@ -38,7 +38,4 @@ public enum InProcessServiceBridges: Sendable { public typealias RouteMessagingAgent = @Sendable (MessagingAgentRoute) async throws -> Void nonisolated(unsafe) public static var messagingAgentRoute: RouteMessagingAgent? - - public typealias RunNewsReader = @Sendable (Data) async throws -> NewsReaderRunResult - nonisolated(unsafe) public static var runNewsReader: RunNewsReader? } diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift b/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift index 9d732142..5d3808f0 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/MCPServiceXPC.swift @@ -19,8 +19,6 @@ import CryptoKit func callTool(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) /// Signed `searchTools` envelope. Reply is `MCPToolSearchResultDTO`. func searchTools(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) - /// Runs the Docker news reader worker (stdin JSON request). Reply is `NewsReaderRunResult`. - func runNewsReader(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) } public struct MCPServiceBootstrapResult: Codable, Sendable, Hashable { @@ -350,12 +348,4 @@ public enum MCPServiceXPCCodec { public static func decodeString(_ data: Data) -> String { String(data: data, encoding: .utf8) ?? "" } - - public static func encodeNewsReaderRunResult(_ result: NewsReaderRunResult) throws -> Data { - try JSONEncoder.service.encode(result) - } - - public static func decodeNewsReaderRunResult(_ data: Data) throws -> NewsReaderRunResult { - try JSONDecoder.service.decode(NewsReaderRunResult.self, from: data) - } } diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift b/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift index ed48b99e..94eec536 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/MCPToolCallTimeouts.swift @@ -12,9 +12,6 @@ public enum MCPToolCallTimeouts { /// Must be at least as long as `MCPServiceDockerHelperRunner` call timeout. public static let pluginInvokeNanoseconds: UInt64 = 120_000_000_000 - /// News reader Docker worker (RSS fetch + optional summary prep). - public static let newsReaderNanoseconds: UInt64 = 200_000_000_000 - public static func nanoseconds(forToolName toolName: String) -> UInt64 { switch toolName { case "web.crawl", "plugin_factory_build", "script_exec": diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift index 923fdff3..a152366d 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift @@ -4,7 +4,6 @@ import Foundation public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { public enum PluginType: String, Codable, Sendable, CaseIterable { case connector - case newsReader = "news_reader" case custom } @@ -148,8 +147,6 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { pluginID: pluginID, skillMarkdown: skillMarkdown ) - case .newsDigest: - throw PluginSkillDraftError.newsUsesReaderPath } } @@ -332,7 +329,6 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { case preview case credentials case build - case news } public static func failureStep(forStage stage: String?) -> FailureStep { @@ -345,8 +341,6 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { return .preview case "auth", "discover", "credentials": return .credentials - case "news", "paywall": - return .news case "crawl", "docs", "factory", "build", "review": return .build default: diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift b/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift deleted file mode 100644 index 57ce6529..00000000 --- a/packages/Structure/Sources/AppLayerServices/News/NewsReaderModels.swift +++ /dev/null @@ -1,231 +0,0 @@ -import Foundation - -public enum NewsReaderMode: String, Codable, Sendable, Hashable, CaseIterable { - case rss - case list - case summary - - public var displayName: String { - switch self { - case .rss: return "RSS feed" - case .list: return "Crawl site" - case .summary: return "AI summary" - } - } - - public init(from decoder: Decoder) throws { - let raw = try decoder.singleValueContainer().decode(String.self) - switch raw { - case "rss": self = .rss - case "list": self = .list - case "summary", "summaries": self = .summary - default: - self = .rss - } - } -} - -public enum NewsReaderSchedule: String, Codable, Sendable, Hashable, CaseIterable { - case off - case hourly - case daily - - public var displayName: String { - switch self { - case .off: return "Only when opened" - case .hourly: return "Every hour" - case .daily: return "Every day" - } - } -} - -public struct NewsSource: Codable, Sendable, Hashable, Identifiable { - public var id: String - public var label: String - public var url: String - - public init(id: String = UUID().uuidString, label: String, url: String) { - self.id = id - self.label = label - self.url = url.trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -public struct NewsReaderSpec: Codable, Sendable, Hashable, Identifiable { - public var id: String - public var name: String - public var topics: [String] - public var sources: [NewsSource] - public var mode: NewsReaderMode - public var maxCount: Int - public var schedule: NewsReaderSchedule - public var summaryText: String? - public var lastError: String? - public var lastFetchedAt: Date? - public var createdAt: Date - public var updatedAt: Date - - public init( - id: String = UUID().uuidString, - name: String, - topics: [String], - sources: [NewsSource], - mode: NewsReaderMode = .rss, - maxCount: Int = 20, - schedule: NewsReaderSchedule = .off, - summaryText: String? = nil, - lastError: String? = nil, - lastFetchedAt: Date? = nil, - createdAt: Date = .now, - updatedAt: Date = .now - ) { - self.id = id - self.name = name.trimmingCharacters(in: .whitespacesAndNewlines) - self.topics = topics.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } - self.sources = sources.filter { !$0.url.isEmpty } - self.mode = mode - self.maxCount = min(50, max(1, maxCount)) - self.schedule = schedule - self.summaryText = summaryText?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - self.lastError = lastError - self.lastFetchedAt = lastFetchedAt - self.createdAt = createdAt - self.updatedAt = updatedAt - } -} - -public struct NewsItem: Codable, Sendable, Hashable, Identifiable { - public var id: String - public var readerID: String - public var title: String - public var sourceURL: String - public var sourceLabel: String - public var summary: String? - public var publishedAt: Date? - public var fetchedAt: Date - - public init( - id: String = UUID().uuidString, - readerID: String, - title: String, - sourceURL: String, - sourceLabel: String, - summary: String? = nil, - publishedAt: Date? = nil, - fetchedAt: Date = .now - ) { - self.id = id - self.readerID = readerID - self.title = title.trimmingCharacters(in: .whitespacesAndNewlines) - self.sourceURL = sourceURL.trimmingCharacters(in: .whitespacesAndNewlines) - self.sourceLabel = sourceLabel - self.summary = summary?.trimmingCharacters(in: .whitespacesAndNewlines) - self.publishedAt = publishedAt - self.fetchedAt = fetchedAt - } -} - -public enum NewsPresetTopic: String, Sendable, CaseIterable, Identifiable { - case financial - case tech - case international - case politics - case science - case sports - - public var id: String { rawValue } - - public var displayName: String { - switch self { - case .financial: return "Financial" - case .tech: return "Tech" - case .international: return "International" - case .politics: return "Politics" - case .science: return "Science" - case .sports: return "Sports" - } - } -} - -public enum NewsPresetSource: String, Sendable, CaseIterable, Identifiable { - case googleNews - case foxNews - case newsmax - case nationalReview - case wsj - - public var id: String { rawValue } - - public var source: NewsSource { - switch self { - case .googleNews: - return NewsSource( - id: rawValue, - label: "Google News", - url: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" - ) - case .foxNews: - return NewsSource( - id: rawValue, - label: "Fox News", - url: "https://moxie.foxnews.com/google-publisher/latest.xml" - ) - case .newsmax: - return NewsSource( - id: rawValue, - label: "Newsmax", - url: "https://www.newsmax.com/rss/Newsfront/" - ) - case .nationalReview: - return NewsSource( - id: rawValue, - label: "National Review", - url: "https://www.nationalreview.com/feed/" - ) - case .wsj: - return NewsSource( - id: rawValue, - label: "Wall Street Journal", - url: "https://feeds.a.dj.com/rss/RSSWorldNews.xml" - ) - } - } -} - -public enum NewsReaderError: Error, Sendable, Equatable, LocalizedError { - case paywalled(url: String, detail: String) - case invalidURL(String) - case emptySources - case emptyName - case fetchFailed(url: String, detail: String) - case notReady - case summarizerUnavailable - case workerUnavailable(String) - - public var errorDescription: String? { - switch self { - case .paywalled(let url, let detail): - return "This source is behind a paywall, which is not supported yet. \(detail) (\(url))" - case .invalidURL(let url): - return "That is not a usable web address: \(url)" - case .emptySources: - return "Add at least one source or URL." - case .emptyName: - return "Give this news list a name." - case .fetchFailed(let url, let detail): - return "Could not read \(url). \(detail)" - case .notReady: - return "News lists are not ready yet. Try again in a moment." - case .summarizerUnavailable: - return "Add an API key in Settings before creating an AI summary list." - case .workerUnavailable(let detail): - return "News reader worker is not available. \(detail)" - } - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift b/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift deleted file mode 100644 index aba438eb..00000000 --- a/packages/Structure/Sources/AppLayerServices/News/NewsReaderRefresh.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation - -public enum NewsReaderRefresh { - public static func validateAndFetch( - spec: NewsReaderSpec, - worker: any NewsWorkerRunning, - summarizer: NewsSummaryGenerating? = nil - ) async throws -> NewsReaderFetchResult { - let name = spec.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw NewsReaderError.emptyName } - guard !spec.sources.isEmpty else { throw NewsReaderError.emptySources } - - for source in spec.sources { - guard let url = URL(string: source.url), url.scheme == "http" || url.scheme == "https" else { - throw NewsReaderError.invalidURL(source.url) - } - if let reason = NewsPaywall.preflightRejection(url: url) { - throw NewsReaderError.paywalled(url: source.url, detail: reason) - } - } - - let request = NewsReaderWorkerRequest( - mode: spec.mode, - sources: spec.sources, - topics: spec.topics, - maxCount: spec.maxCount, - contextHint: ([spec.name] + spec.topics).joined(separator: " ") - ) - let requestJSON = try request.encodedJSON() - let stdout = try await worker.run(requestJSON: requestJSON) - let result = try JSONDecoder.service.decode(NewsReaderWorkerResult.self, from: stdout) - guard result.ok else { - let detail = result.diagnostics.joined(separator: " ") - throw NewsReaderError.fetchFailed( - url: spec.sources.first?.url ?? name, - detail: detail.isEmpty ? "News reader returned no articles." : detail - ) - } - - let items = result.articles.map { article in - NewsItem( - readerID: spec.id, - title: article.title, - sourceURL: article.url, - sourceLabel: sourceLabel(for: article.url, sources: spec.sources), - summary: article.detail?.nilIfEmpty, - publishedAt: parsePublishedAt(article.publishedAt) - ) - } - - var summaryText: String? - if spec.mode == .summary { - guard let summarizer else { - throw NewsReaderError.summarizerUnavailable - } - summaryText = try await summarizer.summarize( - listName: spec.name, - topics: spec.topics, - articles: items - ) - } - - return NewsReaderFetchResult(items: items, summaryText: summaryText) - } - - private static func sourceLabel(for url: String, sources: [NewsSource]) -> String { - if let host = URL(string: url)?.host { - if let match = sources.first(where: { URL(string: $0.url)?.host == host }) { - return match.label - } - return host - } - return sources.first?.label ?? "Source" - } - - private static func parsePublishedAt(_ raw: String?) -> Date? { - guard let raw, !raw.isEmpty else { return nil } - let rfc = DateFormatter() - rfc.locale = Locale(identifier: "en_US_POSIX") - rfc.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" - if let date = rfc.date(from: raw) { return date } - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = iso.date(from: raw) { return date } - iso.formatOptions = [.withInternetDateTime] - return iso.date(from: raw) - } -} - -public struct NewsReaderFetchResult: Sendable, Hashable { - public var items: [NewsItem] - public var summaryText: String? - - public init(items: [NewsItem], summaryText: String? = nil) { - self.items = items - self.summaryText = summaryText?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - } -} - -public protocol NewsSummaryGenerating: Sendable { - func summarize(listName: String, topics: [String], articles: [NewsItem]) async throws -> String -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift b/packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift deleted file mode 100644 index 42ddc0ef..00000000 --- a/packages/Structure/Sources/AppLayerServices/News/NewsReaderWorkerTypes.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -public protocol NewsWorkerRunning: Sendable { - func run(requestJSON: Data) async throws -> Data -} - -public struct NewsReaderWorkerRequest: Codable, Sendable, Hashable { - public var mode: NewsReaderMode - public var sources: [NewsSource] - public var topics: [String] - public var maxCount: Int - public var contextHint: String? - - public init( - mode: NewsReaderMode, - sources: [NewsSource], - topics: [String], - maxCount: Int, - contextHint: String? = nil - ) { - self.mode = mode - self.sources = sources - self.topics = topics - self.maxCount = maxCount - self.contextHint = contextHint?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - } - - public func encodedJSON() throws -> Data { - try JSONEncoder.service.encode(self) - } -} - -public struct NewsReaderWorkerArticle: Codable, Sendable, Hashable { - public var title: String - public var url: String - public var detail: String? - public var publishedAt: String? - - enum CodingKeys: String, CodingKey { - case title - case url - case detail - case publishedAt = "published_at" - } -} - -public struct NewsReaderWorkerResult: Codable, Sendable, Hashable { - public var ok: Bool - public var mode: NewsReaderMode - public var articles: [NewsReaderWorkerArticle] - public var diagnostics: [String] -} - -public struct NewsReaderRunResult: Codable, Sendable, Hashable { - public var ok: Bool - public var stdout: Data - public var stderr: Data - public var message: String - - public init(ok: Bool, stdout: Data = Data(), stderr: Data = Data(), message: String = "") { - self.ok = ok - self.stdout = stdout - self.stderr = stderr - self.message = message - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift b/packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift deleted file mode 100644 index 0d082d2d..00000000 --- a/packages/Structure/Sources/AppLayerServices/News/NewsWorkerBridge.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -public enum NewsWorkerBridge: Sendable { - public typealias Runner = @Sendable (Data) async throws -> Data - - private final class Storage: @unchecked Sendable { - var runner: Runner? - } - - private static let storage = Storage() - - public static func install(_ runner: @escaping Runner) { - storage.runner = runner - } - - public static func run(requestJSON: Data) async throws -> Data { - guard let runner = storage.runner else { - throw NewsReaderError.workerUnavailable("Docker news reader is not ready yet.") - } - return try await runner(requestJSON) - } -} - -public struct BridgedNewsWorker: NewsWorkerRunning { - public init() {} - - public func run(requestJSON: Data) async throws -> Data { - try await NewsWorkerBridge.run(requestJSON: requestJSON) - } -} diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift index bcdc13fa..f6b7d28d 100644 --- a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSkillDraft.swift @@ -32,7 +32,6 @@ public struct PluginSkillDraft: Sendable, Hashable { public enum PlannedKind: String, Sendable, Hashable { case messagingConnector - case newsDigest case customCapability } @@ -41,25 +40,19 @@ public struct PluginSkillDraft: Sendable, Hashable { public var triggers: Set public var examples: [Example] public var pluginName: String - public var newsTopics: [String] - public var newsSourceURLs: [String] public init( goal: String = "", purpose: String = "", triggers: Set = [.chat], examples: [Example] = [], - pluginName: String = "", - newsTopics: [String] = [], - newsSourceURLs: [String] = [] + pluginName: String = "" ) { self.goal = goal self.purpose = purpose self.triggers = triggers self.examples = examples self.pluginName = pluginName - self.newsTopics = newsTopics - self.newsSourceURLs = newsSourceURLs } public var plannedKind: PlannedKind { @@ -74,7 +67,7 @@ public struct PluginSkillDraft: Sendable, Hashable { switch plannedKind { case .messagingConnector: return inferredConnectorVendor?.isSelectableInWizard == true - case .newsDigest, .customCapability: + case .customCapability: return true } } @@ -104,12 +97,6 @@ public struct PluginSkillDraft: Sendable, Hashable { public func packageOutline() -> [String] { switch plannedKind { - case .newsDigest: - return [ - "plugin.json — name, schedule, and reader settings", - "skills/\(normalizedPluginFolderName())/SKILL.md — when Derrick uses this list", - "News fetcher — loads articles with source links", - ] case .messagingConnector, .customCapability: return [ "plugin.json — name, permissions, and secrets", @@ -143,8 +130,6 @@ public enum PluginSkillDraftPlanner { for kind: PluginSkillDraft.PlannedKind ) -> Set { switch kind { - case .newsDigest: - return [.chat, .schedule] case .messagingConnector: return [.chat, .messaging, .mention] case .customCapability: @@ -160,22 +145,8 @@ public enum PluginSkillDraftPlanner { } } - public static func inferNewsMode(from draft: PluginSkillDraft) -> NewsReaderMode { - let text = combinedText(draft) - if ["summary", "summarize", "summaries", "digest", "brief", "overview"] - .contains(where: { text.contains($0) }) { - return .summary - } - if ["crawl", "website", "homepage", "web page", "webpage", "site"] - .contains(where: { text.contains($0) }) { - return .list - } - return .rss - } - public static func inferKind(from draft: PluginSkillDraft) -> PluginSkillDraft.PlannedKind { let text = combinedText(draft) - if looksLikeNews(text) { return .newsDigest } if looksLikeMessaging(text) { return .messagingConnector } return .customCapability } @@ -208,12 +179,6 @@ public enum PluginSkillDraftPlanner { draft.triggers = defaultTriggers(for: draft) } sanitizeTriggers(in: &draft) - if inferKind(from: draft) == .newsDigest, draft.newsTopics.isEmpty { - draft.newsTopics = defaultNewsTopics(from: trimmed) - } - if inferKind(from: draft) == .newsDigest, draft.newsSourceURLs.isEmpty { - draft.newsSourceURLs = [NewsPresetSource.googleNews.source.url] - } } public static func skillMarkdown(for draft: PluginSkillDraft) -> String { @@ -259,15 +224,6 @@ public enum PluginSkillDraftPlanner { Confirmed behavior: \(examples) """ - case .newsDigest: - return """ - \(purpose) - - News reader that fetches articles from configured sources and includes source links. - - Topics: \(draft.newsTopics.joined(separator: ", ")) - Sources: \(draft.newsSourceURLs.joined(separator: ", ")) - """ case .customCapability: return """ \(purpose) @@ -313,8 +269,6 @@ public enum PluginSkillDraftPlanner { Return go_source, test_input_json, and skill_files. The host writes plugin.json when a host manifest is supplied; otherwise include a valid manifest in your output path via the builder contract. """ - case .newsDigest: - throw PluginSkillDraftError.newsUsesReaderPath } } @@ -324,10 +278,6 @@ public enum PluginSkillDraftPlanner { .lowercased() } - private static func looksLikeNews(_ text: String) -> Bool { - ["news", "rss", "headline", "digest", "articles", "reader"].contains { text.contains($0) } - } - private static func looksLikeMessaging(_ text: String) -> Bool { ["slack", "telegram", "whatsapp", "discord", "messaging", "channel", "inbox", "dm", "chat app"] .contains { text.contains($0) } @@ -340,12 +290,6 @@ public enum PluginSkillDraftPlanner { return ConnectorPluginNaming.defaultPluginID(vendor: vendor, existingIDs: existingIDs) } return ConnectorPluginNaming.defaultPluginID(vendor: .slack, existingIDs: existingIDs) - case .newsDigest: - let base = "news-list" - if !existingIDs.contains(base) { return base } - var index = 2 - while existingIDs.contains("\(base)-\(index)") { index += 1 } - return "\(base)-\(index)" case .customCapability: let words = draft.goal .lowercased() @@ -362,8 +306,6 @@ public enum PluginSkillDraftPlanner { switch inferKind(from: draft) { case .messagingConnector: return [.messaging, .chat] - case .newsDigest: - return [.schedule, .chat] case .customCapability: return [.chat] } @@ -383,13 +325,6 @@ public enum PluginSkillDraftPlanner { pluginDoes: "post the message in that channel" ), ] - case .newsDigest: - return [ - PluginSkillDraft.Example( - userSays: "What's in my news list?", - pluginDoes: "fetch the latest articles with links to the original sources" - ), - ] case .customCapability: let snippet = draft.goal.trimmingCharacters(in: .whitespacesAndNewlines) return [ @@ -400,46 +335,16 @@ public enum PluginSkillDraftPlanner { ] } } - - private static func defaultNewsTopics(from goal: String) -> [String] { - let lower = goal.lowercased() - var topics: [String] = [] - if lower.contains("financ") || lower.contains("market") { - topics.append(NewsPresetTopic.financial.displayName) - } - if lower.contains("tech") { - topics.append(NewsPresetTopic.tech.displayName) - } - if lower.contains("world") || lower.contains("international") { - topics.append(NewsPresetTopic.international.displayName) - } - if lower.contains("politic") { - topics.append(NewsPresetTopic.politics.displayName) - } - if lower.contains("science") { - topics.append(NewsPresetTopic.science.displayName) - } - if lower.contains("sport") { - topics.append(NewsPresetTopic.sports.displayName) - } - if topics.isEmpty { - topics.append(NewsPresetTopic.tech.displayName) - } - return topics - } } public enum PluginSkillDraftError: Error, LocalizedError { case missingConnectorVendor - case newsUsesReaderPath case invalidPluginName public var errorDescription: String? { switch self { case .missingConnectorVendor: return "Could not determine which messaging service this plugin targets." - case .newsUsesReaderPath: - return "News lists use the reader path, not the plugin factory." case .invalidPluginName: return "Choose a valid plugin name using letters, numbers, and hyphens." } diff --git a/packages/Structure/Sources/Contract/GuestContract.swift b/packages/Structure/Sources/Contract/GuestContract.swift index 4385f61c..fdf034ad 100644 --- a/packages/Structure/Sources/Contract/GuestContract.swift +++ b/packages/Structure/Sources/Contract/GuestContract.swift @@ -15,7 +15,6 @@ public enum GuestContract: Sendable { case workerProduct = "worker-product.schema.json" case webCrawlerResult = "web-crawler-result.schema.json" case fileExtractorResult = "file-extractor-result.schema.json" - case newsReaderResult = "news-reader-result.schema.json" case scriptExecContract = "script-exec-contract.schema.json" } diff --git a/packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json deleted file mode 100644 index 6632851d..00000000 --- a/packages/Structure/Sources/Contract/Resources/schemas/news-reader-result.schema.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://derrick.local/schemas/news-reader-result.json", - "title": "News reader worker stdout", - "description": "JSON object written to stdout by derrick-news-reader.", - "$ref": "worker-product.schema.json#/$defs/news_reader_result" -} diff --git a/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json b/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json index 611ef5fe..874f6bfb 100644 --- a/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json +++ b/packages/Structure/Sources/Contract/Resources/schemas/worker-product.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://derrick.local/schemas/worker-product.json", "title": "Derrick trusted worker product contracts", - "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler, file extractor, and news reader). Swift host and Go workers must match these schemas.", + "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler and file extractor). Swift host and Go workers must match these schemas.", "$defs": { "string_list": { "type": "array", @@ -92,35 +92,6 @@ }, "diagnostics": { "$ref": "#/$defs/string_list" } } - }, - "news_reader_mode": { - "type": "string", - "enum": ["rss", "list", "summary"] - }, - "news_reader_article": { - "type": "object", - "required": ["title", "url"], - "additionalProperties": false, - "properties": { - "title": { "type": "string" }, - "url": { "type": "string" }, - "detail": { "type": "string" }, - "published_at": { "type": "string" } - } - }, - "news_reader_result": { - "type": "object", - "required": ["ok", "mode", "articles", "diagnostics"], - "additionalProperties": false, - "properties": { - "ok": { "type": "boolean" }, - "mode": { "$ref": "#/$defs/news_reader_mode" }, - "articles": { - "type": "array", - "items": { "$ref": "#/$defs/news_reader_article" } - }, - "diagnostics": { "$ref": "#/$defs/string_list" } - } } } } diff --git a/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift b/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift index 040be5a6..ca2f57e1 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DerrickDockerRuntimeIdentity.swift @@ -13,9 +13,8 @@ public enum DerrickDockerRuntimeIdentity: Sendable { /// Name prefixes for current and unlabeled leftover containers. public static let namePrefixes = [ - "derrick-web-crawler", - "derrick-news-reader", - "derrick-guest-runtime", + "derrick-web-crawler", + "derrick-guest-runtime", "derrick-swift-runtime", "derrick-file-extractor", ] diff --git a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift index 9a5c9d52..248376c2 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DockerWorkerRuntime.swift @@ -8,12 +8,10 @@ public enum DockerWorkerRuntime: Sendable { public static let crawlerBinary = "/usr/local/bin/derrick-web-crawler" public static let extractorBinary = "/usr/local/bin/derrick-file-extractor" - public static let newsReaderBinary = "/usr/local/bin/derrick-news-reader" /// Binaries that must exist in the unified worker image. public static let requiredBinaries: [String] = [ crawlerBinary, extractorBinary, - newsReaderBinary, ] public static let guestBinaryPath = "/tmp/guest" @@ -30,5 +28,5 @@ public enum DockerWorkerRuntime: Sendable { /// OCI label written by `docker/worker/Dockerfile`; used to detect stale local images. public static let binariesLabelKey = "derrick.worker.binaries" - public static let binariesLabelValue = "crawler,extractor,news-reader" + public static let binariesLabelValue = "crawler,extractor" } diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index b3996871..40413446 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -1205,7 +1205,6 @@ import Testing #expect(DerrickDockerRuntimeIdentity.createLabelArguments == ["--label", "app.derrick=runtime"]) #expect(DerrickDockerRuntimeIdentity.namePrefixes == [ "derrick-web-crawler", - "derrick-news-reader", "derrick-guest-runtime", "derrick-swift-runtime", "derrick-file-extractor", @@ -1486,8 +1485,6 @@ import Testing #expect(PluginFactoryCreateInput.failureStep(forStage: "name") == .skill) #expect(PluginFactoryCreateInput.failureStep(forStage: "auth") == .credentials) #expect(PluginFactoryCreateInput.failureStep(forStage: "discover") == .credentials) - #expect(PluginFactoryCreateInput.failureStep(forStage: "paywall") == .news) - #expect(PluginFactoryCreateInput.failureStep(forStage: "news") == .news) #expect(PluginFactoryCreateInput.failureStep(forStage: "goal") == .goal) } diff --git a/packages/Structure/Tests/StructureTests/NewsPaywallTests.swift b/packages/Structure/Tests/StructureTests/NewsPaywallTests.swift new file mode 100644 index 00000000..0659531a --- /dev/null +++ b/packages/Structure/Tests/StructureTests/NewsPaywallTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +import Structure + +@Suite struct NewsPaywallTests { + @Test func paywallWarningIsUserFacing() { + #expect(NewsPaywall.userWarning.localizedCaseInsensitiveContains("paywall")) + #expect(NewsPaywall.userWarning.localizedCaseInsensitiveContains("not supported")) + } + + @Test func preflightRejectsPaywalledArticleAllowsFeed() { + #expect( + NewsPaywall.preflightRejection( + url: URL(string: "https://www.nytimes.com/2024/01/01/world.html")! + ) != nil + ) + #expect( + NewsPaywall.preflightRejection( + url: URL(string: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml")! + ) == nil + ) + } + + @Test func canonicalFetchURLUpgradesGeneralGoogleNewsRSSForTechHint() { + let url = URL(string: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en")! + let canonical = NewsSourceURL.canonicalFetchURL(url, contextHint: "tech-news Tech") + #expect(canonical.path.contains("/headlines/section/topic/TECHNOLOGY")) + } +} diff --git a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift b/packages/Structure/Tests/StructureTests/NewsReaderTests.swift deleted file mode 100644 index 7077ce03..00000000 --- a/packages/Structure/Tests/StructureTests/NewsReaderTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import Testing -import Structure - -@Suite struct NewsReaderTests { - @Test func paywallWarningIsUserFacing() { - #expect(NewsPaywall.userWarning.localizedCaseInsensitiveContains("paywall")) - #expect(NewsPaywall.userWarning.localizedCaseInsensitiveContains("not supported")) - } - - @Test func preflightRejectsPaywalledArticleAllowsFeed() { - #expect( - NewsPaywall.preflightRejection( - url: URL(string: "https://www.nytimes.com/2024/01/01/world.html")! - ) != nil - ) - #expect( - NewsPaywall.preflightRejection( - url: URL(string: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml")! - ) == nil - ) - } - - @Test func inferNewsModeMapsSummaryCrawlAndRSS() { - let summary = PluginSkillDraft(goal: "Give me a summary of tech news") - #expect(PluginSkillDraftPlanner.inferNewsMode(from: summary) == .summary) - - let crawl = PluginSkillDraft(goal: "Crawl the BBC homepage for headlines") - #expect(PluginSkillDraftPlanner.inferNewsMode(from: crawl) == .list) - - let rss = PluginSkillDraft(goal: "Fetch tech headlines from Google News RSS") - #expect(PluginSkillDraftPlanner.inferNewsMode(from: rss) == .rss) - } - - @Test func legacySummariesModeDecodesToSummary() throws { - struct Wrapper: Decodable { let mode: NewsReaderMode } - let wrapper = try JSONDecoder.service.decode( - Wrapper.self, - from: Data(#"{"mode":"summaries"}"#.utf8) - ) - #expect(wrapper.mode == .summary) - } - - @Test func presetSourcesUsePublicFeedsNotPaywalledArticlePages() { - for preset in NewsPresetSource.allCases { - let url = URL(string: preset.source.url)! - #expect( - NewsPaywall.preflightRejection(url: url) == nil, - "Expected \(preset.source.label) feed to pass paywall preflight" - ) - } - } - - @Test func canonicalFetchURLUpgradesGeneralGoogleNewsRSSForTechHint() { - let url = URL(string: "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en")! - let canonical = NewsSourceURL.canonicalFetchURL(url, contextHint: "tech-news Tech") - #expect(canonical.path.contains("/headlines/section/topic/TECHNOLOGY")) - } - - @Test func workerRequestEncodesModeAndSources() throws { - let request = NewsReaderWorkerRequest( - mode: .rss, - sources: [NewsSource(label: "BBC", url: "https://feeds.bbci.co.uk/news/rss.xml")], - topics: ["Tech"], - maxCount: 10, - contextHint: "tech-news" - ) - let json = try request.encodedJSON() - let decoded = try JSONDecoder.service.decode(NewsReaderWorkerRequest.self, from: json) - #expect(decoded.mode == .rss) - #expect(decoded.sources.count == 1) - #expect(decoded.topics == ["Tech"]) - } -} diff --git a/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift b/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift index 561a0a5b..a2bb3eb3 100644 --- a/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift +++ b/packages/Structure/Tests/StructureTests/PluginSkillDraftTests.swift @@ -3,18 +3,10 @@ import Testing @testable import Structure @Suite struct PluginSkillDraftTests { - @Test func infersSummariesModeFromSummaryGoal() { - var draft = PluginSkillDraft(goal: "Give me a summary of tech headlines") - PluginSkillDraftPlanner.applyGoal(draft.goal, to: &draft, existingPluginIDs: []) - #expect(PluginSkillDraftPlanner.inferNewsMode(from: draft) == .summary) - } - - @Test func infersNewsFromGoal() { + @Test func newsGoalIsCustomCapabilityNotACoreProduct() { var draft = PluginSkillDraft(goal: "Fetch tech news headlines daily") PluginSkillDraftPlanner.applyGoal(draft.goal, to: &draft, existingPluginIDs: []) - #expect(draft.plannedKind == .newsDigest) - #expect(!draft.newsTopics.isEmpty) - #expect(!draft.newsSourceURLs.isEmpty) + #expect(draft.plannedKind == .customCapability) } @Test func infersSlackConnectorFromGoal() { @@ -78,18 +70,6 @@ import Testing #expect(input.pluginID == "summarizer") } - @Test func newsDigestOnlyAllowsChatAndScheduleTriggers() { - let allowed = PluginSkillDraftPlanner.availableTriggers(for: .newsDigest) - #expect(allowed == Set([.chat, .schedule])) - var draft = PluginSkillDraft( - goal: "tech news", - triggers: [.chat, .schedule, .mention, .messaging], - pluginName: "tech-news" - ) - PluginSkillDraftPlanner.sanitizeTriggers(in: &draft) - #expect(draft.triggers == Set([.chat, .schedule])) - } - @Test func messagingConnectorDisallowsScheduleTrigger() { let allowed = PluginSkillDraftPlanner.availableTriggers(for: .messagingConnector) #expect(allowed == Set([.chat, .messaging, .mention])) @@ -101,18 +81,4 @@ import Testing PluginSkillDraftPlanner.sanitizeTriggers(in: &draft) #expect(draft.triggers == Set([.chat, .messaging])) } - - @Test func newsDraftRejectsFactoryInput() { - let draft = PluginSkillDraft( - goal: "news digest", - purpose: "News", - examples: [PluginSkillDraft.Example(userSays: "news", pluginDoes: "fetch")], - pluginName: "news-list", - newsTopics: ["Tech"], - newsSourceURLs: ["https://news.google.com/rss"] - ) - #expect(throws: PluginSkillDraftError.newsUsesReaderPath) { - try PluginFactoryCreateInput.makeFromSkillDraft(draft) - } - } } diff --git a/ui/JobKeepAlive/DaemonModuleBootstrap.swift b/ui/JobKeepAlive/DaemonModuleBootstrap.swift index df0c8d87..85956545 100644 --- a/ui/JobKeepAlive/DaemonModuleBootstrap.swift +++ b/ui/JobKeepAlive/DaemonModuleBootstrap.swift @@ -37,9 +37,6 @@ enum DaemonModuleBootstrap { let tools = try await MCPServiceToolHost.shared.searchTools(query: query, principal: principal) return MCPToolSearchResultDTO(ok: true, tools: tools, message: "ok") } - InProcessServiceBridges.runNewsReader = { requestJSON in - try await MCPServiceToolHost.shared.runNewsReader(requestJSON: requestJSON) - } InProcessServiceBridges.jobLocalProxy = JobServiceExportedObject() InProcessServiceBridges.jobNetworkPreflight = { toolName, argumentsJSON, jobID in let repo = try await JobServiceStore.shared.sharedRepository() diff --git a/ui/JobKeepAlive/DaemonUnifiedXPC.swift b/ui/JobKeepAlive/DaemonUnifiedXPC.swift index 1bd52e6d..4806931a 100644 --- a/ui/JobKeepAlive/DaemonUnifiedXPC.swift +++ b/ui/JobKeepAlive/DaemonUnifiedXPC.swift @@ -234,10 +234,6 @@ final class DaemonUnifiedExportedObject: NSObject, DerrickDaemonServiceXPC, @unc mcp.searchTools(requestJSON: requestJSON, withReply: reply) } - func runNewsReader(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) { - mcp.runNewsReader(requestJSON: requestJSON, withReply: reply) - } - // MARK: - Workflow runtime func startWorkflow(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) { diff --git a/ui/MCPService/MCPServiceExportedObject.swift b/ui/MCPService/MCPServiceExportedObject.swift index d7dc7540..e1a90b7e 100644 --- a/ui/MCPService/MCPServiceExportedObject.swift +++ b/ui/MCPService/MCPServiceExportedObject.swift @@ -177,21 +177,4 @@ final class MCPServiceExportedObject: NSObject, MCPServiceXPC { } } } - - func runNewsReader(requestJSON: NSData, withReply reply: @escaping @Sendable (NSData) -> Void) { - let data = requestJSON as Data - Task { - do { - let result = try await MCPServiceToolHost.shared.runNewsReader(requestJSON: data) - reply((try MCPServiceXPCCodec.encodeNewsReaderRunResult(result)) as NSData) - } catch { - fputs("[MCPService] runNewsReader failed: \(error.localizedDescription)\n", stderr) - let result = NewsReaderRunResult(ok: false, message: error.localizedDescription) - reply( - (try? MCPServiceXPCCodec.encodeNewsReaderRunResult(result)) as NSData? - ?? Data("{}".utf8) as NSData - ) - } - } - } } diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index 7a7e5335..d3aa0aff 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -195,36 +195,6 @@ actor MCPServiceToolHost { return made } - func runNewsReader(requestJSON: Data, timeoutSeconds: Int = 180) async throws -> NewsReaderRunResult { - _ = try await ensureReady() - let executor = NewsReaderDockerExecutor( - executor: MCPServiceDockerHelperRunner.shared.makeStdinCLIExecutor() - ) - let result = try await executor.run(input: requestJSON, timeoutSeconds: timeoutSeconds) - let stderrText = String(decoding: result.stderr, as: UTF8.self) - if result.exitCode != 0 { - let detail = newsReaderFailureDetail(exitCode: result.exitCode, stderr: stderrText) - await MCPServiceStore.shared.log( - level: .error, - message: "news reader failed: \(detail)", - code: "news_reader_failed" - ) - return NewsReaderRunResult( - ok: false, - stdout: result.stdout, - stderr: result.stderr, - message: detail - ) - } - guard !result.stdout.isEmpty else { - return NewsReaderRunResult( - ok: false, - message: "News reader returned no output." - ) - } - return NewsReaderRunResult(ok: true, stdout: result.stdout, stderr: result.stderr) - } - func searchTools(query: String, principal: ServicePrincipal) async throws -> [MCPToolDescriptorDTO] { let client = try await ensureReady().client await MCPServiceStore.shared.log( @@ -388,15 +358,3 @@ private func pluginFactoryFailureDetail(for error: Error) -> String { } return error.localizedDescription } - -private func newsReaderFailureDetail(exitCode: Int32, stderr: String) -> String { - if exitCode == 126 { - return """ - The worker image on this Mac is missing the news reader binary. Quit Derrick completely and reopen it so the worker image can rebuild, then try again. - """ - } - if !stderr.isEmpty { - return stderr - } - return "exit \(exitCode)" -} diff --git a/ui/SharedAgentRuntime/Services/MCPServiceClient.swift b/ui/SharedAgentRuntime/Services/MCPServiceClient.swift index 4f4dbd1e..048f7cec 100644 --- a/ui/SharedAgentRuntime/Services/MCPServiceClient.swift +++ b/ui/SharedAgentRuntime/Services/MCPServiceClient.swift @@ -176,25 +176,6 @@ public final class MCPServiceClient: @unchecked Sendable { } } - public func runNewsReader(requestJSON: Data, timeoutSeconds: Int = 180) async throws -> NewsReaderRunResult { - if DerrickProcessRole.isDaemon, let run = InProcessServiceBridges.runNewsReader { - return try await run(requestJSON) - } - nonisolated(unsafe) let proxy = try remoteProxy() - let payload = requestJSON as NSData - return try await invoke(timeout: MCPToolCallTimeouts.newsReaderNanoseconds) { - try await withCheckedThrowingContinuation { cont in - proxy.runNewsReader(requestJSON: payload) { data in - do { - cont.resume(returning: try MCPServiceXPCCodec.decodeNewsReaderRunResult(data as Data)) - } catch { - cont.resume(throwing: error) - } - } - } - } - } - public func searchTools(principal: ServicePrincipal, query: String = "") async throws -> MCPToolSearchResultDTO { if DerrickProcessRole.isDaemon, let search = InProcessServiceBridges.mcpSearchTools { return try await search(principal, query) diff --git a/ui/ui/Messaging/AppWorkspace.swift b/ui/ui/Messaging/AppWorkspace.swift index 08095538..872c89b9 100644 --- a/ui/ui/Messaging/AppWorkspace.swift +++ b/ui/ui/Messaging/AppWorkspace.swift @@ -4,6 +4,5 @@ enum AppWorkspace: Equatable { case chats case plugins case messaging - case news case debugLogs } diff --git a/ui/ui/News/MCPServiceNewsWorker.swift b/ui/ui/News/MCPServiceNewsWorker.swift deleted file mode 100644 index 54c8277f..00000000 --- a/ui/ui/News/MCPServiceNewsWorker.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import Structure - -/// Runs the Docker news reader in MCPService where the egress proxy can bind. -struct MCPServiceNewsWorker: NewsWorkerRunning { - func run(requestJSON: Data) async throws -> Data { - _ = try await MCPServiceClient.shared.ensureUpAndHealth(retries: 2) - let result = try await MCPServiceClient.shared.runNewsReader(requestJSON: requestJSON) - guard result.ok else { - let detail = result.message.nilIfEmpty - ?? String(decoding: result.stderr, as: UTF8.self).nilIfEmpty - ?? "News reader failed." - throw NewsReaderError.workerUnavailable(detail) - } - return result.stdout - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/ui/ui/News/NewsReaderStore.swift b/ui/ui/News/NewsReaderStore.swift deleted file mode 100644 index dde776d8..00000000 --- a/ui/ui/News/NewsReaderStore.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Combine -import DBRepository -import Foundation -import Structure - -@MainActor -final class NewsReaderStore: ObservableObject { - static let shared = NewsReaderStore() - - @Published private(set) var readers: [NewsReaderSpec] = [] - @Published private(set) var items: [NewsItem] = [] - @Published var selectedReaderID: String? - @Published private(set) var isRefreshing = false - @Published private(set) var lastError: String? - - private var repository: DBRepository? - private let worker: any NewsWorkerRunning - private var summarizer: NewsReaderSummarizer? - - init( - worker: any NewsWorkerRunning = MCPServiceNewsWorker(), - summarizer: NewsReaderSummarizer? = nil - ) { - self.worker = worker - self.summarizer = summarizer - } - - func attachSummarizer(_ settings: LLMModelSettings) { - self.summarizer = NewsReaderSummarizer(settings: settings) - } - - var selectedReader: NewsReaderSpec? { - readers.first { $0.id == selectedReaderID } - } - - func configure(repository: DBRepository, summarizerSettings: LLMModelSettings? = nil) async { - self.repository = repository - if let summarizerSettings { - attachSummarizer(summarizerSettings) - } - await reload() - } - - func reload() async { - guard let repository else { return } - do { - readers = try await repository.listNewsReaders() - if selectedReaderID == nil { - selectedReaderID = readers.first?.id - } - if let id = selectedReaderID { - items = try await repository.listNewsItems(readerID: id) - } else { - items = [] - } - lastError = nil - } catch { - lastError = error.localizedDescription - } - } - - func select(id: String) async { - selectedReaderID = id - await reloadItems() - if let reader = selectedReader, shouldRefreshForSchedule(reader) { - await refreshSelected() - } - } - - @discardableResult - func create(_ spec: NewsReaderSpec) async throws -> NewsReaderSpec { - guard let repository else { - throw NewsReaderError.notReady - } - var next = spec - next.updatedAt = .now - let fetched = try await NewsReaderRefresh.validateAndFetch( - spec: next, - worker: worker, - summarizer: summarizer - ) - next.lastFetchedAt = .now - next.lastError = nil - next.summaryText = fetched.summaryText - try await repository.upsertNewsReader(next) - try await repository.replaceNewsItems(readerID: next.id, items: fetched.items) - selectedReaderID = next.id - self.items = fetched.items - await reload() - return next - } - - func refreshSelected() async { - guard let repository, var reader = selectedReader else { return } - isRefreshing = true - defer { isRefreshing = false } - do { - let fetched = try await NewsReaderRefresh.validateAndFetch( - spec: reader, - worker: worker, - summarizer: summarizer - ) - reader.lastFetchedAt = .now - reader.lastError = nil - reader.updatedAt = .now - reader.summaryText = fetched.summaryText - try await repository.upsertNewsReader(reader) - try await repository.replaceNewsItems(readerID: reader.id, items: fetched.items) - items = fetched.items - lastError = nil - await reload() - } catch { - reader.lastError = error.localizedDescription - reader.updatedAt = .now - try? await repository.upsertNewsReader(reader) - lastError = error.localizedDescription - await reload() - } - } - - func deleteSelected() async { - guard let repository, let id = selectedReaderID else { return } - try? await repository.deleteNewsReader(id: id) - selectedReaderID = nil - await reload() - } - - private func reloadItems() async { - guard let repository, let id = selectedReaderID else { - items = [] - return - } - items = (try? await repository.listNewsItems(readerID: id)) ?? [] - } - - private func shouldRefreshForSchedule(_ reader: NewsReaderSpec) -> Bool { - guard reader.schedule != .off else { return false } - guard let last = reader.lastFetchedAt else { return true } - switch reader.schedule { - case .off: - return false - case .hourly: - return Date().timeIntervalSince(last) >= 3_600 - case .daily: - return Date().timeIntervalSince(last) >= 86_400 - } - } -} diff --git a/ui/ui/News/NewsReaderSummarizer.swift b/ui/ui/News/NewsReaderSummarizer.swift deleted file mode 100644 index e0b04047..00000000 --- a/ui/ui/News/NewsReaderSummarizer.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import LLMAgentClient -import Structure - -struct NewsReaderSummarizer: NewsSummaryGenerating { - let settings: LLMModelSettings - - func summarize(listName: String, topics: [String], articles: [NewsItem]) async throws -> String { - let model = await MainActor.run { settings.summarizerModel } - guard let apiKey = await LLMProviderCredentialGate.resolveAPIKey(for: model) else { - throw NewsReaderError.summarizerUnavailable - } - - let prompt = Self.prompt(listName: listName, topics: topics, articles: articles) - let request = AgentRequest.prompt( - prompt, - system: """ - You summarize news for the user. Write clear prose in plain English. - Use bullet points. Include markdown links to the original articles using the URLs provided. - Do not add a title or markdown heading for the list name. - Cover every article provided when possible. - Do not invent stories or URLs. - """, - temperature: 0.2 - ) - let text: String - switch model { - case .gemini(let geminiModel): - let client = GeminiAgentClient(provider: GeminiProvider(apiKey: apiKey)) - text = try await collect(client.stream(request, model: geminiModel)) - case .openai(let openAIModel): - let client = OpenAIAgentClient(provider: OpenAIProvider(apiKey: apiKey)) - text = try await collect(client.stream(request, model: openAIModel)) - } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw NewsReaderError.fetchFailed(url: listName, detail: "Summarizer returned an empty response.") - } - return trimmed - } - - private func collect(_ stream: AsyncThrowingStream) async throws -> String { - let (text, usage) = try await collectAgentStream(stream) - if let usage { - _ = await UsageLimitsService.shared.recordAPIUsage(usage) - } - return text - } - - private static func prompt(listName: String, topics: [String], articles: [NewsItem]) -> String { - var lines = [ - "Summarize these articles for the list \"\(listName)\".", - ] - if !topics.isEmpty { - lines.append("Topics: \(topics.joined(separator: ", "))") - } - lines.append("Articles:") - for article in articles.prefix(12) { - var entry = "- \(article.title) (\(article.sourceURL))" - if let detail = article.summary, !detail.isEmpty { - entry += "\n \(detail)" - } - lines.append(entry) - } - return lines.joined(separator: "\n") - } -} diff --git a/ui/ui/News/NewsWorkspaceView.swift b/ui/ui/News/NewsWorkspaceView.swift deleted file mode 100644 index 82285709..00000000 --- a/ui/ui/News/NewsWorkspaceView.swift +++ /dev/null @@ -1,171 +0,0 @@ -import SwiftUI -import Structure - -struct NewsWorkspaceView: View { - @ObservedObject var store: NewsReaderStore - - var body: some View { - VStack(spacing: 0) { - header - Divider() - if store.selectedReader == nil { - emptyState - } else { - itemList - } - } - .background(Color(red: 252.0 / 255.0, green: 252.0 / 255.0, blue: 250.0 / 255.0)) - } - - private var header: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(store.selectedReader?.name ?? "News") - .font(.headline) - if let reader = store.selectedReader { - Text(headerSubtitle(reader)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - if store.selectedReader != nil { - Button { - Task { await store.refreshSelected() } - } label: { - if store.isRefreshing { - ProgressView() - .controlSize(.small) - } else { - Text("Refresh") - } - } - .disabled(store.isRefreshing) - .buttonStyle(.bordered) - } - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - } - - private var emptyState: some View { - VStack(spacing: 10) { - Spacer() - Image(systemName: "newspaper") - .font(.system(size: 36)) - .foregroundStyle(.secondary) - Text("No news lists yet") - .font(.title3.weight(.semibold)) - Text("Create a news list from Plugins. Derrick fetches articles in Docker, then shows them here.") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var itemList: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let error = store.selectedReader?.lastError ?? store.lastError, !error.isEmpty { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) - } - - if store.selectedReader?.mode == .summary, - let summary = store.selectedReader?.summaryText, - !summary.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("AI summary") - .font(.subheadline.weight(.semibold)) - Text("A digest of the articles below.") - .font(.caption) - .foregroundStyle(.secondary) - MarkdownResponseView(text: summary, allowsCSVExport: false) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) - } - - if store.selectedReader?.mode != .summary { - ForEach(store.items) { item in - articleCard(item) - } - } else if !store.items.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Articles (\(store.items.count))") - .font(.subheadline.weight(.semibold)) - Text("Stories fetched from your feeds. The header feed count is how many RSS sources are configured, not how many articles appear here.") - .font(.caption) - .foregroundStyle(.secondary) - ForEach(store.items) { item in - HStack(alignment: .top, spacing: 8) { - Text("•") - VStack(alignment: .leading, spacing: 2) { - Link(item.title, destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) - .font(.caption.weight(.semibold)) - Text(item.sourceLabel) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) - } - - if store.items.isEmpty, store.lastError == nil, store.selectedReader?.lastError == nil { - Text("No articles yet. Refresh this list.") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(24) - } - } - - @ViewBuilder - private func articleCard(_ item: NewsItem) -> some View { - VStack(alignment: .leading, spacing: 6) { - Link(destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) { - Text(item.title) - .font(.body.weight(.semibold)) - .multilineTextAlignment(.leading) - } - HStack(spacing: 8) { - Text(item.sourceLabel) - .font(.caption) - .foregroundStyle(.secondary) - if let host = URL(string: item.sourceURL)?.host { - Link(host, destination: URL(string: item.sourceURL) ?? URL(string: "https://example.com")!) - .font(.caption.weight(.semibold)) - .lineLimit(1) - } - } - if let summary = item.summary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(4) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) - } - - private func headerSubtitle(_ reader: NewsReaderSpec) -> String { - let topics = reader.topics.isEmpty ? "All topics" : reader.topics.joined(separator: ", ") - let sources = "\(reader.sources.count) source\(reader.sources.count == 1 ? "" : "s")" - return "\(topics) · \(sources) · \(reader.mode.displayName) · \(reader.schedule.displayName)" - } -} diff --git a/ui/ui/Plugins/PluginCreationController.swift b/ui/ui/Plugins/PluginCreationController.swift index fb654916..c4228f2a 100644 --- a/ui/ui/Plugins/PluginCreationController.swift +++ b/ui/ui/Plugins/PluginCreationController.swift @@ -20,7 +20,6 @@ final class PluginCreationController: ObservableObject { enum SuccessOutcome: Equatable { case plugin - case newsList } struct ProgressStepState: Identifiable, Equatable { @@ -70,18 +69,12 @@ final class PluginCreationController: ObservableObject { !$0.userSays.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !$0.pluginDoes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else { return false } - if skillDraft.plannedKind == .newsDigest { - return !skillDraft.newsTopics.isEmpty && !skillDraft.newsSourceURLs.isEmpty - } return skillDraft.buildBlockedReason == nil } var canConfirmPluginName: Bool { let trimmed = skillDraft.pluginName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } - if skillDraft.plannedKind == .newsDigest { - return true - } return (try? PluginID.normalized(trimmed)) != nil } @@ -188,48 +181,6 @@ final class PluginCreationController: ObservableObject { } } - func addNewsTopic() { - let topic = skillDraft.goal.trimmingCharacters(in: .whitespacesAndNewlines) - guard !topic.isEmpty else { return } - mutateSkillDraft { draft in - if !draft.newsTopics.contains(where: { $0.compare(topic, options: .caseInsensitive) == .orderedSame }) { - draft.newsTopics.append(topic) - } - } - } - - func removeNewsTopic(_ topic: String) { - mutateSkillDraft { $0.newsTopics.removeAll { $0 == topic } } - } - - func addNewsTopicFromPreset(_ topic: String) { - let trimmed = topic.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - mutateSkillDraft { draft in - if !draft.newsTopics.contains(trimmed) { - draft.newsTopics.append(trimmed) - } - } - } - - func addNewsURL(_ raw: String) { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let normalized = NewsSourceURL.canonicalFetchURL( - URL(string: trimmed.contains("://") ? trimmed : "https://\(trimmed)") - ?? URL(string: "https://news.google.com/rss")! - ).absoluteString - mutateSkillDraft { draft in - if !draft.newsSourceURLs.contains(normalized) { - draft.newsSourceURLs.append(normalized) - } - } - } - - func removeNewsURL(_ url: String) { - mutateSkillDraft { $0.newsSourceURLs.removeAll { $0 == url } } - } - private func mutateSkillDraft(_ transform: (inout PluginSkillDraft) -> Void) { var draft = skillDraft transform(&draft) @@ -245,11 +196,6 @@ final class PluginCreationController: ObservableObject { creationAPIKey = helperAPIKey creationReviewerModelJSON = helperReviewerModelJSON - if skillDraft.plannedKind == .newsDigest { - startNewsCreation() - return - } - if skillDraft.plannedKind == .messagingConnector { phase = .discoveringAuth statusMessage = "Reading how this service authenticates…" @@ -284,7 +230,7 @@ final class PluginCreationController: ObservableObject { case .failed(let step, _, _): switch step { case .goal: phase = .goal - case .skill, .news: phase = .skill + case .skill: phase = .skill case .preview: phase = .preview case .credentials: phase = .preview case .build: phase = .preview @@ -298,66 +244,6 @@ final class PluginCreationController: ObservableObject { showIntro() } - private func startNewsCreation() { - let sources = skillDraft.newsSourceURLs.map { url in - NewsSource(label: URL(string: url)?.host ?? url, url: url) - } - let spec = NewsReaderSpec( - name: skillDraft.pluginName, - topics: skillDraft.newsTopics, - sources: sources, - mode: PluginSkillDraftPlanner.inferNewsMode(from: skillDraft), - maxCount: 20, - schedule: .off - ) - if let blocked = spec.sources.compactMap({ source -> (NewsSource, String)? in - guard let url = URL(string: source.url), - let reason = NewsPaywall.preflightRejection(url: url) else { return nil } - return (source, reason) - }).first { - phase = .failed( - step: .news, - message: NewsReaderError.paywalled(url: blocked.0.url, detail: blocked.1).errorDescription - ?? "This source is behind a paywall, which is not supported yet." - ) - return - } - phase = .creating - statusMessage = "Starting news reader in Docker…" - progressSteps = [ - ProgressStepState(id: "skill", title: "Write SKILL.md", status: .completed), - ProgressStepState(id: "sources", title: "Check sources", status: .active), - ProgressStepState(id: "fetch", title: "Run news reader", status: .pending), - ] - if spec.mode == .summary { - progressSteps.append( - ProgressStepState(id: "summary", title: "Summarize with AI", status: .pending) - ) - } - pollTask?.cancel() - pollTask = Task { @MainActor in - do { - let saved = try await NewsReaderStore.shared.create(spec) - setProgressStep("sources", status: .completed) - setProgressStep("fetch", status: .completed) - if spec.mode == .summary { - setProgressStep("summary", status: .completed) - } - phase = .succeeded(pluginID: saved.id, outcome: .newsList) - } catch let error as NewsReaderError { - setProgressStep("sources", status: .failed) - phase = .failed( - step: .news, - message: error.localizedDescription, - technicalDetail: String(describing: error) - ) - } catch { - setProgressStep("sources", status: .failed) - phase = .failed(step: .news, message: error.localizedDescription) - } - } - } - private func startFactoryCreation() { guard let creationAPIKey, !creationAPIKey.isEmpty else { phase = .failed( diff --git a/ui/ui/Plugins/PluginsWorkspaceView.swift b/ui/ui/Plugins/PluginsWorkspaceView.swift index 11467e67..a3e8a3b3 100644 --- a/ui/ui/Plugins/PluginsWorkspaceView.swift +++ b/ui/ui/Plugins/PluginsWorkspaceView.swift @@ -8,10 +8,6 @@ struct PluginsWorkspaceView: View { let helperReviewerModelJSON: String? let sessionID: String let onOpenMessagingConnector: (String) -> Void - var onOpenNewsReader: (String) -> Void = { _ in } - - @State private var newsTopicDraft = "" - @State private var newsURLDraft = "" var body: some View { ZStack { @@ -85,22 +81,18 @@ struct PluginsWorkspaceView: View { case .collectCredentials: return "Plugin credentials" case .failed: return failureTitle case .succeeded(_, let outcome): - return outcome == .newsList ? "News list ready" : "Plugin ready" + return "Plugin ready" } } private var creatingTitle: String { switch controller.skillDraft.plannedKind { - case .newsDigest: return "Creating news list" case .messagingConnector: return "Creating connector" case .customCapability: return "Building plugin" } } private var failureTitle: String { - if case .failed(let step, _, _) = controller.phase, step == .news { - return "Could not create news list" - } return "Could not create plugin" } @@ -252,12 +244,6 @@ struct PluginsWorkspaceView: View { } Spacer() switch outcome { - case .newsList: - Button("Open news list") { - onOpenNewsReader(pluginID) - } - .buttonStyle(ModalPrimaryButtonStyle()) - .keyboardShortcut(.defaultAction) case .plugin where controller.skillDraft.plannedKind == .messagingConnector: Button("Open connector") { onOpenMessagingConnector(pluginID) @@ -275,10 +261,7 @@ struct PluginsWorkspaceView: View { } private var buildButtonTitle: String { - switch controller.skillDraft.plannedKind { - case .newsDigest: return "Create" - default: return "Build plugin" - } + "Build plugin" } private var skillBuilderForm: some View { @@ -308,10 +291,6 @@ struct PluginsWorkspaceView: View { examplesSection - if controller.skillDraft.plannedKind == .newsDigest { - newsFieldsSection - } - nameFieldSection if let blocked = controller.skillDraft.buildBlockedReason { @@ -339,8 +318,6 @@ struct PluginsWorkspaceView: View { case .messagingConnector: let vendor = controller.skillDraft.inferredConnectorVendor?.displayName ?? "Messaging" return ("\(vendor) connector", "bubble.left.and.bubble.right") - case .newsDigest: - return ("News list", "newspaper") case .customCapability: return ("Custom capability", "wand.and.stars") } @@ -395,98 +372,6 @@ struct PluginsWorkspaceView: View { ) } - private var newsFieldsSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Topics") - .font(.caption) - .foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 110), spacing: 8)], spacing: 8) { - ForEach(NewsPresetTopic.allCases) { topic in - let on = controller.skillDraft.newsTopics.contains(topic.displayName) - Button { - if on { - controller.removeNewsTopic(topic.displayName) - } else if !controller.skillDraft.newsTopics.contains(topic.displayName) { - controller.addNewsTopicFromPreset(topic.displayName) - } - } label: { - Text(topic.displayName) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - HStack { - TextField("Add a custom topic", text: $newsTopicDraft) - .textFieldStyle(.roundedBorder) - .onSubmit { addNewsTopicFromDraft() } - Button("Add") { addNewsTopicFromDraft() } - } - Text("Sources") - .font(.caption) - .foregroundStyle(.secondary) - LazyVGrid(columns: [GridItem(.adaptive(minimum: 130), spacing: 8)], spacing: 8) { - ForEach(NewsPresetSource.allCases) { source in - let url = source.source.url - let on = controller.skillDraft.newsSourceURLs.contains(url) - Button { - if on { - controller.removeNewsURL(url) - } else { - controller.addNewsURL(url) - } - } label: { - Text(source.source.label) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(on ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - HStack { - TextField("https://…", text: $newsURLDraft) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("news-url-field") - .onSubmit { addNewsURLFromDraft() } - Button("Add URL") { addNewsURLFromDraft() } - } - ForEach(controller.skillDraft.newsSourceURLs, id: \.self) { url in - HStack { - Text(url) - .font(.caption) - .lineLimit(1) - Spacer() - Button("Remove") { controller.removeNewsURL(url) } - .font(.caption) - } - } - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text(NewsPaywall.userWarning) - .fixedSize(horizontal: false, vertical: true) - } - .font(.caption) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) - } - } - - private func addNewsTopicFromDraft() { - let topic = newsTopicDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !topic.isEmpty else { return } - controller.addNewsTopicFromPreset(topic) - newsTopicDraft = "" - } - private func triggerChip(_ trigger: PluginSkillDraft.Trigger) -> some View { let available = controller.skillDraft.isTriggerAvailable(trigger) let on = available && controller.skillDraft.triggers.contains(trigger) @@ -517,15 +402,6 @@ struct PluginsWorkspaceView: View { ) -> String { guard !available else { return "" } switch controller.skillDraft.plannedKind { - case .newsDigest: - switch trigger { - case .mention: - return "News lists live in the sidebar, not as /slash commands." - case .messaging: - return "News lists are not messaging connectors." - default: - return "Not available for news lists." - } case .messagingConnector: switch trigger { case .schedule: @@ -544,19 +420,14 @@ struct PluginsWorkspaceView: View { } private var nameFieldSection: some View { - let isNews = controller.skillDraft.plannedKind == .newsDigest - return VStack(alignment: .leading, spacing: 6) { - Text(isNews ? "List name" : "Plugin name") + VStack(alignment: .leading, spacing: 6) { + Text("Plugin name") .font(.caption) .foregroundStyle(.secondary) - TextField(isNews ? "List name" : "Plugin name", text: controller.skillDraftBinding(\.pluginName)) + TextField("Plugin name", text: controller.skillDraftBinding(\.pluginName)) .textFieldStyle(.roundedBorder) .accessibilityIdentifier("connector-plugin-name") - if isNews { - Text("This name appears in the sidebar. News lists are not slash commands.") - .font(.caption2) - .foregroundStyle(.tertiary) - } else if controller.canConfirmPluginName { + if controller.canConfirmPluginName { Text("Invoke this plugin in chat as /\(normalizedPluginID()).") .font(.caption2) .foregroundStyle(.tertiary) @@ -576,11 +447,6 @@ struct PluginsWorkspaceView: View { return trimmed.isEmpty ? "plugin" : trimmed } - private func addNewsURLFromDraft() { - controller.addNewsURL(newsURLDraft) - newsURLDraft = "" - } - private var previewForm: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -588,14 +454,6 @@ struct PluginsWorkspaceView: View { nameFieldSection - if controller.skillDraft.plannedKind == .newsDigest { - Text( - "Display mode: \(PluginSkillDraftPlanner.inferNewsMode(from: controller.skillDraft).displayName)" - ) - .font(.caption) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 6) { Text("Scenarios") .font(.caption) @@ -637,23 +495,12 @@ struct PluginsWorkspaceView: View { @ViewBuilder private func failureBody(message: String, technicalDetail: String?) -> some View { VStack(alignment: .leading, spacing: 10) { - if case .failed(let step, _, _) = controller.phase, step == .news { - Label( - message.localizedCaseInsensitiveContains("paywall") - ? "Blocked because of a paywall" - : "News list was not created", - systemImage: "exclamationmark.triangle.fill" - ) + Label("Nothing was installed", systemImage: "minus.circle") .font(.subheadline.weight(.semibold)) - .foregroundStyle(message.localizedCaseInsensitiveContains("paywall") ? Color.orange : Color.secondary) - } else { - Label("Nothing was installed", systemImage: "minus.circle") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - Text("Your sidebar and Messaging are unchanged.") - .font(.caption) - .foregroundStyle(.secondary) - } + .foregroundStyle(.secondary) + Text("Your sidebar and Messaging are unchanged.") + .font(.caption) + .foregroundStyle(.secondary) Text(message) .font(.body) .fixedSize(horizontal: false, vertical: true) @@ -673,10 +520,6 @@ struct PluginsWorkspaceView: View { @ViewBuilder private func successBody(pluginID: String, outcome: PluginCreationController.SuccessOutcome) -> some View { switch outcome { - case .newsList: - Text("Your news list is ready. Every article includes a source link.") - .font(.body) - .fixedSize(horizontal: false, vertical: true) case .plugin: if controller.skillDraft.plannedKind == .messagingConnector { Text("Your connector /\(pluginID) is ready. Open it to start talking in Messaging.") diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 9254ea3d..46013e34 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -234,7 +234,6 @@ struct ContentView: View { @ObservedObject private var bootstrapStatus = AppBootstrapStatus.shared @StateObject private var chatSessions = ChatSessionStore() @StateObject private var messaging = MessagingStore() - @ObservedObject private var news = NewsReaderStore.shared @State private var workspace: AppWorkspace = .chats private var secretStore: SecretStore { @@ -389,7 +388,6 @@ struct ContentView: View { modelThinkingSettings: modelThinkingSettings ?? LLMModelThinkingSettings(repository: helperModelSettings.settingsRepository), chatSessions: chatSessions, messaging: messaging, - news: news, workspace: $workspace, isDebugEnabled: isDebugEnabled ) @@ -403,14 +401,12 @@ struct ContentView: View { VStack(spacing: 0) { if workspace == .messaging { MessagingTabBarView(store: messaging) - } else if workspace != .debugLogs && workspace != .plugins && workspace != .news { + } else if workspace != .debugLogs && workspace != .plugins { ChatTabBarView(store: chatSessions) } switch workspace { case .messaging: MessagingConversationView(store: messaging) - case .news: - NewsWorkspaceView(store: news) case .debugLogs: DebugLogsView(repository: repository) case .plugins: @@ -428,13 +424,6 @@ struct ContentView: View { } Task { await pluginFactoryList.reload() } } - }, - onOpenNewsReader: { readerID in - Task { @MainActor in - workspace = .news - await news.select(id: readerID) - pluginCreationController.dismissSuccess() - } } ) default: @@ -846,10 +835,6 @@ struct ContentView: View { await ContainerLifecycleSettingsService.shared.configure(repository: repository) await OrchestrationLimitsSettingsService.shared.configure(repository: repository) await PluginFactoryListStore.shared.configure(repository: repository) - await NewsReaderStore.shared.configure( - repository: repository, - summarizerSettings: helperModelSettings - ) await AgentProfileStore.shared.configure(repository: repository) pluginCreationController.configure(repository: repository) } diff --git a/ui/ui/Views/SidebarView.swift b/ui/ui/Views/SidebarView.swift index 20b340f4..b719f13d 100644 --- a/ui/ui/Views/SidebarView.swift +++ b/ui/ui/Views/SidebarView.swift @@ -10,7 +10,6 @@ struct SidebarView: View { @ObservedObject var modelThinkingSettings: LLMModelThinkingSettings @ObservedObject var chatSessions: ChatSessionStore @ObservedObject var messaging: MessagingStore - @ObservedObject var news: NewsReaderStore @Binding var workspace: AppWorkspace var isDebugEnabled: Bool = false /// Reference type must not be recreated every `View` value; hold via `@State`. @@ -81,17 +80,6 @@ struct SidebarView: View { workspace = .messaging Task { await messaging.syncConnectorsFromFactory() } } - SidebarActionRow( - row: SidebarRow( - id: "news", - icon: "newspaper.fill", - title: "News", - isProminent: workspace == .news - ) - ) { - workspace = .news - Task { await news.reload() } - } if isDebugEnabled { SidebarActionRow( row: SidebarRow( @@ -110,8 +98,6 @@ struct SidebarView: View { pluginsList } else if workspace == .messaging { messagingList - } else if workspace == .news { - newsList } else if workspace == .debugLogs { debugLogsHint } else { @@ -289,46 +275,6 @@ struct SidebarView: View { } } - private var newsList: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Saved lists") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.top, 4) - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - if news.readers.isEmpty { - Text("No news lists yet") - .font(.system(size: sideMenuRecentsFontSize)) - .foregroundStyle(.secondary) - } else { - ForEach(news.readers) { reader in - Button { - workspace = .news - Task { await news.select(id: reader.id) } - } label: { - Text(reader.name) - .font(.system(size: sideMenuRecentsFontSize)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle( - news.selectedReaderID == reader.id - ? Color.primary - : Color.primary.opacity(0.9) - ) - } - .buttonStyle(.plain) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - private var pluginsList: some View { VStack(alignment: .leading, spacing: 8) { ScrollView { @@ -439,7 +385,6 @@ struct SidebarView: View { modelThinkingSettings: LLMModelThinkingSettings(repository: repo), chatSessions: store, messaging: MessagingStore(), - news: NewsReaderStore.shared, workspace: .constant(.chats) ) } diff --git a/ui/uiTests/NewsReaderFlowTests.swift b/ui/uiTests/NewsReaderFlowTests.swift deleted file mode 100644 index 6ee53646..00000000 --- a/ui/uiTests/NewsReaderFlowTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -import Testing -import Structure - -@Suite struct NewsReaderFlowTests { - @Test func skillDraftForTechNewsSummaryRequest() { - var draft = PluginSkillDraft() - PluginSkillDraftPlanner.applyGoal( - "Give me summaries of today's tech news", - to: &draft, - existingPluginIDs: [] - ) - draft.pluginName = "tech-news" - draft.newsSourceURLs = [ - NewsPresetSource.googleNews.source.url, - NewsPresetSource.wsj.source.url, - ] - draft.newsTopics = ["Tech"] - draft.examples = [ - PluginSkillDraft.Example( - userSays: "Give me summaries of tech news", - pluginDoes: "fetch headlines and summarize them with source links" - ), - ] - - #expect(draft.plannedKind == .newsDigest) - #expect(PluginSkillDraftPlanner.inferNewsMode(from: draft) == .summary) - #expect(draft.pluginName == "tech-news") - #expect(draft.newsSourceURLs.contains(NewsPresetSource.googleNews.source.url)) - #expect(draft.newsSourceURLs.contains(NewsPresetSource.wsj.source.url)) - } - - @Test func newsReaderWorkerRequestMatchesWizardSpec() throws { - let sources = [ - NewsPresetSource.googleNews.source, - NewsPresetSource.wsj.source, - ] - let spec = NewsReaderSpec( - name: "tech-news", - topics: ["Tech"], - sources: sources, - mode: .summary, - maxCount: 20, - schedule: .off - ) - let request = NewsReaderWorkerRequest( - mode: spec.mode, - sources: spec.sources, - topics: spec.topics, - maxCount: spec.maxCount, - contextHint: spec.name - ) - let json = try request.encodedJSON() - let object = try JSONSerialization.jsonObject(with: json) as? [String: Any] - #expect(object?["mode"] as? String == "summary") - #expect((object?["sources"] as? [[String: Any]])?.count == 2) - } -} diff --git a/workers/go/cmd/derrick-news-reader/main.go b/workers/go/cmd/derrick-news-reader/main.go deleted file mode 100644 index a078c1da..00000000 --- a/workers/go/cmd/derrick-news-reader/main.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "io" - "os" - "strconv" - - "github.com/jsoneaday/derrick/workers/internal/contract" - "github.com/jsoneaday/derrick/workers/internal/newsreader" -) - -func main() { - input, err := io.ReadAll(os.Stdin) - if err != nil { - writeResult(blockedResult(newsreader.ModeRSS, "News reader input must be a valid JSON object.")) - return - } - - var req newsreader.Request - if err := json.Unmarshal(input, &req); err != nil { - writeResult(blockedResult(newsreader.ModeRSS, "News reader input must be a valid JSON object.")) - return - } - - validated, err := newsreader.Validate(req) - if err != nil { - writeResult(blockedResult(req.Mode, err.Error())) - return - } - - result := newsreader.Run(context.Background(), validated, readProxy()) - writeResult(result) -} - -func readProxy() *newsreader.ProxyConfig { - host := os.Getenv("DERRICK_EGRESS_PROXY_HOST") - portStr := os.Getenv("DERRICK_EGRESS_PROXY_PORT") - token := os.Getenv("DERRICK_EGRESS_PROXY_TOKEN") - if host == "" && portStr == "" && token == "" { - return nil - } - port, _ := strconv.Atoi(portStr) - return &newsreader.ProxyConfig{Host: host, Port: port, Token: token} -} - -func blockedResult(mode newsreader.Mode, message string) newsreader.Result { - if mode == "" { - mode = newsreader.ModeRSS - } - return newsreader.Result{ - OK: false, - Mode: mode, - Articles: []newsreader.Article{}, - Diagnostics: []string{message}, - } -} - -func writeResult(result newsreader.Result) { - payload, err := encodedResult(result) - if err != nil { - writeEncodedResult(blockedResult(result.Mode, "News reader output violated worker contract.")) - return - } - _, _ = os.Stdout.Write(payload) -} - -func encodedResult(result newsreader.Result) ([]byte, error) { - if result.Articles == nil { - result.Articles = []newsreader.Article{} - } - if result.Diagnostics == nil { - result.Diagnostics = []string{} - } - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(result); err != nil { - return nil, err - } - data := bytes.TrimSpace(buf.Bytes()) - if err := contract.ValidateNewsReaderResultJSON(data); err != nil { - return nil, err - } - return append(data, '\n'), nil -} - -func writeEncodedResult(result newsreader.Result) { - payload, err := encodedResult(result) - if err != nil { - fallback := blockedResult(newsreader.ModeRSS, "News reader failed to produce schema-compliant output.") - payload, _ = encodedResult(fallback) - } - _, _ = os.Stdout.Write(payload) -} diff --git a/workers/go/internal/contract/contract.go b/workers/go/internal/contract/contract.go index e36ecc36..ba341c0b 100644 --- a/workers/go/internal/contract/contract.go +++ b/workers/go/internal/contract/contract.go @@ -34,11 +34,6 @@ func ValidateFileExtractorResultJSON(data []byte) error { return validate("file-extractor-result.schema.json", data) } -// ValidateNewsReaderResultJSON checks news reader stdout against news-reader-result.schema.json. -func ValidateNewsReaderResultJSON(data []byte) error { - return validate("news-reader-result.schema.json", data) -} - func validate(schemaName string, data []byte) error { compiler := jsonschema.NewCompiler() if err := loadSchemas(compiler); err != nil { diff --git a/workers/go/internal/contract/schemas/news-reader-result.schema.json b/workers/go/internal/contract/schemas/news-reader-result.schema.json deleted file mode 100644 index 6632851d..00000000 --- a/workers/go/internal/contract/schemas/news-reader-result.schema.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://derrick.local/schemas/news-reader-result.json", - "title": "News reader worker stdout", - "description": "JSON object written to stdout by derrick-news-reader.", - "$ref": "worker-product.schema.json#/$defs/news_reader_result" -} diff --git a/workers/go/internal/contract/schemas/worker-product.schema.json b/workers/go/internal/contract/schemas/worker-product.schema.json index 611ef5fe..874f6bfb 100644 --- a/workers/go/internal/contract/schemas/worker-product.schema.json +++ b/workers/go/internal/contract/schemas/worker-product.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://derrick.local/schemas/worker-product.json", "title": "Derrick trusted worker product contracts", - "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler, file extractor, and news reader). Swift host and Go workers must match these schemas.", + "description": "Shared stdout JSON contracts for prebuilt Go worker binaries (web crawler and file extractor). Swift host and Go workers must match these schemas.", "$defs": { "string_list": { "type": "array", @@ -92,35 +92,6 @@ }, "diagnostics": { "$ref": "#/$defs/string_list" } } - }, - "news_reader_mode": { - "type": "string", - "enum": ["rss", "list", "summary"] - }, - "news_reader_article": { - "type": "object", - "required": ["title", "url"], - "additionalProperties": false, - "properties": { - "title": { "type": "string" }, - "url": { "type": "string" }, - "detail": { "type": "string" }, - "published_at": { "type": "string" } - } - }, - "news_reader_result": { - "type": "object", - "required": ["ok", "mode", "articles", "diagnostics"], - "additionalProperties": false, - "properties": { - "ok": { "type": "boolean" }, - "mode": { "$ref": "#/$defs/news_reader_mode" }, - "articles": { - "type": "array", - "items": { "$ref": "#/$defs/news_reader_article" } - }, - "diagnostics": { "$ref": "#/$defs/string_list" } - } } } } diff --git a/workers/go/internal/newsreader/engine.go b/workers/go/internal/newsreader/engine.go deleted file mode 100644 index 74e60814..00000000 --- a/workers/go/internal/newsreader/engine.go +++ /dev/null @@ -1,34 +0,0 @@ -package newsreader - -import ( - "context" -) - -func Run(ctx context.Context, req Request, proxy *ProxyConfig) Result { - client := newHTTPClient(proxy) - collected := make([]Article, 0, req.MaxCount) - diagnostics := make([]string, 0) - - for _, source := range req.Sources { - articles, notes := fetchSource(ctx, client, source, req.Mode, req.ContextHint) - diagnostics = append(diagnostics, notes...) - collected = append(collected, articles...) - } - - filtered := filterTopics(collected, req.Topics) - unique := uniqueArticles(filtered) - if len(unique) > req.MaxCount { - unique = unique[:req.MaxCount] - } - - ok := len(unique) > 0 - if !ok && len(diagnostics) == 0 { - diagnostics = append(diagnostics, "No articles were found for the configured sources.") - } - return Result{ - OK: ok, - Mode: req.Mode, - Articles: unique, - Diagnostics: diagnostics, - } -} diff --git a/workers/go/internal/newsreader/fetch.go b/workers/go/internal/newsreader/fetch.go deleted file mode 100644 index 7c2b047d..00000000 --- a/workers/go/internal/newsreader/fetch.go +++ /dev/null @@ -1,127 +0,0 @@ -package newsreader - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -func fetchSource( - ctx context.Context, - client *http.Client, - source Source, - mode Mode, - contextHint string, -) ([]Article, []string) { - fetchURL := CanonicalFetchURL(source.URL, contextHint) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) - if err != nil { - return nil, []string{fmt.Sprintf("%s: %v", source.Label, err)} - } - req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DerrickNewsReader/1.0)") - req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, text/html;q=0.8") - - resp, err := client.Do(req) - if err != nil { - return nil, []string{fmt.Sprintf("Could not read %s: %v", source.URL, err)} - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 400 { - return nil, []string{fmt.Sprintf("Could not read %s: HTTP %d", source.URL, resp.StatusCode)} - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) - if err != nil { - return nil, []string{fmt.Sprintf("Could not read %s: %v", source.URL, err)} - } - text := string(body) - if looksLikeFeed(text) { - articles := parseFeed(text, source.Label) - if len(articles) > 0 { - return articles, nil - } - } - if mode == ModeRSS { - articles := parseFeed(text, source.Label) - if len(articles) > 0 { - return articles, nil - } - return nil, []string{fmt.Sprintf("No articles were found in %s", source.URL)} - } - if mode == ModeList || mode == ModeSummary { - articles := parseHTMLArticles(text, fetchURL, source.Label) - if len(articles) > 0 { - return articles, nil - } - return nil, []string{fmt.Sprintf("No articles were found on %s", source.URL)} - } - return nil, []string{fmt.Sprintf("No articles were found in %s", source.URL)} -} - -func newHTTPClient(proxy *ProxyConfig) *http.Client { - transport := http.DefaultTransport.(*http.Transport).Clone() - return &http.Client{ - Timeout: time.Duration(DefaultTimeoutSeconds) * time.Second, - Transport: transport, - } -} - -func filterTopics(articles []Article, topics []string) []Article { - needles := make([]string, 0, len(topics)) - for _, topic := range topics { - trimmed := strings.ToLower(strings.TrimSpace(topic)) - if trimmed != "" { - needles = append(needles, trimmed) - } - } - if len(needles) == 0 { - return articles - } - matched := make([]Article, 0, len(articles)) - unmatched := make([]Article, 0, len(articles)) - for _, article := range articles { - hay := strings.ToLower(article.Title + " " + article.Detail) - if topicMatches(hay, needles) { - matched = append(matched, article) - } else { - unmatched = append(unmatched, article) - } - } - if len(matched) == 0 { - return articles - } - // Prefer topic matches, but keep other articles when filtering would drop too many - // (for example WSJ headlines that do not literally contain "tech"). - if len(matched) < 3 && len(unmatched) > 0 { - out := make([]Article, 0, len(articles)) - out = append(out, matched...) - out = append(out, unmatched...) - return out - } - return matched -} - -func topicMatches(hay string, needles []string) bool { - for _, needle := range needles { - if strings.Contains(hay, needle) { - return true - } - } - return false -} - -func uniqueArticles(articles []Article) []Article { - seen := map[string]bool{} - out := make([]Article, 0, len(articles)) - for _, article := range articles { - if seen[article.URL] { - continue - } - seen[article.URL] = true - out = append(out, article) - } - return out -} diff --git a/workers/go/internal/newsreader/list.go b/workers/go/internal/newsreader/list.go deleted file mode 100644 index 9d3c3735..00000000 --- a/workers/go/internal/newsreader/list.go +++ /dev/null @@ -1,88 +0,0 @@ -package newsreader - -import ( - "net/url" - "strings" - - "github.com/PuerkitoBio/goquery" -) - -func parseHTMLArticles(body string, pageURL string, sourceLabel string) []Article { - doc, err := goquery.NewDocumentFromReader(strings.NewReader(body)) - if err != nil { - return nil - } - articles := make([]Article, 0, 32) - seen := map[string]bool{} - - add := func(title string, href string, detail string) { - title = SanitizeDetail(title, 240) - href = strings.TrimSpace(href) - if title == "" || href == "" || seen[href] { - return - } - seen[href] = true - articles = append(articles, Article{ - Title: title, - URL: href, - Detail: SanitizeDetail(detail, 280), - }) - } - - doc.Find("article a[href]").Each(func(_ int, sel *goquery.Selection) { - href := resolveHref(pageURL, sel.AttrOr("href", "")) - if href == "" { - return - } - title := strings.TrimSpace(sel.Text()) - if title == "" { - title = strings.TrimSpace(sel.Closest("article").Find("h1,h2,h3").First().Text()) - } - add(title, href, sel.Closest("article").Text()) - }) - - doc.Find("h1 a[href], h2 a[href], h3 a[href]").Each(func(_ int, sel *goquery.Selection) { - href := resolveHref(pageURL, sel.AttrOr("href", "")) - title := strings.TrimSpace(sel.Text()) - add(title, href, sel.Parent().Text()) - }) - - if len(articles) == 0 { - doc.Find("a[href]").Each(func(_ int, sel *goquery.Selection) { - href := resolveHref(pageURL, sel.AttrOr("href", "")) - title := strings.TrimSpace(sel.Text()) - if len(title) < 12 || len(title) > 200 { - return - } - add(title, href, "") - }) - } - - if len(articles) == 0 { - title := strings.TrimSpace(doc.Find("title").First().Text()) - if title != "" { - add(title, pageURL, doc.Find("meta[name=description]").AttrOr("content", "")) - } - } - return articles -} - -func resolveHref(pageURL string, href string) string { - href = strings.TrimSpace(href) - if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") { - return "" - } - base, err := url.Parse(pageURL) - if err != nil { - return href - } - ref, err := url.Parse(href) - if err != nil { - return "" - } - resolved := base.ResolveReference(ref) - if resolved.Scheme != "http" && resolved.Scheme != "https" { - return "" - } - return resolved.String() -} diff --git a/workers/go/internal/newsreader/newsreader_test.go b/workers/go/internal/newsreader/newsreader_test.go deleted file mode 100644 index 3bee2d75..00000000 --- a/workers/go/internal/newsreader/newsreader_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package newsreader - -import "testing" - -func TestValidateRequiresSources(t *testing.T) { - _, err := Validate(Request{Mode: ModeRSS, Sources: []Source{}}) - if err == nil { - t.Fatal("expected validation error") - } -} - -func TestParseFeedReadsRSSItem(t *testing.T) { - rss := ` - Hellohttps://example.com/aBody - ` - articles := parseFeed(rss, "Example") - if len(articles) != 1 { - t.Fatalf("expected 1 article, got %d", len(articles)) - } - if articles[0].Title != "Hello" { - t.Fatalf("unexpected title %q", articles[0].Title) - } -} - -func TestCanonicalFetchURLRewritesGoogleNewsHomepage(t *testing.T) { - got := CanonicalFetchURL("https://news.google.com/", "tech news") - if got == "https://news.google.com/" { - t.Fatalf("expected RSS rewrite, got %q", got) - } -} - -func TestCanonicalFetchURLUpgradesGeneralGoogleNewsRSSForTechHint(t *testing.T) { - got := CanonicalFetchURL( - "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en", - "tech-news Tech", - ) - want := "https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=en-US&gl=US&ceid=US:en" - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - -func TestFilterTopicsKeepsUnmatchedArticlesWhenFewMatches(t *testing.T) { - articles := []Article{ - {Title: "TechCrunch story", URL: "https://example.com/a"}, - {Title: "Wall Street earnings", URL: "https://example.com/b"}, - {Title: "Another market report", URL: "https://example.com/c"}, - } - got := filterTopics(articles, []string{"Tech"}) - if len(got) != 3 { - t.Fatalf("expected all articles when only one matches, got %d", len(got)) - } - if got[0].Title != "TechCrunch story" { - t.Fatalf("expected matched article first, got %q", got[0].Title) - } -} diff --git a/workers/go/internal/newsreader/rss.go b/workers/go/internal/newsreader/rss.go deleted file mode 100644 index 3d8372e0..00000000 --- a/workers/go/internal/newsreader/rss.go +++ /dev/null @@ -1,207 +0,0 @@ -package newsreader - -import ( - "encoding/xml" - "regexp" - "strings" -) - -type rssFeed struct { - Channel rssChannel `xml:"channel"` -} - -type rssChannel struct { - Items []rssItem `xml:"item"` -} - -type rssItem struct { - Title string `xml:"title"` - Link string `xml:"link"` - Description string `xml:"description"` - PubDate string `xml:"pubDate"` -} - -type atomFeed struct { - Entries []atomEntry `xml:"entry"` -} - -type atomEntry struct { - Title string `xml:"title"` - Link atomLink `xml:"link"` - Summary string `xml:"summary"` - Updated string `xml:"updated"` - Content atomContent `xml:"content"` -} - -type atomLink struct { - Href string `xml:"href,attr"` -} - -type atomContent struct { - Value string `xml:",chardata"` -} - -func looksLikeFeed(body string) bool { - lower := strings.ToLower(body) - return strings.Contains(lower, "") - blocks = append(blocks, splitBlocks(body, "")...) - articles := make([]Article, 0, len(blocks)) - for _, block := range blocks { - title := firstTag(block, "title") - link := firstTag(block, "link") - if link == "" { - link = firstAttr(block, "link", "href") - } - if link == "" { - link = firstTag(block, "guid") - } - description := firstTag(block, "description") - if description == "" { - description = firstTag(block, "summary") - } - if description == "" { - description = firstTag(block, "content") - } - cleanTitle := SanitizeDetail(title, 240) - cleanLink := strings.TrimSpace(StripTags(link)) - if cleanTitle == "" || cleanLink == "" { - continue - } - resolved := PreferredArticleURL(cleanLink, description) - detail := SanitizeDetail(description, 280) - articles = append(articles, Article{ - Title: cleanTitle, - URL: resolved, - Detail: detail, - PublishedAt: strings.TrimSpace(firstTag(block, "pubDate")), - }) - } - return articles -} - -func splitBlocks(body string, startToken string, endToken string) []string { - lower := strings.ToLower(body) - startLower := strings.ToLower(startToken) - endLower := strings.ToLower(endToken) - var blocks []string - idx := 0 - for { - start := strings.Index(lower[idx:], startLower) - if start < 0 { - break - } - start += idx - end := strings.Index(lower[start:], endLower) - if end < 0 { - break - } - end += start + len(endToken) - blocks = append(blocks, body[start:end]) - idx = end - } - return blocks -} - -func firstTag(block string, name string) string { - open := "<" + strings.ToLower(name) - lower := strings.ToLower(block) - start := strings.Index(lower, open) - if start < 0 { - return "" - } - closeTag := strings.Index(block[start:], ">") - if closeTag < 0 { - return "" - } - contentStart := start + closeTag + 1 - endToken := "" - end := strings.Index(strings.ToLower(block[contentStart:]), endToken) - if end < 0 { - return "" - } - return block[contentStart : contentStart+end] -} - -func firstAttr(block string, tagName string, attr string) string { - open := "<" + strings.ToLower(tagName) - lower := strings.ToLower(block) - start := strings.Index(lower, open) - if start < 0 { - return "" - } - closeTag := strings.Index(block[start:], ">") - if closeTag < 0 { - return "" - } - tag := block[start : start+closeTag+1] - pattern := regexp.MustCompile(`(?i)` + attr + `\s*=\s*"([^"]+)"`) - match := pattern.FindStringSubmatch(tag) - if len(match) < 2 { - return "" - } - return match[1] -} diff --git a/workers/go/internal/newsreader/text.go b/workers/go/internal/newsreader/text.go deleted file mode 100644 index 9f8e952c..00000000 --- a/workers/go/internal/newsreader/text.go +++ /dev/null @@ -1,76 +0,0 @@ -package newsreader - -import ( - "net/url" - "regexp" - "strings" -) - -var tagPattern = regexp.MustCompile(`<[^>]+>`) -var hrefPattern = regexp.MustCompile(`(?i)href\s*=\s*"([^"]+)"`) -var whitespacePattern = regexp.MustCompile(`\s+`) - -func StripTags(raw string) string { - value := tagPattern.ReplaceAllString(raw, " ") - value = strings.ReplaceAll(value, "", "") - return DecodeEntities(value) -} - -func DecodeEntities(raw string) string { - replacements := []struct { - from string - to string - }{ - {"&", "&"}, - {"<", "<"}, - {">", ">"}, - {""", "\""}, - {"'", "'"}, - {"'", "'"}, - {" ", " "}, - } - value := raw - for _, pair := range replacements { - value = strings.ReplaceAll(value, pair.from, pair.to) - } - return value -} - -func CollapseWhitespace(raw string) string { - return strings.TrimSpace(whitespacePattern.ReplaceAllString(raw, " ")) -} - -func SanitizeDetail(raw string, maxLen int) string { - cleaned := CollapseWhitespace(StripTags(raw)) - if cleaned == "" { - return "" - } - if len(cleaned) <= maxLen { - return cleaned - } - return strings.TrimSpace(cleaned[:maxLen]) + "…" -} - -func PreferredArticleURL(link string, htmlSnippet string) string { - link = strings.TrimSpace(link) - for _, match := range hrefPattern.FindAllStringSubmatch(htmlSnippet, -1) { - if len(match) < 2 { - continue - } - href := strings.TrimSpace(match[1]) - if !strings.HasPrefix(href, "http") { - continue - } - parsed, err := url.Parse(href) - if err != nil { - continue - } - host := strings.ToLower(parsed.Hostname()) - if strings.Contains(host, "google.com") || strings.Contains(host, "googleusercontent.com") { - continue - } - return href - } - return link -} diff --git a/workers/go/internal/newsreader/types.go b/workers/go/internal/newsreader/types.go deleted file mode 100644 index 3738664b..00000000 --- a/workers/go/internal/newsreader/types.go +++ /dev/null @@ -1,49 +0,0 @@ -package newsreader - -const ( - DefaultMaxCount = 20 - MaximumMaxCount = 50 - DefaultTimeoutSeconds = 120 - MaximumTimeoutSeconds = 300 -) - -type Mode string - -const ( - ModeRSS Mode = "rss" - ModeList Mode = "list" - ModeSummary Mode = "summary" -) - -type Source struct { - Label string `json:"label"` - URL string `json:"url"` -} - -type Request struct { - Mode Mode `json:"mode"` - Sources []Source `json:"sources"` - Topics []string `json:"topics"` - MaxCount int `json:"maxCount"` - ContextHint string `json:"contextHint,omitempty"` -} - -type Article struct { - Title string `json:"title"` - URL string `json:"url"` - Detail string `json:"detail,omitempty"` - PublishedAt string `json:"published_at,omitempty"` -} - -type Result struct { - OK bool `json:"ok"` - Mode Mode `json:"mode"` - Articles []Article `json:"articles"` - Diagnostics []string `json:"diagnostics"` -} - -type ProxyConfig struct { - Host string - Port int - Token string -} diff --git a/workers/go/internal/newsreader/urls.go b/workers/go/internal/newsreader/urls.go deleted file mode 100644 index 599fe7d9..00000000 --- a/workers/go/internal/newsreader/urls.go +++ /dev/null @@ -1,62 +0,0 @@ -package newsreader - -import ( - "net/url" - "strings" -) - -func CanonicalFetchURL(raw string, contextHint string) string { - parsed, err := url.Parse(raw) - if err != nil { - return raw - } - host := strings.ToLower(parsed.Hostname()) - if !strings.Contains(host, "news.google.com") { - return raw - } - path := strings.ToLower(parsed.Path) - if strings.Contains(path, "/rss") || strings.HasSuffix(path, ".xml") { - if !strings.Contains(path, "/headlines/section/topic/") { - if section := googleNewsSection(contextHint); section != "" { - return "https://news.google.com/rss/headlines/section/topic/" + section + "?hl=en-US&gl=US&ceid=US:en" - } - } - return raw - } - if strings.Contains(path, "/topics/") { - if section := googleNewsSection(contextHint); section != "" { - return "https://news.google.com/rss/headlines/section/topic/" + section + "?hl=en-US&gl=US&ceid=US:en" - } - return "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" - } - if parsed.RawQuery == "" { - return "https://news.google.com/rss?hl=en-US&gl=US&ceid=US:en" - } - return raw -} - -func googleNewsSection(hint string) string { - lower := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(hint, "-", " "), "_", " ")) - if strings.Contains(lower, "tech") { - return "TECHNOLOGY" - } - if strings.Contains(lower, "business") || strings.Contains(lower, "finance") || strings.Contains(lower, "market") { - return "BUSINESS" - } - if strings.Contains(lower, "science") { - return "SCIENCE" - } - if strings.Contains(lower, "sport") { - return "SPORTS" - } - if strings.Contains(lower, "health") { - return "HEALTH" - } - if strings.Contains(lower, "entertainment") { - return "ENTERTAINMENT" - } - if strings.Contains(lower, "world") { - return "WORLD" - } - return "" -} diff --git a/workers/go/internal/newsreader/validate.go b/workers/go/internal/newsreader/validate.go deleted file mode 100644 index a9ec8196..00000000 --- a/workers/go/internal/newsreader/validate.go +++ /dev/null @@ -1,38 +0,0 @@ -package newsreader - -import ( - "fmt" - "net/url" - "strings" -) - -func Validate(req Request) (Request, error) { - out := req - if out.Mode != ModeRSS && out.Mode != ModeList && out.Mode != ModeSummary { - return out, fmt.Errorf("mode must be rss, list, or summary") - } - if len(out.Sources) == 0 { - return out, fmt.Errorf("add at least one source URL") - } - if out.MaxCount <= 0 { - out.MaxCount = DefaultMaxCount - } - if out.MaxCount > MaximumMaxCount { - out.MaxCount = MaximumMaxCount - } - for i, source := range out.Sources { - trimmed := strings.TrimSpace(source.URL) - if trimmed == "" { - return out, fmt.Errorf("source %d is missing a URL", i+1) - } - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme != "http" && parsed.Scheme != "https" { - return out, fmt.Errorf("source %d is not a usable web address", i+1) - } - out.Sources[i].URL = trimmed - if strings.TrimSpace(out.Sources[i].Label) == "" { - out.Sources[i].Label = parsed.Host - } - } - return out, nil -} From d32838e1658bbabb899f231070a90daf547107b3 Mon Sep 17 00:00:00 2001 From: David Choi Date: Fri, 11 Sep 2026 22:23:47 -0400 Subject: [PATCH 10/17] Fix plugin factory test fixtures so content hashes match packaged files. Co-authored-by: Cursor --- .../DBRepositoryTests/DBRepositoryTests.swift | 92 +++++++++---------- 1 file changed, 42 insertions(+), 50 deletions(-) diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift index 4d2ed206..343da150 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift @@ -81,23 +81,9 @@ final class DBRepositoryTests: XCTestCase { func testSchemaUpgradeDoesNotWipeExistingRows() async throws { let repository = try makeRepository() _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") - let artifact = Data("compiled".utf8) - let files: [String: Data] = [ - "plugin.json": Data(#"{"name":"keep-me"}"#.utf8), - "app.derrick/runtime.json": Data(#"{"language":"swift"}"#.utf8), - "app.derrick/plugin.go": Data("package main".utf8), - "app.derrick/plugin": artifact, - ] - let release = PluginFactoryRelease( + let release = makeGoFactoryRelease( pluginID: "keep-me", - version: "1.0.0", - manifestJSON: String(decoding: files["plugin.json"] ?? Data(), as: UTF8.self), - runtimeJSON: String(decoding: files["app.derrick/runtime.json"] ?? Data(), as: UTF8.self), - guestSource: "print(\"[]\")", - compiledArtifact: artifact, - skillFiles: [:], - contentHash: PluginContentHash.hash(files: files), - reviewSummary: "approved" + manifestName: "keep-me" ) try await repository.savePluginFactoryRelease(release) let url = await repository.databaseURL @@ -119,25 +105,11 @@ final class DBRepositoryTests: XCTestCase { func testApprovedPluginFactoryReleasePersistsAndVerifies() async throws { let repository = try makeRepository() _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") - let artifact = Data("compiled".utf8) let skillFiles = ["skills/weather/SKILL.md": "# Weather"] - let files: [String: Data] = [ - "plugin.json": Data(#"{"name":"weather-tool"}"#.utf8), - "app.derrick/runtime.json": Data(#"{"language":"swift"}"#.utf8), - "app.derrick/plugin.go": Data("package main".utf8), - "app.derrick/plugin": artifact, - "skills/weather/SKILL.md": Data("# Weather".utf8), - ] - let release = PluginFactoryRelease( + let release = makeGoFactoryRelease( pluginID: "weather-tool", - version: "1.0.0", - manifestJSON: String(decoding: files["plugin.json"] ?? Data(), as: UTF8.self), - runtimeJSON: String(decoding: files["app.derrick/runtime.json"] ?? Data(), as: UTF8.self), - guestSource: "print(\"[]\")", - compiledArtifact: artifact, - skillFiles: skillFiles, - contentHash: PluginContentHash.hash(files: files), - reviewSummary: "approved" + manifestName: "weather-tool", + skillFiles: skillFiles ) try await repository.savePluginFactoryRelease(release) @@ -164,25 +136,11 @@ final class DBRepositoryTests: XCTestCase { ) ) _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") - let artifact = Data("compiled".utf8) let skillFiles = ["skills/weather/SKILL.md": "# Weather"] - let files: [String: Data] = [ - "plugin.json": Data(#"{"name":"weather-tool"}"#.utf8), - "app.derrick/runtime.json": Data(#"{"language":"swift"}"#.utf8), - "app.derrick/plugin.go": Data("package main".utf8), - "app.derrick/plugin": artifact, - "skills/weather/SKILL.md": Data("# Weather".utf8), - ] - let release = PluginFactoryRelease( + let release = makeGoFactoryRelease( pluginID: "weather-tool", - version: "1.0.0", - manifestJSON: String(decoding: files["plugin.json"] ?? Data(), as: UTF8.self), - runtimeJSON: String(decoding: files["app.derrick/runtime.json"] ?? Data(), as: UTF8.self), - guestSource: "print(\"[]\")", - compiledArtifact: artifact, - skillFiles: skillFiles, - contentHash: PluginContentHash.hash(files: files), - reviewSummary: "approved" + manifestName: "weather-tool", + skillFiles: skillFiles ) try await repository.savePluginFactoryRelease(release) @@ -633,4 +591,38 @@ final class DBRepositoryTests: XCTestCase { } return String(cString: c) } + + /// Hash must match `packageFiles()`, which uses `guestSource` at the Go guest path. + private func makeGoFactoryRelease( + pluginID: String, + manifestName: String, + skillFiles: [String: String] = [:] + ) -> PluginFactoryRelease { + let artifact = Data("compiled".utf8) + let guestSource = "package main" + let manifestJSON = "{\"name\":\"\(manifestName)\"}" + let runtimeJSON = #"{"language":"go"}"# + var files: [String: Data] = [ + "plugin.json": Data(manifestJSON.utf8), + "app.derrick/runtime.json": Data(runtimeJSON.utf8), + "app.derrick/plugin.go": Data(guestSource.utf8), + "app.derrick/plugin": artifact, + ] + for (path, body) in skillFiles { + files[path] = Data(body.utf8) + } + let release = PluginFactoryRelease( + pluginID: pluginID, + version: "1.0.0", + manifestJSON: manifestJSON, + runtimeJSON: runtimeJSON, + guestSource: guestSource, + compiledArtifact: artifact, + skillFiles: skillFiles, + contentHash: PluginContentHash.hash(files: files), + reviewSummary: "approved" + ) + XCTAssertTrue(release.verifyIntegrity(), "test fixture hash must match packageFiles()") + return release + } } From 1eeac37cd91d7f7ea629a4d1fc5b53139bdbd2a4 Mon Sep 17 00:00:00 2001 From: David Choi Date: Fri, 11 Sep 2026 22:44:19 -0400 Subject: [PATCH 11/17] Fix MCPServer Docker tests for the unified worker image. Mocks now return the binaries label and pin digest, so prewarm skips rebuild when the image is already current and CI no longer treats an empty inspect as a missing image. Co-authored-by: Cursor --- .../MCPServer/DockerImageInspector.swift | 2 +- .../DockerProductImagePrewarmer.swift | 2 +- .../Tests/MCPServerTests/MCPServerTests.swift | 128 +++++++++++------- 3 files changed, 83 insertions(+), 49 deletions(-) diff --git a/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift b/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift index 57873932..d242193a 100644 --- a/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift +++ b/packages/MCPServer/Sources/MCPServer/DockerImageInspector.swift @@ -34,7 +34,7 @@ public enum DockerImageInspector: Sendable { } } - /// Returns false when the image exists but predates required worker binaries (e.g. news reader). + /// Returns false when the image exists but predates required worker binaries. public static func workerImageHasCurrentBinaries( tag: String = DockerWorkerRuntime.image, executor: @escaping DockerCLIExecutor diff --git a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift index 82f9d7b4..1c1b3403 100644 --- a/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift +++ b/packages/MCPServer/Sources/MCPServer/DockerProductImagePrewarmer.swift @@ -48,7 +48,7 @@ public enum DockerProductImagePrewarmer: Sendable { ) return } - // Stale worker image (missing news reader, etc.). Rebuild overwrites the tag. + // Stale worker image (missing required binaries). Rebuild overwrites the tag. } guard let repoRoot = DerrickRepositoryRoot.locate() else { diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index c9dd3a51..b3970914 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -7,7 +7,7 @@ import Plugin import WebCrawler @testable import MCPServer -@Suite struct MCPServerTests { +@Suite(.serialized) struct MCPServerTests { private static let dummyGoScript = """ package main @@ -38,9 +38,49 @@ import WebCrawler let digest = DockerWorkerRuntime.pinnedDigest.rawValue + "\n" return DockerCLIResult(exitCode: 0, stdout: Data(digest.utf8), stderr: Data()) } + if arguments.contains(where: { $0.contains(DockerWorkerRuntime.binariesLabelKey) }) { + let label = DockerWorkerRuntime.binariesLabelValue + "\n" + return DockerCLIResult(exitCode: 0, stdout: Data(label.utf8), stderr: Data()) + } return DockerCLIResult(exitCode: 0, stdout: Data("[]".utf8), stderr: Data()) } + private actor ImageBuildLatch { + private(set) var succeeded = false + func markSucceeded() { succeeded = true } + } + + private static func missingUntilBuiltExecutor( + recorder: DockerCallRecorder, + latch: ImageBuildLatch, + failFirstBuild: Bool = false, + buildDelay: Duration? = nil + ) -> DockerCLIExecutor { + { args, _, _ in + await recorder.append(args) + if args.first == "build" { + if let buildDelay { + try await Task.sleep(for: buildDelay) + } + if failFirstBuild { + let builds = await recorder.calls.filter { $0.first == "build" }.count + if builds == 1 { + return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data("boom".utf8)) + } + } + await latch.markSucceeded() + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.first == "image" { + if await latch.succeeded, let mocked = mockWorkerImageInspect(args) { + return mocked + } + return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data()) + } + return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + } + private static let dummyCompiledGuest = Data([0x7f, 0x45, 0x4c, 0x46, 0x02]) private static func mockGuestDocker(_ arguments: [String]) -> DockerCLIResult? { @@ -299,42 +339,40 @@ import WebCrawler let recorder = DockerCallRecorder() let executor: DockerCLIExecutor = { args, _, _ in await recorder.append(args) + if let mocked = Self.mockWorkerImageInspect(args) { + return mocked + } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) } try await DockerProductImagePrewarmer.ensureWebCrawlerImage(executor: executor) - #expect(await recorder.calls == [["image", "inspect", DockerProductImagePolicy.webCrawlerImage]]) + let calls = await recorder.calls + #expect(calls.first == ["image", "inspect", DockerProductImagePolicy.workerImage]) + #expect(calls.contains { $0.contains("--format") && $0.contains(where: { $0.contains(DockerWorkerRuntime.binariesLabelKey) }) }) + #expect(calls.contains { $0.contains("{{.Id}}") }) + #expect(!calls.contains { $0.first == "build" }) } @Test func dockerProductImagePrewarmerBuildsWhenImageMissing() async throws { guard DerrickRepositoryRoot.locate() != nil else { return } let recorder = DockerCallRecorder() - let executor: DockerCLIExecutor = { args, _, _ in - await recorder.append(args) - if args.first == "image" { - return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data()) - } - if args.first == "build" { - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } + let latch = ImageBuildLatch() + let executor = Self.missingUntilBuiltExecutor(recorder: recorder, latch: latch) try await DockerProductImagePrewarmer.ensureWebCrawlerImage(executor: executor) let calls = await recorder.calls - #expect(calls.count == 2) - #expect(calls[0] == ["image", "inspect", DockerProductImagePolicy.webCrawlerImage]) - #expect(calls[1].first == "build") - #expect(calls[1].contains(DockerProductImagePolicy.webCrawlerImage)) - #expect(calls[1].last?.hasSuffix("/\(DockerProductImagePolicy.webCrawlerBuildContextRelativePath)") == true) + #expect(calls[0] == ["image", "inspect", DockerProductImagePolicy.workerImage]) + #expect(calls.contains { $0.first == "build" && $0.contains(DockerProductImagePolicy.workerImage) }) + #expect(calls.contains { $0.contains("{{.Id}}") }) + #expect(calls.filter { $0.first == "build" }.count == 1) } @Test func crawlerImageBuildFailureMessageOmitsBuildkitDump() { let error = DockerProductImagePrewarmerError.buildFailed( - DockerProductImagePolicy.webCrawlerImage, + DockerProductImagePolicy.workerImage, "#0 building with \"default\" instance using docker driver" ) let text = error.localizedDescription #expect(!text.contains("#0 building")) - #expect(text.lowercased().contains("web crawler")) + #expect(text.lowercased().contains("worker image")) #expect(text.lowercased().contains("disk")) #expect(error.compilerDiagnostic == nil) } @@ -346,7 +384,7 @@ import WebCrawler error: Build failed """ let error = DockerProductImagePrewarmerError.buildFailed( - DockerProductImagePolicy.webCrawlerImage, + DockerProductImagePolicy.workerImage, detail ) #expect(error.compilerDiagnostic?.contains("CryptoKit") == true) @@ -356,17 +394,12 @@ import WebCrawler @Test func crawlerImageBuildIsSingleFlight() async throws { guard DerrickRepositoryRoot.locate() != nil else { return } let recorder = DockerCallRecorder() - let executor: DockerCLIExecutor = { args, _, _ in - await recorder.append(args) - if args.first == "image" { - return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data()) - } - if args.first == "build" { - try await Task.sleep(for: .milliseconds(80)) - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } + let latch = ImageBuildLatch() + let executor = Self.missingUntilBuiltExecutor( + recorder: recorder, + latch: latch, + buildDelay: .milliseconds(80) + ) let gate = WebCrawlerImageGate() try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { try await gate.ensureReady(executor: executor) } @@ -376,26 +409,18 @@ import WebCrawler } let calls = await recorder.calls #expect(calls.filter { $0.first == "build" }.count == 1) - #expect(calls.filter { $0.first == "image" }.count == 1) + #expect(calls.filter { $0 == ["image", "inspect", DockerProductImagePolicy.workerImage] }.count == 1) } @Test func crawlerImageBuildFailureAllowsRetry() async throws { guard DerrickRepositoryRoot.locate() != nil else { return } let recorder = DockerCallRecorder() - let executor: DockerCLIExecutor = { args, _, _ in - await recorder.append(args) - if args.first == "image" { - return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data()) - } - if args.first == "build" { - let builds = await recorder.calls.filter { $0.first == "build" }.count - if builds == 1 { - return DockerCLIResult(exitCode: 1, stdout: Data(), stderr: Data("boom".utf8)) - } - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } - return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) - } + let latch = ImageBuildLatch() + let executor = Self.missingUntilBuiltExecutor( + recorder: recorder, + latch: latch, + failFirstBuild: true + ) let gate = WebCrawlerImageGate() do { try await gate.ensureReady(executor: executor) @@ -444,6 +469,9 @@ import WebCrawler let runner = FileExtractorDockerExecutor( executor: { arguments, _, _ in await recorder.append(arguments) + if let mocked = Self.mockWorkerImageInspect(arguments) { + return mocked + } return DockerCLIResult(exitCode: 0, stdout: Data(), stderr: Data()) }, queue: DerrickDockerRunQueue(maxConcurrentContainers: 1) @@ -480,11 +508,17 @@ import WebCrawler timeoutSeconds: 5 ) Issue.record("expected missing extractor image") + } catch is DockerProductImagePrewarmerError { + // Image is missing; prewarmer tries a rebuild and that mock also fails. + } catch is DockerImageDigestError { + // Pin check after a failed inspect. } catch let error as FileExtractorDockerExecutorError { #expect(error == .imageUnavailable(FileExtractorDockerExecutor.image)) } let calls = await recorder.calls - #expect(calls == [["image", "inspect", FileExtractorDockerExecutor.image]]) + #expect(calls.contains { $0.first == "image" && $0.contains("inspect") }) + #expect(!calls.contains { $0.first == "create" }) + #expect(!calls.contains { $0.first == "start" }) } @Test func orphanSweeperRemovesLabeledAndPrefixedContainers() async throws { From ae20ed2401d49278091f073002699502815c9865 Mon Sep 17 00:00:00 2001 From: David Choi Date: Fri, 11 Sep 2026 23:22:40 -0400 Subject: [PATCH 12/17] Stop requiring host Go in StructureTests on CI. Guest compile runs in Docker; ensureInstalled is a local diagnostic. Tests now cover version parsing instead of calling `go version` on the runner. Co-authored-by: Cursor --- .../DockerRunnerXPC/DerrickGoToolchain.swift | 4 ++-- .../DerrickGoToolchainTests.swift | 23 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift b/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift index dc08137b..7253423f 100644 --- a/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift +++ b/packages/Structure/Sources/DockerRunnerXPC/DerrickGoToolchain.swift @@ -27,7 +27,7 @@ public enum DerrickGoToolchain: Sendable { } } - private static func parseVersion(_ text: String) -> String? { + static func parseVersion(_ text: String) -> String? { // go version go1.27.1 darwin/arm64 for part in text.split(separator: " ") { let token = String(part) @@ -38,7 +38,7 @@ public enum DerrickGoToolchain: Sendable { return nil } - private static func versionSatisfies(_ found: String, minimum: String) -> Bool { + static func versionSatisfies(_ found: String, minimum: String) -> Bool { compareVersions(found, minimum) != .orderedAscending } diff --git a/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift b/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift index 6587b5d2..b618f0f7 100644 --- a/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift +++ b/packages/Structure/Tests/StructureTests/DerrickGoToolchainTests.swift @@ -1,12 +1,29 @@ -import Structure import Testing +@testable import Structure @Suite struct DerrickGoToolchainTests { @Test func minimumVersionIsPinned() { #expect(DerrickGoToolchain.minimumVersion == "1.27.1") } - @Test func ensureInstalledAcceptsCurrentGo() throws { - try DerrickGoToolchain.ensureInstalled() + @Test func parseVersionReadsGoVersionLine() { + #expect( + DerrickGoToolchain.parseVersion("go version go1.27.1 darwin/arm64") == "1.27.1" + ) + } + + @Test func parseVersionRejectsEmptyOutput() { + #expect(DerrickGoToolchain.parseVersion("") == nil) + #expect(DerrickGoToolchain.parseVersion("go version") == nil) + } + + @Test func versionSatisfiesAcceptsCurrentAndNewer() { + #expect(DerrickGoToolchain.versionSatisfies("1.27.1", minimum: "1.27.1")) + #expect(DerrickGoToolchain.versionSatisfies("1.28.0", minimum: "1.27.1")) + } + + @Test func versionSatisfiesRejectsOlder() { + #expect(!DerrickGoToolchain.versionSatisfies("1.27.0", minimum: "1.27.1")) + #expect(!DerrickGoToolchain.versionSatisfies("1.26.9", minimum: "1.27.1")) } } From a51b5003471adc476d7863d2db9e3f92d46c6477 Mon Sep 17 00:00:00 2001 From: David Choi Date: Sat, 12 Sep 2026 11:05:44 -0400 Subject: [PATCH 13/17] finish new plugins --- .../MCPService/PluginFactoryCreateInput.swift | 17 + .../Plugin/PluginPresent.swift | 22 ++ .../Plugin/PluginPresentPolicy.swift | 116 ++++++ .../Plugin/PluginSpecDraft.swift | 170 ++++++++ .../Plugin/PluginSpecProcession.swift | 373 ++++++++++++++++++ .../PluginSpecProcessionTests.swift | 130 ++++++ .../PluginFactoryUserFacingFormatter.swift | 8 +- ui/ui/Messaging/AppWorkspace.swift | 8 +- .../Messaging/MessagingConversationView.swift | 32 +- ui/ui/Messaging/MessagingStore.swift | 17 +- ui/ui/Plugins/PluginCreationController.swift | 61 ++- ui/ui/Plugins/PluginsWorkspaceView.swift | 48 +-- ui/ui/Session/ChatSessionStore.swift | 190 ++++++++- ui/ui/Session/ChatTabSurfacePolicy.swift | 34 ++ ui/ui/Views/ContentView.swift | 169 ++++++-- ui/ui/Views/LLMModelSettingsView.swift | 9 +- .../Views/PluginFactorySettingsListView.swift | 121 ++++++ ui/ui/Views/PluginPresentTabBody.swift | 43 ++ ui/ui/Views/SidebarView.swift | 190 +-------- ui/uiTests/ChatTabRoutingTests.swift | 80 ++++ ...luginFactoryUserFacingFormatterTests.swift | 15 +- 21 files changed, 1577 insertions(+), 276 deletions(-) create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/PluginPresent.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/PluginPresentPolicy.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift create mode 100644 packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift create mode 100644 ui/ui/Session/ChatTabSurfacePolicy.swift create mode 100644 ui/ui/Views/PluginFactorySettingsListView.swift create mode 100644 ui/ui/Views/PluginPresentTabBody.swift create mode 100644 ui/uiTests/ChatTabRoutingTests.swift diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift index a152366d..4bb2dc62 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateInput.swift @@ -150,6 +150,23 @@ public struct PluginFactoryCreateInput: Codable, Sendable, Hashable { } } + public static func makeFromSpecDraft( + _ spec: PluginSpecDraft, + auth: ConnectorAuthDiscovery? = nil, + existingPluginIDs: [String] = [] + ) throws -> PluginFactoryCreateInput { + guard spec.isBuildable else { + throw PluginCreatorSpecError.notBuildable + } + var skill = spec.asSkillDraft() + PluginSkillDraftPlanner.applyGoal( + skill.goal, + to: &skill, + existingPluginIDs: existingPluginIDs + ) + return try makeFromSkillDraft(skill, auth: auth) + } + /// Builds connector workflow input. The factory goal uses the fixed scope sentence, not free-text extras. public static func makeConnector( vendor: ConnectorVendor, diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginPresent.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginPresent.swift new file mode 100644 index 00000000..3a69a556 --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginPresent.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Host pipe that draws a Return payload inside a Chat tab. +/// Not a Work verb and not a product SKU. The human does not pick this unless the host cannot decide. +public enum PluginPresent: String, Sendable, Hashable, Codable, CaseIterable { + case conversation + case thread + case generatedView + case file + case image +} + +public enum PluginPresentSource: String, Sendable, Hashable, Codable { + case inferred + case asked + case wrongnessOverride +} + +public enum PluginPresentBinding: Equatable, Sendable { + case decided(PluginPresent) + case needsHumanChoice +} diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginPresentPolicy.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginPresentPolicy.swift new file mode 100644 index 00000000..ba82eaa3 --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginPresentPolicy.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Host determination of Present. Not a procession slot. +public enum PluginPresentPolicy: Sendable { + public static func bind( + spec: PluginSpecDraft, + isMessagingConnector: Bool = false + ) -> PluginPresentBinding { + if isMessagingConnector || spec.isMessagingConnect { + return .decided(.thread) + } + guard let returnClass = spec.returnClass else { + return .needsHumanChoice + } + switch returnClass { + case .threadItems: + return .decided(.thread) + case .file: + return .decided(.file) + case .image: + return .decided(.image) + case .list: + return .decided(.generatedView) + case .brief, .message: + return .decided(.conversation) + } + } + + public static func applyWrongness( + _ text: String, + current: PluginPresent + ) -> PluginPresentBinding { + let lowered = text.lowercased() + let wantsGeneratedView = matches( + lowered, + [ + "not a wall of markdown", + "not raw markdown", + "raw ###", + "skim", + "many stories", + "scan", + "not one giant blob", + ] + ) + let wantsConversation = matches( + lowered, + [ + "just tell me", + "in the chat", + "in chat", + "readable text", + "don't need a view", + "do not need a view", + ] + ) + let wantsFile = matches( + lowered, + [ + "actual document", + "the actual document", + "download", + "save a file", + "as a pdf", + "as a file", + ] + ) + let wantsImage = matches( + lowered, + [ + "as an image", + "a picture", + "screenshot", + ] + ) + + var hits: [PluginPresent] = [] + if wantsGeneratedView { hits.append(.generatedView) } + if wantsConversation { hits.append(.conversation) } + if wantsFile { hits.append(.file) } + if wantsImage { hits.append(.image) } + + let unique = Array(Set(hits)) + if unique.count > 1 { + return .needsHumanChoice + } + if unique.count == 1, let next = unique.first, next != current { + return .decided(next) + } + if wantsGeneratedView, current == .conversation { + return .decided(.generatedView) + } + if wantsConversation, current == .generatedView { + return .decided(.conversation) + } + return .decided(current) + } + + public static func presentFromChoice(_ text: String) -> PluginPresent? { + let lowered = text.lowercased() + if matches(lowered, ["file", "document", "download", "pdf"]) { + return .file + } + if matches(lowered, ["scan", "view", "cards", "dashboard"]) { + return .generatedView + } + if matches(lowered, ["text", "chat", "readable", "markdown"]) { + return .conversation + } + return nil + } + + private static func matches(_ text: String, _ needles: [String]) -> Bool { + needles.contains { text.contains($0) } + } +} diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift new file mode 100644 index 00000000..8876bbbc --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecDraft.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Finite ontology slots. Empty means not buildable. Oracles are not slots. +public enum PluginSpecSlot: String, Sendable, Hashable, Codable, CaseIterable { + case connect + case access + case work + case returnPayload + case trigger + + public var processionIndex: Int { + switch self { + case .connect: return 0 + case .access: return 1 + case .work: return 2 + case .returnPayload: return 3 + case .trigger: return 4 + } + } +} + +public enum PluginConnectClass: String, Sendable, Hashable, Codable { + case namedSite + case feed + case localFiles + case messagingInbox + case installedApp +} + +public enum PluginAccessState: String, Sendable, Hashable, Codable { + case reachable + case unreachable +} + +public enum PluginWorkVerb: String, Sendable, Hashable, Codable { + case fetch + case summarize + case list + case send + case search + case watch +} + +public enum PluginReturnClass: String, Sendable, Hashable, Codable { + case message + case list + case brief + case file + case image + case threadItems +} + +public enum PluginTriggerClass: String, Sendable, Hashable, Codable { + case chat + case messaging + case schedule + case mention +} + +public struct PluginConnectBinding: Sendable, Hashable, Codable { + public var klass: PluginConnectClass + public var detail: String + + public init(klass: PluginConnectClass, detail: String) { + self.klass = klass + self.detail = detail + } +} + +/// Spec filled by the plugin-creator procession. Present is bound by the host. +public struct PluginSpecDraft: Sendable, Hashable, Codable { + public var claimedOutcome: String? + public var connect: PluginConnectBinding? + public var access: PluginAccessState? + public var work: PluginWorkVerb? + public var returnClass: PluginReturnClass? + public var trigger: PluginTriggerClass? + public var present: PluginPresent? + public var presentSource: PluginPresentSource? + public var wrongness: String? + public var parked: [String: String] + + public init( + claimedOutcome: String? = nil, + connect: PluginConnectBinding? = nil, + access: PluginAccessState? = nil, + work: PluginWorkVerb? = nil, + returnClass: PluginReturnClass? = nil, + trigger: PluginTriggerClass? = nil, + present: PluginPresent? = nil, + presentSource: PluginPresentSource? = nil, + wrongness: String? = nil, + parked: [String: String] = [:] + ) { + self.claimedOutcome = claimedOutcome + self.connect = connect + self.access = access + self.work = work + self.returnClass = returnClass + self.trigger = trigger + self.present = present + self.presentSource = presentSource + self.wrongness = wrongness + self.parked = parked + } + + public var isMessagingConnect: Bool { + connect?.klass == .messagingInbox + } + + public var isBuildable: Bool { + guard let claimedOutcome, !claimedOutcome.isEmpty else { return false } + guard connect != nil else { return false } + guard access == .reachable else { return false } + guard work != nil, returnClass != nil, trigger != nil else { return false } + guard present != nil else { return false } + guard let wrongness, !wrongness.isEmpty else { return false } + return true + } + + public func asSkillDraft(pluginName: String = "") -> PluginSkillDraft { + let outcome = claimedOutcome ?? "" + var triggers: Set = [.chat] + if let trigger { + triggers = [Self.skillTrigger(trigger)] + } + let purposeParts = [ + outcome, + connect.map { "Connect: \($0.klass.rawValue) \($0.detail)" }, + work.map { "Work: \($0.rawValue)" }, + returnClass.map { "Return: \($0.rawValue)" }, + present.map { "Present: \($0.rawValue)" }, + ].compactMap { $0 } + return PluginSkillDraft( + goal: outcome, + purpose: purposeParts.joined(separator: ". "), + triggers: triggers, + examples: [ + PluginSkillDraft.Example( + userSays: outcome.isEmpty ? "Run this plugin" : outcome, + pluginDoes: "return \(returnClass?.rawValue ?? "a result") in the Chat tab" + ), + ], + pluginName: pluginName + ) + } + + private static func skillTrigger(_ trigger: PluginTriggerClass) -> PluginSkillDraft.Trigger { + switch trigger { + case .chat: return .chat + case .messaging: return .messaging + case .schedule: return .schedule + case .mention: return .mention + } + } +} + +public enum PluginCreatorSpecError: Error, LocalizedError, Equatable, Hashable { + case notBuildable + case accessUnreachable + + public var errorDescription: String? { + switch self { + case .notBuildable: + return "This plugin is not ready to build until every spec slot and Present are bound." + case .accessUnreachable: + return "Derrick cannot reach that source yet, so the plugin cannot be built." + } + } +} diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift new file mode 100644 index 00000000..173fa42a --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginSpecProcession.swift @@ -0,0 +1,373 @@ +import Foundation + +/// Next thing the creator skill may ask. One legal ask at a time. +public enum PluginSpecAsk: Equatable, Sendable, Hashable { + case claimedOutcome + case slot(PluginSpecSlot) + case presentChoice + case wrongness + case complete + case blocked(PluginCreatorSpecError) +} + +public struct PluginSpecSession: Sendable, Hashable { + public var draft: PluginSpecDraft + public var ask: PluginSpecAsk + + public init(draft: PluginSpecDraft = PluginSpecDraft(), ask: PluginSpecAsk = .claimedOutcome) { + self.draft = draft + self.ask = ask + } +} + +public struct PluginSpecTurn: Equatable, Sendable { + public var reply: String + public var ask: PluginSpecAsk + public var isComplete: Bool + + public init(reply: String, ask: PluginSpecAsk, isComplete: Bool) { + self.reply = reply + self.ask = ask + self.isComplete = isComplete + } +} + +/// Procession: ask only the next unfilled legal slot. Received means bound, not merely spoken. +public enum PluginSpecProcession: Sendable { + public static let creatorTabID = "plugin-creator" + + public static var openingQuestion: String { + question(for: .claimedOutcome) + } + + public static func question(for ask: PluginSpecAsk) -> String { + switch ask { + case .claimedOutcome: + return "What should this plugin do when it works?" + case .slot(.connect): + return "Where should that come from? A site, a feed, an app you already use, or files on this Mac?" + case .slot(.access): + return "Can Derrick reach that now, or is it blocked (login, paywall, or missing files)?" + case .slot(.work): + return "What should it do to that source? Fetch, summarize, list, send, search, or watch?" + case .slot(.returnPayload): + return "What should come back — a brief, a list, a message, a file, an image, or thread items?" + case .slot(.trigger): + return "When should it run — when you ask in chat, on a schedule, when you type /name, or from messaging?" + case .presentChoice: + return "In this chat tab, should this show as readable text, a view you can scan, or a file?" + case .wrongness: + return "What would make this the wrong plugin? What must not happen?" + case .complete: + return "The spec is bound. Derrick can build this plugin." + case .blocked(.accessUnreachable): + return "Derrick cannot reach that source yet, so this plugin cannot be built." + case .blocked: + return "This plugin is not ready to build." + } + } + + public static func advance(session: inout PluginSpecSession, utterance: String) -> PluginSpecTurn { + let trimmed = utterance.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return PluginSpecTurn(reply: question(for: session.ask), ask: session.ask, isComplete: false) + } + + parkLaterSlots(from: trimmed, onto: &session.draft) + + switch session.ask { + case .claimedOutcome: + session.draft.claimedOutcome = trimmed + session.ask = nextAsk(session.draft) + case .slot(let slot): + if bind(slot, from: trimmed, onto: &session.draft) { + session.draft.parked[slot.rawValue] = nil + session.ask = nextAsk(session.draft) + } + case .presentChoice: + if let present = PluginPresentPolicy.presentFromChoice(trimmed) { + session.draft.present = present + session.draft.presentSource = .asked + session.ask = .wrongness + } + case .wrongness: + session.draft.wrongness = trimmed + if let current = session.draft.present { + switch PluginPresentPolicy.applyWrongness(trimmed, current: current) { + case .decided(let next): + if next != current { + session.draft.present = next + session.draft.presentSource = .wrongnessOverride + } + session.ask = session.draft.isBuildable ? .complete : nextAsk(session.draft) + case .needsHumanChoice: + session.ask = .presentChoice + } + } else { + session.ask = nextAsk(session.draft) + } + case .complete, .blocked: + break + } + + if case .blocked = session.ask { + return PluginSpecTurn( + reply: question(for: session.ask), + ask: session.ask, + isComplete: false + ) + } + + applyParkedBindings(&session) + if session.ask == .complete || session.draft.isBuildable { + session.ask = .complete + return PluginSpecTurn( + reply: question(for: .complete), + ask: .complete, + isComplete: true + ) + } + + return PluginSpecTurn( + reply: notBoundHint(for: session.ask, utterance: trimmed) ?? question(for: session.ask), + ask: session.ask, + isComplete: false + ) + } + + public static func nextAsk(_ draft: PluginSpecDraft) -> PluginSpecAsk { + if draft.claimedOutcome?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + return .claimedOutcome + } + if draft.connect == nil { return .slot(.connect) } + if draft.access == nil { return .slot(.access) } + if draft.access == .unreachable { return .blocked(.accessUnreachable) } + if draft.work == nil { return .slot(.work) } + if draft.returnClass == nil { return .slot(.returnPayload) } + if draft.trigger == nil { return .slot(.trigger) } + if draft.present == nil { + if case .needsHumanChoice = PluginPresentPolicy.bind(spec: draft) { + return .presentChoice + } + return .wrongness + } + if draft.wrongness?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + return .wrongness + } + return draft.isBuildable ? .complete : .blocked(.notBuildable) + } + + /// Host binds Present after Return (and Trigger) without asking, unless tied. + public static func bindInferredPresent(onto draft: inout PluginSpecDraft) { + guard draft.present == nil, draft.returnClass != nil else { return } + switch PluginPresentPolicy.bind(spec: draft) { + case .decided(let present): + draft.present = present + draft.presentSource = .inferred + case .needsHumanChoice: + break + } + } + + private static func applyParkedBindings(_ session: inout PluginSpecSession) { + bindInferredPresent(onto: &session.draft) + var progressed = true + while progressed { + progressed = false + let ask = nextAsk(session.draft) + if case .slot(let slot) = ask, let parked = session.draft.parked[slot.rawValue], + bind(slot, from: parked, onto: &session.draft) { + session.draft.parked[slot.rawValue] = nil + progressed = true + } + bindInferredPresent(onto: &session.draft) + } + session.ask = nextAsk(session.draft) + bindInferredPresent(onto: &session.draft) + session.ask = nextAsk(session.draft) + } + + private static func parkLaterSlots(from text: String, onto draft: inout PluginSpecDraft) { + let later: [PluginSpecSlot] = [.connect, .access, .work, .returnPayload, .trigger] + for slot in later { + if extract(slot, from: text) != nil { + draft.parked[slot.rawValue] = text + } + } + } + + @discardableResult + private static func bind( + _ slot: PluginSpecSlot, + from text: String, + onto draft: inout PluginSpecDraft + ) -> Bool { + switch slot { + case .connect: + guard let binding = PluginSpecClassifier.connect(from: text) else { return false } + draft.connect = binding + return true + case .access: + guard let access = PluginSpecClassifier.access(from: text) else { return false } + draft.access = access + return true + case .work: + guard let work = PluginSpecClassifier.work(from: text) else { return false } + draft.work = work + return true + case .returnPayload: + guard let payload = PluginSpecClassifier.returnClass(from: text) else { return false } + draft.returnClass = payload + return true + case .trigger: + guard let trigger = PluginSpecClassifier.trigger(from: text) else { return false } + draft.trigger = trigger + return true + } + } + + private static func extract(_ slot: PluginSpecSlot, from text: String) -> Bool? { + switch slot { + case .connect: return PluginSpecClassifier.connect(from: text) != nil ? true : nil + case .access: return PluginSpecClassifier.access(from: text) != nil ? true : nil + case .work: return PluginSpecClassifier.work(from: text) != nil ? true : nil + case .returnPayload: return PluginSpecClassifier.returnClass(from: text) != nil ? true : nil + case .trigger: return PluginSpecClassifier.trigger(from: text) != nil ? true : nil + } + } + + private static func notBoundHint(for ask: PluginSpecAsk, utterance: String) -> String? { + if case .slot(.connect) = ask, PluginSpecClassifier.connect(from: utterance) == nil { + return "That is not a place Derrick can open. Name a site, a feed, files on this Mac, or an app you already use." + } + return nil + } + + public static let creatorSkillMarkdown = """ + # Plugin creator + + You fill a finite spec. Ask only the next unfilled legal slot. Received means bound, not merely spoken. + + Slots, in order: Connect, Access, Work, Return, Trigger. + Oracles, not slots: claimed outcome (first), wrongness (last). + Present is bound by the host after Return. Do not ask for Present unless the host cannot decide. + + Park volunteered later answers. Do not jump ahead. + """ +} + +enum PluginSpecClassifier { + static func connect(from text: String) -> PluginConnectBinding? { + let lowered = text.lowercased() + if isVaguePlace(lowered) { + return nil + } + if lowered.contains("slack") || lowered.contains("telegram") + || lowered.contains("whatsapp") || lowered.contains("discord") + || lowered.contains("inbox") { + return PluginConnectBinding(klass: .messagingInbox, detail: text) + } + if lowered.contains("rss") || lowered.contains("atom") || lowered.contains("feed") { + return PluginConnectBinding(klass: .feed, detail: text) + } + if lowered.contains("this mac") || lowered.contains("local file") + || lowered.contains("files on") || lowered.contains("folder") { + return PluginConnectBinding(klass: .localFiles, detail: text) + } + if lowered.contains("http://") || lowered.contains("https://") + || lowered.contains("google news") || lowered.contains("wall street journal") + || looksLikeNamedSite(lowered) { + return PluginConnectBinding(klass: .namedSite, detail: text) + } + if lowered.contains("app i") || lowered.contains("app you already") { + return PluginConnectBinding(klass: .installedApp, detail: text) + } + return nil + } + + static func access(from text: String) -> PluginAccessState? { + let lowered = text.lowercased() + if lowered.contains("paywall") || lowered.contains("can't") || lowered.contains("cannot") + || lowered.contains("blocked") || lowered.contains("no login") + || lowered.contains("don't have") || lowered.contains("do not have") { + return .unreachable + } + if lowered.contains("yes") || lowered.contains("public") || lowered.contains("can open") + || lowered.contains("reachable") || lowered.contains("i have") || lowered.contains("logged in") { + return .reachable + } + return nil + } + + static func work(from text: String) -> PluginWorkVerb? { + let lowered = text.lowercased() + if lowered.contains("summar") { return .summarize } + if lowered.contains("send") || lowered.contains("post") { return .send } + if lowered.contains("list") || lowered.contains("show my") { return .list } + if lowered.contains("watch") || lowered.contains("monitor") { return .watch } + if lowered.contains("search") { return .search } + if lowered.contains("fetch") || lowered.contains("get ") || lowered.contains("download") { + return .fetch + } + return nil + } + + static func returnClass(from text: String) -> PluginReturnClass? { + let lowered = text.lowercased() + if lowered.contains("thread") || lowered.contains("channel list") { + return .threadItems + } + if lowered.contains("image") || lowered.contains("picture") || lowered.contains("screenshot") { + return .image + } + if lowered.contains("file") || lowered.contains("pdf") || lowered.contains("document") { + return .file + } + if lowered.contains("list") || lowered.contains("headlines") || lowered.contains("many stor") { + return .list + } + if lowered.contains("summar") || lowered.contains("brief") { + return .brief + } + if lowered.contains("message") || lowered.contains("reply") || lowered.contains("tell me") { + return .message + } + return nil + } + + static func trigger(from text: String) -> PluginTriggerClass? { + let lowered = text.lowercased() + if lowered.contains("schedule") || lowered.contains("every day") || lowered.contains("daily") { + return .schedule + } + if lowered.contains("messaging") || lowered.contains("from slack") { + return .messaging + } + if lowered.contains("/name") || lowered.contains("slash") || lowered.contains("when i type /") { + return .mention + } + if lowered.contains("chat") || lowered.contains("when i ask") { + return .chat + } + return nil + } + + private static func isVaguePlace(_ lowered: String) -> Bool { + let collapsed = lowered.trimmingCharacters(in: .whitespacesAndNewlines) + if collapsed == "the internet" || collapsed == "internet" || collapsed == "online" + || collapsed == "the web" || collapsed == "google" || collapsed == "just google" + || collapsed == "google is fine" { + return true + } + if collapsed.contains("the internet") && !looksLikeNamedSite(collapsed) { + return true + } + return false + } + + private static func looksLikeNamedSite(_ lowered: String) -> Bool { + lowered.contains("news") && (lowered.contains("google") || lowered.contains(".com") || lowered.contains("journal")) + || lowered.contains("nytimes") + || lowered.contains("wall street") + } +} diff --git a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift new file mode 100644 index 00000000..98878de9 --- /dev/null +++ b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import Structure + +@Suite struct PluginSpecProcessionTests { + @Test func internetDoesNotBindConnect() { + var session = PluginSpecSession() + _ = PluginSpecProcession.advance(session: &session, utterance: "Summaries of today’s tech news.") + #expect(session.ask == .slot(.connect)) + let turn = PluginSpecProcession.advance(session: &session, utterance: "Just the internet. Google is fine.") + #expect(session.draft.connect == nil) + #expect(session.ask == .slot(.connect)) + #expect(turn.reply.contains("not a place")) + } + + @Test func parksReturnFromClaimedOutcomeButDoesNotSkipConnect() { + var session = PluginSpecSession() + _ = PluginSpecProcession.advance(session: &session, utterance: "Give me summaries of today’s tech news.") + #expect(session.draft.claimedOutcome != nil) + #expect(session.ask == .slot(.connect)) + #expect(session.draft.parked[PluginSpecSlot.returnPayload.rawValue] != nil) + } + + @Test func namedSiteBindsConnectThenAsksAccess() { + var session = PluginSpecSession() + _ = PluginSpecProcession.advance(session: &session, utterance: "Summaries of tech news") + _ = PluginSpecProcession.advance(session: &session, utterance: "Google News, and also the Wall Street Journal.") + #expect(session.draft.connect?.klass == .namedSite) + #expect(session.ask == .slot(.access)) + } + + @Test func unreachableAccessBlocksBuild() { + var session = PluginSpecSession() + _ = PluginSpecProcession.advance(session: &session, utterance: "Fetch files") + _ = PluginSpecProcession.advance(session: &session, utterance: "files on this Mac") + let turn = PluginSpecProcession.advance(session: &session, utterance: "paywall, I cannot open them") + #expect(session.draft.access == .unreachable) + #expect(turn.ask == .blocked(.accessUnreachable)) + #expect(session.draft.isBuildable == false) + } + + @Test func completeSpecInfersConversationPresent() { + var session = PluginSpecSession() + _ = PluginSpecProcession.advance(session: &session, utterance: "A short brief of my notes") + _ = PluginSpecProcession.advance(session: &session, utterance: "files on this Mac") + _ = PluginSpecProcession.advance(session: &session, utterance: "yes I can open them") + _ = PluginSpecProcession.advance(session: &session, utterance: "summarize them") + _ = PluginSpecProcession.advance(session: &session, utterance: "a brief") + _ = PluginSpecProcession.advance(session: &session, utterance: "when I ask in chat") + #expect(session.draft.present == .conversation) + #expect(session.draft.presentSource == .inferred) + #expect(session.ask == .wrongness) + let done = PluginSpecProcession.advance(session: &session, utterance: "nothing, that is fine") + #expect(done.isComplete) + #expect(session.draft.isBuildable) + } + + @Test func slackConnectInfersThreadPresent() { + var session = PluginSpecSession() + _ = PluginSpecProcession.advance(session: &session, utterance: "Read my Slack inbox") + _ = PluginSpecProcession.advance(session: &session, utterance: "Slack") + _ = PluginSpecProcession.advance(session: &session, utterance: "yes I am logged in") + _ = PluginSpecProcession.advance(session: &session, utterance: "list my channels") + _ = PluginSpecProcession.advance(session: &session, utterance: "thread items") + _ = PluginSpecProcession.advance(session: &session, utterance: "from messaging") + #expect(session.draft.present == .thread) + } +} + +@Suite struct PluginPresentPolicyTests { + @Test func listReturnBindsGeneratedView() { + var spec = PluginSpecDraft(returnClass: .list) + #expect(PluginPresentPolicy.bind(spec: spec) == .decided(.generatedView)) + spec.returnClass = .file + #expect(PluginPresentPolicy.bind(spec: spec) == .decided(.file)) + spec.returnClass = .image + #expect(PluginPresentPolicy.bind(spec: spec) == .decided(.image)) + spec.returnClass = .brief + #expect(PluginPresentPolicy.bind(spec: spec) == .decided(.conversation)) + spec.connect = PluginConnectBinding(klass: .messagingInbox, detail: "Slack") + #expect(PluginPresentPolicy.bind(spec: spec) == .decided(.thread)) + } + + @Test func wrongnessOverridesConversationToGeneratedView() { + let binding = PluginPresentPolicy.applyWrongness( + "Not a wall of markdown; I need to skim many stories", + current: .conversation + ) + #expect(binding == .decided(.generatedView)) + } + + @Test func wrongnessOverridesGeneratedViewToConversation() { + let binding = PluginPresentPolicy.applyWrongness( + "Just tell me in the chat", + current: .generatedView + ) + #expect(binding == .decided(.conversation)) + } + + @Test func wrongnessCanForceFile() { + #expect( + PluginPresentPolicy.applyWrongness("I need the actual document", current: .conversation) + == .decided(.file) + ) + } + + @Test func makeFromSpecDraftRequiresPresent() { + let empty = PluginSpecDraft(claimedOutcome: "do it") + #expect(throws: PluginCreatorSpecError.notBuildable) { + try PluginFactoryCreateInput.makeFromSpecDraft(empty) + } + } + + @Test func makeFromSpecDraftSucceedsWhenComplete() throws { + let spec = PluginSpecDraft( + claimedOutcome: "Summarize my notes", + connect: PluginConnectBinding(klass: .localFiles, detail: "files on this Mac"), + access: .reachable, + work: .summarize, + returnClass: .brief, + trigger: .chat, + present: .conversation, + presentSource: .inferred, + wrongness: "not an empty reply" + ) + let input = try PluginFactoryCreateInput.makeFromSpecDraft(spec) + #expect(input.pluginType == .custom) + #expect(input.pluginID != nil) + } +} diff --git a/ui/SharedAgentRuntime/Conversation/PluginFactoryUserFacingFormatter.swift b/ui/SharedAgentRuntime/Conversation/PluginFactoryUserFacingFormatter.swift index 601497bb..c3d29bb5 100644 --- a/ui/SharedAgentRuntime/Conversation/PluginFactoryUserFacingFormatter.swift +++ b/ui/SharedAgentRuntime/Conversation/PluginFactoryUserFacingFormatter.swift @@ -71,24 +71,24 @@ enum PluginFactoryUserFacingFormatter { return """ **Plugin approved and saved.** - Open **Messaging → \(pluginID)** to connect and sync. Taking you there now. + Open **Chat → /\(pluginID)** to connect and sync. Taking you there now. """ } return """ **Plugin approved and saved.** - Open **Messaging → \(pluginID)** to connect and sync. + Open **Chat → /\(pluginID)** to connect and sync. """ } return """ - Plugin **\(pluginID)** was saved. Open **Messaging → \(pluginID)** to add credentials and connect. + Plugin **\(pluginID)** was saved. Open **Chat → /\(pluginID)** to add credentials and connect. """ } if credentialsReady { return """ **Plugin approved and saved.** - Run **\(pluginID)** from Plugins when you are ready. + Run **/\(pluginID)** in Chat when you are ready. """ } return """ diff --git a/ui/ui/Messaging/AppWorkspace.swift b/ui/ui/Messaging/AppWorkspace.swift index 872c89b9..c2e5b23d 100644 --- a/ui/ui/Messaging/AppWorkspace.swift +++ b/ui/ui/Messaging/AppWorkspace.swift @@ -2,7 +2,11 @@ import Foundation enum AppWorkspace: Equatable { case chats - case plugins - case messaging case debugLogs } + +enum ChatShellNotification { + static let startPluginCreation = Notification.Name("derrick.startPluginCreation") + static let openPluginInChat = Notification.Name("derrick.openPluginInChat") + static let pluginIDUserInfoKey = "pluginID" +} diff --git a/ui/ui/Messaging/MessagingConversationView.swift b/ui/ui/Messaging/MessagingConversationView.swift index f0c8bc75..f1ec77a3 100644 --- a/ui/ui/Messaging/MessagingConversationView.swift +++ b/ui/ui/Messaging/MessagingConversationView.swift @@ -3,6 +3,7 @@ import SwiftUI struct MessagingConversationView: View { @ObservedObject var store: MessagingStore + var onInboundBannerTap: (() -> Void)? = nil @ObservedObject private var agentProfiles = AgentProfileStore.shared @State private var draft = "" @State private var threadDraft = "" @@ -56,9 +57,9 @@ struct MessagingConversationView: View { private var emptyConnectors: some View { VStack(spacing: 10) { - Text("Messaging") + Text("Connector") .font(.system(size: 28, weight: .semibold, design: .rounded)) - Text("Connector plugins show up here. Create a messaging connector plugin to start.") + Text("Installed connectors appear as Chat tabs. Type / then the plugin name, or open one from Settings → Plugins.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -279,17 +280,22 @@ struct MessagingConversationView: View { } } if let banner = store.inboundBanner, !banner.isEmpty { - Text(banner) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(2) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: 520) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) - .padding(.top, 10) - .transition(.move(edge: .top).combined(with: .opacity)) + Button { + onInboundBannerTap?() + } label: { + Text(banner) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: 520) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + } + .buttonStyle(.plain) + .padding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) } } .animation(.easeInOut(duration: 0.35), value: store.inboundBanner) diff --git a/ui/ui/Messaging/MessagingStore.swift b/ui/ui/Messaging/MessagingStore.swift index cd60e012..b676a861 100644 --- a/ui/ui/Messaging/MessagingStore.swift +++ b/ui/ui/Messaging/MessagingStore.swift @@ -28,6 +28,8 @@ final class MessagingStore: ObservableObject { @Published private(set) var isConnectorSyncing = false @Published var isSending = false @Published private(set) var inboundBanner: String? + @Published private(set) var inboundBannerPluginID: String? + @Published private(set) var inboundBannerThreadID: String? private var repository: DBRepository? private var cancellables = Set() @@ -176,7 +178,7 @@ final class MessagingStore: ObservableObject { } @discardableResult - func openConnector(pluginID: String) async -> Bool { + func openConnector(pluginID: String, autoOpenMostRecent: Bool = true) async -> Bool { guard PluginFactoryCreateInput.ConnectorVendor.isEnabledMessagingPluginID(pluginID) else { return false } @@ -196,7 +198,7 @@ final class MessagingStore: ObservableObject { await catalog.refreshBadges() DerrickMessagingIngressSignal.postPoll() } - await session.openConnector(pluginID: pluginID, autoOpenMostRecent: true) + await session.openConnector(pluginID: pluginID, autoOpenMostRecent: autoOpenMostRecent) guard session.selectedPluginID == pluginID else { return false } publishForegroundPresence() primeInboundMessageIDs() @@ -485,13 +487,24 @@ final class MessagingStore: ObservableObject { } inboundBannerTask?.cancel() inboundBanner = text + inboundBannerPluginID = session.selectedPluginID + inboundBannerThreadID = newest.threadID inboundBannerTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 5_000_000_000) guard !Task.isCancelled else { return } inboundBanner = nil + inboundBannerPluginID = nil + inboundBannerThreadID = nil } } + func clearInboundBanner() { + inboundBannerTask?.cancel() + inboundBanner = nil + inboundBannerPluginID = nil + inboundBannerThreadID = nil + } + private static func messagingDetailJSON( pluginID: String, threadID: String, diff --git a/ui/ui/Plugins/PluginCreationController.swift b/ui/ui/Plugins/PluginCreationController.swift index c4228f2a..f4bae8f2 100644 --- a/ui/ui/Plugins/PluginCreationController.swift +++ b/ui/ui/Plugins/PluginCreationController.swift @@ -7,6 +7,7 @@ import SwiftUI @MainActor final class PluginCreationController: ObservableObject { enum Phase: Equatable { + case idle case intro case goal case skill @@ -35,10 +36,11 @@ final class PluginCreationController: ObservableObject { var status: Status } - @Published private(set) var phase: Phase = .intro + @Published private(set) var phase: Phase = .idle @Published private(set) var statusMessage = "" @Published private(set) var progressSteps: [ProgressStepState] = [] @Published var skillDraft = PluginSkillDraft() + @Published private(set) var completedSpec: PluginSpecDraft? @Published private(set) var credentialFields: [PluginCredentialFieldPresentation] = [] @Published var credentialDrafts: [String: String] = [:] @@ -57,6 +59,15 @@ final class PluginCreationController: ObservableObject { discoverTask?.cancel() } + var showsFactoryChrome: Bool { + switch phase { + case .idle, .intro, .goal, .skill, .preview: + return false + case .discoveringAuth, .creating, .collectCredentials, .failed, .succeeded: + return true + } + } + var canContinueFromGoal: Bool { !skillDraft.goal.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -101,6 +112,19 @@ final class PluginCreationController: ObservableObject { ) } + func hide() { + cancelPolling() + discoverTask?.cancel() + pendingAuth = nil + phase = .idle + statusMessage = "" + progressSteps = [] + credentialFields = [] + credentialDrafts = [:] + skillDraft = PluginSkillDraft() + completedSpec = nil + } + func showIntro() { cancelPolling() discoverTask?.cancel() @@ -111,6 +135,28 @@ final class PluginCreationController: ObservableObject { credentialFields = [] credentialDrafts = [:] skillDraft = PluginSkillDraft() + completedSpec = nil + } + + func beginFromCompletedSpec( + _ spec: PluginSpecDraft, + sessionID: String, + helperAPIKey: String?, + helperReviewerModelJSON: String? + ) { + completedSpec = spec + var skill = spec.asSkillDraft() + PluginSkillDraftPlanner.applyGoal( + skill.goal, + to: &skill, + existingPluginIDs: PluginFactoryListStore.shared.pluginIDs + ) + skillDraft = skill + confirmPreview( + sessionID: sessionID, + helperAPIKey: helperAPIKey, + helperReviewerModelJSON: helperReviewerModelJSON + ) } func beginCreate() { @@ -241,7 +287,7 @@ final class PluginCreationController: ObservableObject { } func dismissSuccess() { - showIntro() + hide() } private func startFactoryCreation() { @@ -253,7 +299,16 @@ final class PluginCreationController: ObservableObject { return } do { - let input = try PluginFactoryCreateInput.makeFromSkillDraft(skillDraft, auth: pendingAuth) + let input: PluginFactoryCreateInput + if let completedSpec { + input = try PluginFactoryCreateInput.makeFromSpecDraft( + completedSpec, + auth: pendingAuth, + existingPluginIDs: PluginFactoryListStore.shared.pluginIDs + ) + } else { + throw PluginCreatorSpecError.notBuildable + } cancelPolling() phase = .creating statusMessage = "Building your plugin…" diff --git a/ui/ui/Plugins/PluginsWorkspaceView.swift b/ui/ui/Plugins/PluginsWorkspaceView.swift index a3e8a3b3..32f39f3b 100644 --- a/ui/ui/Plugins/PluginsWorkspaceView.swift +++ b/ui/ui/Plugins/PluginsWorkspaceView.swift @@ -10,32 +10,28 @@ struct PluginsWorkspaceView: View { let onOpenMessagingConnector: (String) -> Void var body: some View { - ZStack { - Color(red: 252.0 / 255.0, green: 252.0 / 255.0, blue: 250.0 / 255.0) - .ignoresSafeArea() - - VStack(spacing: 12) { - Image(systemName: "puzzlepiece.extension") - .font(.system(size: 36)) - .foregroundStyle(.secondary) - Text("Plugins") - .font(.title2.weight(.semibold)) - Text("Installed plugins appear in the sidebar. Use Messaging to talk to connector plugins.") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 360) - } - .padding(32) - } - .modalPopup( - isPresented: true, + Color.clear + .ignoresSafeArea() + .modalPopup( + isPresented: controller.phase != .idle, minWidth: 400, minHeight: 0, maxWidth: 560, maxHeight: modalMaxHeight, - onBackdropDismiss: canDismiss ? { controller.showIntro() } : nil, - onEscape: canDismiss ? { controller.showIntro() } : nil, + onBackdropDismiss: canDismiss ? { + if controller.phase == .intro { + controller.hide() + } else { + controller.showIntro() + } + } : nil, + onEscape: canDismiss ? { + if controller.phase == .intro { + controller.hide() + } else { + controller.showIntro() + } + } : nil, header: { Text(modalTitle) .font(.headline) @@ -58,6 +54,7 @@ struct PluginsWorkspaceView: View { private var modalMaxHeight: CGFloat { switch controller.phase { + case .idle: return 560 case .skill, .preview: return 720 default: return 560 } @@ -65,13 +62,14 @@ struct PluginsWorkspaceView: View { private var canDismiss: Bool { switch controller.phase { - case .creating, .discoveringAuth: return false + case .idle, .creating, .discoveringAuth: return false default: return true } } private var modalTitle: String { switch controller.phase { + case .idle: return "Create a plugin" case .intro: return "Create a plugin" case .goal: return "What should it do?" case .skill: return "Define the skill" @@ -99,6 +97,8 @@ struct PluginsWorkspaceView: View { @ViewBuilder private var modalBody: some View { switch controller.phase { + case .idle: + EmptyView() case .intro: Text(""" Describe what you want Derrick to do. Derrick will draft a skill, show you a preview, and build a secure plugin package. @@ -166,6 +166,8 @@ struct PluginsWorkspaceView: View { @ViewBuilder private var modalFooter: some View { switch controller.phase { + case .idle: + EmptyView() case .intro: HStack { Spacer() diff --git a/ui/ui/Session/ChatSessionStore.swift b/ui/ui/Session/ChatSessionStore.swift index 37aa83c7..2023d6a2 100644 --- a/ui/ui/Session/ChatSessionStore.swift +++ b/ui/ui/Session/ChatSessionStore.swift @@ -4,25 +4,66 @@ import Foundation import LLMAgentClient import Structure +enum ChatTabSurface: String, Hashable, Sendable { + case conversation + case thread + case generatedView + case file + case image + + init(_ present: PluginPresent) { + switch present { + case .conversation: self = .conversation + case .thread: self = .thread + case .generatedView: self = .generatedView + case .file: self = .file + case .image: self = .image + } + } +} + struct ChatTab: Identifiable, Hashable { let id: String var title: String var turns: [ChatTurn] var pendingAttachments: [ChatFileAttachment] var isStreaming: Bool + var surface: ChatTabSurface + var pluginID: String? + var threadID: String? + var isPluginCreator: Bool + var specSession: PluginSpecSession? init( id: String, title: String, turns: [ChatTurn] = [], pendingAttachments: [ChatFileAttachment] = [], - isStreaming: Bool = false + isStreaming: Bool = false, + surface: ChatTabSurface = .conversation, + pluginID: String? = nil, + threadID: String? = nil, + isPluginCreator: Bool = false, + specSession: PluginSpecSession? = nil ) { self.id = id self.title = title self.turns = turns self.pendingAttachments = pendingAttachments self.isStreaming = isStreaming + self.surface = surface + self.pluginID = pluginID + self.threadID = threadID + self.isPluginCreator = isPluginCreator + self.specSession = specSession + } + + static func pluginRootID(_ pluginID: String) -> String { + "plugin:\(pluginID)" + } + + static func pluginThreadID(pluginID: String, threadID: String) -> String { + "plugin:\(pluginID):thread:\(threadID)" } } @@ -32,6 +73,7 @@ final class ChatSessionStore: ObservableObject { @Published var selectedSessionID: String? @Published private(set) var recentSessions: [ChatSessionDTO] = [] @Published var scrollToBottomToken = 0 + var onPluginSpecComplete: ((PluginSpecDraft) -> Void)? private var repository: DBRepository? private var activeTasks: [String: Task] = [:] @@ -83,7 +125,54 @@ final class ChatSessionStore: ObservableObject { let tab = ChatTab(id: id, title: "New chat") tabs.append(tab) selectedSessionID = id - persistSessionShell(sessionID: id, title: tab.title) + persistSessionShell(sessionID: id, title: tab.title, tab: tab) + } + + @discardableResult + func openOrFocusPluginCreator() -> String { + let id = PluginSpecProcession.creatorTabID + if let existing = tabs.firstIndex(where: { $0.id == id }) { + selectedSessionID = tabs[existing].id + if tabs[existing].specSession == nil { + tabs[existing].isPluginCreator = true + tabs[existing].specSession = PluginSpecSession() + } + return id + } + let opening = PluginSpecProcession.openingQuestion + let tab = ChatTab( + id: id, + title: "Create plugin", + turns: [ + ChatTurn( + prompt: "Create plugin", + response: opening, + status: .complete + ), + ], + surface: .conversation, + isPluginCreator: true, + specSession: PluginSpecSession() + ) + tabs.append(tab) + selectedSessionID = id + persistSessionShell(sessionID: id, title: tab.title, tab: tab) + return id + } + + private func sendCreatorUtterance(_ utterance: String, sessionID: String) { + guard let tabIndex = tabs.firstIndex(where: { $0.id == sessionID }) else { return } + var session = tabs[tabIndex].specSession ?? PluginSpecSession() + let turn = PluginSpecProcession.advance(session: &session, utterance: utterance) + tabs[tabIndex].specSession = session + tabs[tabIndex].turns.append( + ChatTurn(prompt: utterance, response: turn.reply, status: .complete) + ) + scrollToBottomToken += 1 + persistSessionShell(sessionID: sessionID, title: tabs[tabIndex].title, tab: tabs[tabIndex]) + if turn.isComplete { + onPluginSpecComplete?(session.draft) + } } func selectSession(id: String) { @@ -92,13 +181,65 @@ final class ChatSessionStore: ObservableObject { return } if !tabs.contains(where: { $0.id == id }) { - let title = recentSessions.first(where: { $0.sessionID == id }).map(displayTitle(for:)) - ?? "Chat" - tabs.append(ChatTab(id: id, title: title)) + let session = recentSessions.first(where: { $0.sessionID == id }) + let title = session.map(displayTitle(for:)) ?? "Chat" + tabs.append(tab(from: session, id: id, title: title)) } selectedSessionID = id } + /// Opens or focuses a Chat tab for `/plugin-id` (connector or standard). + @discardableResult + func openOrFocusPlugin(pluginID: String, surface: ChatTabSurface, title: String? = nil) -> String { + let trimmed = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + let id = ChatTab.pluginRootID(trimmed) + if let existing = tabs.first(where: { $0.id == id }) + ?? tabs.first(where: { $0.pluginID == trimmed && $0.threadID == nil }) { + selectedSessionID = existing.id + return existing.id + } + let tabTitle = title ?? "/\(trimmed)" + let tab = ChatTab( + id: id, + title: tabTitle, + surface: surface, + pluginID: trimmed + ) + tabs.append(tab) + selectedSessionID = id + persistSessionShell(sessionID: id, title: tabTitle, tab: tab) + return id + } + + /// Opens or focuses a Chat tab for a connector thread (notifications, picker). + @discardableResult + func openOrFocusThread( + pluginID: String, + threadID: String, + title: String? = nil + ) -> String { + let plugin = pluginID.trimmingCharacters(in: .whitespacesAndNewlines) + let thread = threadID.trimmingCharacters(in: .whitespacesAndNewlines) + let id = ChatTab.pluginThreadID(pluginID: plugin, threadID: thread) + if let existing = tabs.first(where: { $0.id == id }) + ?? tabs.first(where: { $0.pluginID == plugin && $0.threadID == thread }) { + selectedSessionID = existing.id + return existing.id + } + let tabTitle = title ?? "/\(plugin)" + let tab = ChatTab( + id: id, + title: tabTitle, + surface: .thread, + pluginID: plugin, + threadID: thread + ) + tabs.append(tab) + selectedSessionID = id + persistSessionShell(sessionID: id, title: tabTitle, tab: tab) + return id + } + func closeTab(id: String) { activeTasks[id]?.cancel() activeTasks[id] = nil @@ -132,6 +273,11 @@ final class ChatSessionStore: ObservableObject { let attachments = tabs[tabIndex].pendingAttachments guard !trimmed.isEmpty || !attachments.isEmpty else { return } + if tabs[tabIndex].isPluginCreator { + sendCreatorUtterance(trimmed, sessionID: sessionID) + return + } + guard let resolved = AgentProfileStore.shared.resolveProfile( explicitHandle: profileHandle, message: trimmed @@ -265,7 +411,7 @@ final class ChatSessionStore: ObservableObject { guard tabs[tabIndex].title == "New chat" || tabs[tabIndex].title.isEmpty else { return } let title = Self.title(from: prompt, attachments: attachments) tabs[tabIndex].title = title - persistSessionShell(sessionID: sessionID, title: title) + persistSessionShell(sessionID: sessionID, title: title, tab: tabs[tabIndex]) Task { try? await repository?.updateChatSessionTitle( applicationName: applicationName, @@ -275,15 +421,26 @@ final class ChatSessionStore: ObservableObject { } } - private func persistSessionShell(sessionID: String, title: String) { + private func persistSessionShell(sessionID: String, title: String, tab: ChatTab) { guard let repository else { return } let now = Date.now + var metadata: [String: String] = ["surface": tab.surface.rawValue] + if tab.isPluginCreator { + metadata["pluginCreator"] = "true" + } + if let pluginID = tab.pluginID { + metadata["pluginID"] = pluginID + } + if let threadID = tab.threadID { + metadata["threadID"] = threadID + } let dto = ChatSessionDTO( applicationName: applicationName, sessionID: sessionID, title: title, createdAt: now, - updatedAt: now + updatedAt: now, + metadata: metadata ) Task { try? await repository.upsertChatSession(dto) @@ -291,6 +448,23 @@ final class ChatSessionStore: ObservableObject { } } + private func tab(from session: ChatSessionDTO?, id: String, title: String) -> ChatTab { + let metadata = session?.metadata ?? [:] + let surface = ChatTabSurface(rawValue: metadata["surface"] ?? "") ?? .conversation + let pluginID = metadata["pluginID"].flatMap { $0.isEmpty ? nil : $0 } + let threadID = metadata["threadID"].flatMap { $0.isEmpty ? nil : $0 } + let isPluginCreator = metadata["pluginCreator"] == "true" + return ChatTab( + id: id, + title: title, + surface: surface, + pluginID: pluginID, + threadID: threadID, + isPluginCreator: isPluginCreator, + specSession: isPluginCreator ? PluginSpecSession() : nil + ) + } + private func displayTitle(for session: ChatSessionDTO) -> String { let trimmed = session.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? "Chat" : trimmed diff --git a/ui/ui/Session/ChatTabSurfacePolicy.swift b/ui/ui/Session/ChatTabSurfacePolicy.swift new file mode 100644 index 00000000..dc8d2a46 --- /dev/null +++ b/ui/ui/Session/ChatTabSurfacePolicy.swift @@ -0,0 +1,34 @@ +import Foundation +import Structure + +/// Host binding for Chat-tab Surface. Not a procession slot. +/// +/// The human does not pick Surface unless `bind` returns `.needsHumanChoice`. +enum ChatTabSurfacePolicy: Sendable { + enum Binding: Equatable, Sendable { + case decided(ChatTabSurface) + case needsHumanChoice + } + + /// Slice 1 fallback when no spec Present exists yet. + static func bind(isMessagingConnector: Bool) -> Binding { + if isMessagingConnector { + return .decided(.thread) + } + return .decided(.conversation) + } + + /// Slice 2: bind from Return / Connect, then the caller may apply wrongness on the spec. + static func bind(spec: PluginSpecDraft, isMessagingConnector: Bool = false) -> Binding { + switch PluginPresentPolicy.bind(spec: spec, isMessagingConnector: isMessagingConnector) { + case .decided(let present): + return .decided(ChatTabSurface(present)) + case .needsHumanChoice: + return .needsHumanChoice + } + } + + static func bind(present: PluginPresent) -> Binding { + .decided(ChatTabSurface(present)) + } +} diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index 46013e34..1f3b2630 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -399,17 +399,32 @@ struct ContentView: View { } VStack(spacing: 0) { - if workspace == .messaging { - MessagingTabBarView(store: messaging) - } else if workspace != .debugLogs && workspace != .plugins { + if workspace != .debugLogs { ChatTabBarView(store: chatSessions) } switch workspace { - case .messaging: - MessagingConversationView(store: messaging) case .debugLogs: DebugLogsView(repository: repository) - case .plugins: + case .chats: + if chatSessions.selectedTab?.surface == .thread { + MessagingConversationView( + store: messaging, + onInboundBannerTap: { + Task { @MainActor in + await openInboundBannerConversation() + } + } + ) + } else if let surface = chatSessions.selectedTab?.surface, + surface == .generatedView || surface == .file || surface == .image { + PluginPresentTabBody(surface: surface) + } else { + mainPanel + } + } + } + .overlay { + if pluginCreationController.showsFactoryChrome { PluginsWorkspaceView( controller: pluginCreationController, sessionReady: sessionReady, @@ -418,27 +433,47 @@ struct ContentView: View { sessionID: pluginWizardSessionID, onOpenMessagingConnector: { pluginID in Task { @MainActor in - let opened = await routeToMessagingConnector(pluginID) + let opened = await routeToPluginTab( + pluginID, + present: pluginCreationController.completedSpec?.present + ) if opened { - pluginCreationController.dismissSuccess() + pluginCreationController.hide() } Task { await pluginFactoryList.reload() } } } ) - default: - mainPanel } } } .onChange(of: workspace) { _, newValue in - messaging.setWorkspaceActive(newValue == .messaging) - if newValue == .plugins { - pluginCreationController.showIntro() + messaging.setWorkspaceActive( + newValue == .chats && chatSessions.selectedTab?.surface == .thread + ) + } + .onChange(of: messaging.selectedThreadID) { _, threadID in + guard workspace == .chats, + let threadID, + let pluginID = messaging.selectedPluginID, + chatSessions.selectedTab?.surface == .thread + else { + return + } + let title = messaging.selectedThread?.title ?? "/\(pluginID)" + chatSessions.openOrFocusThread( + pluginID: pluginID, + threadID: threadID, + title: title + ) + } + .onChange(of: chatSessions.selectedSessionID) { _, _ in + Task { @MainActor in + await syncSelectedChatTabWithMessaging() } } .onAppear { - messaging.setWorkspaceActive(workspace == .messaging) + messaging.setWorkspaceActive(chatSessions.selectedTab?.surface == .thread) refreshProviderCredentialUI() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -455,7 +490,7 @@ struct ContentView: View { return } Task { @MainActor in - await routeToMessagingConnector(pluginID) + await routeToPluginTab(pluginID) } } .onReceive(NotificationCenter.default.publisher( @@ -476,13 +511,28 @@ struct ContentView: View { let parentVendorMessageID = notification.userInfo?[ DerrickMessagingConversationPresentationWake.parentVendorMessageIDUserInfoKey ] as? String - await routeToMessagingConversation( + await routeToThreadTab( pluginID: pluginID, threadID: threadID, parentVendorMessageID: parentVendorMessageID ) } } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.startPluginCreation)) { _ in + workspace = .chats + bindPluginCreatorCompletion() + chatSessions.openOrFocusPluginCreator() + } + .onReceive(NotificationCenter.default.publisher(for: ChatShellNotification.openPluginInChat)) { notification in + guard let pluginID = notification.userInfo?[ChatShellNotification.pluginIDUserInfoKey] as? String, + !pluginID.isEmpty + else { + return + } + Task { @MainActor in + await routeToPluginTab(pluginID) + } + } .sheet(isPresented: $isPresentingAPIKeyPrompt) { apiKeyPrompt() } @@ -837,6 +887,7 @@ struct ContentView: View { await PluginFactoryListStore.shared.configure(repository: repository) await AgentProfileStore.shared.configure(repository: repository) pluginCreationController.configure(repository: repository) + bindPluginCreatorCompletion() } /// Prewarm Docker in parallel with daemon + DB. Only peer handoff needs both daemon XPC and Docker. @@ -1376,10 +1427,11 @@ struct ContentView: View { promptFocusToken += 1 Task { @MainActor in - if let pluginID = Self.slashPluginID(from: currentPrompt), - await isMessagingConnector(pluginID) { - await routeToMessagingConnector(pluginID) - return + if let pluginID = Self.slashPluginID(from: currentPrompt) { + await routeToPluginTab(pluginID) + if await isMessagingConnector(pluginID) { + return + } } chatSessions.sendPrompt( @@ -1392,30 +1444,93 @@ struct ContentView: View { } } + private func bindPluginCreatorCompletion() { + chatSessions.onPluginSpecComplete = { spec in + pluginCreationController.beginFromCompletedSpec( + spec, + sessionID: pluginWizardSessionID, + helperAPIKey: currentHelperAPIKey, + helperReviewerModelJSON: currentHelperReviewerModelJSON + ) + } + } + @discardableResult - private func routeToMessagingConnector(_ pluginID: String) async -> Bool { - let opened = await messaging.openConnector(pluginID: pluginID) - guard opened else { return false } - workspace = .messaging - return true + private func routeToPluginTab(_ pluginID: String, present: PluginPresent? = nil) async -> Bool { + workspace = .chats + let connector = await isMessagingConnector(pluginID) + let binding: ChatTabSurfacePolicy.Binding + if let present { + binding = ChatTabSurfacePolicy.bind(present: present) + } else { + binding = ChatTabSurfacePolicy.bind(isMessagingConnector: connector) + } + switch binding { + case .decided(let surface): + chatSessions.openOrFocusPlugin( + pluginID: pluginID, + surface: surface, + title: "/\(pluginID)" + ) + guard surface == .thread else { return true } + let opened = await messaging.openConnector(pluginID: pluginID, autoOpenMostRecent: false) + messaging.setWorkspaceActive(true) + return opened + case .needsHumanChoice: + // The human is asked only when the host cannot decide. Slice 1 never asks. + chatSessions.openOrFocusPlugin( + pluginID: pluginID, + surface: .conversation, + title: "/\(pluginID)" + ) + return true + } } @discardableResult - private func routeToMessagingConversation( + private func routeToThreadTab( pluginID: String, threadID: String, parentVendorMessageID: String? = nil ) async -> Bool { + workspace = .chats let opened = await messaging.openConversation( pluginID: pluginID, threadID: threadID, parentVendorMessageID: parentVendorMessageID ) guard opened else { return false } - workspace = .messaging + let title = messaging.selectedThread?.title ?? "/\(pluginID)" + chatSessions.openOrFocusThread( + pluginID: pluginID, + threadID: threadID, + title: title + ) + messaging.setWorkspaceActive(true) return true } + private func openInboundBannerConversation() async { + guard let pluginID = messaging.inboundBannerPluginID, + let threadID = messaging.inboundBannerThreadID + else { + return + } + _ = await routeToThreadTab(pluginID: pluginID, threadID: threadID) + messaging.clearInboundBanner() + } + + private func syncSelectedChatTabWithMessaging() async { + let tab = chatSessions.selectedTab + messaging.setWorkspaceActive(tab?.surface == .thread) + guard tab?.surface == .thread, let pluginID = tab?.pluginID else { return } + if let threadID = tab?.threadID, !threadID.isEmpty { + _ = await messaging.openConversation(pluginID: pluginID, threadID: threadID) + } else { + _ = await messaging.openConnector(pluginID: pluginID, autoOpenMostRecent: false) + } + } + private func isMessagingConnector(_ pluginID: String) async -> Bool { guard let repository else { return false } let manifests = (try? await repository.listLatestPluginFactoryManifests()) ?? [] diff --git a/ui/ui/Views/LLMModelSettingsView.swift b/ui/ui/Views/LLMModelSettingsView.swift index d3209fc9..3a3e620c 100644 --- a/ui/ui/Views/LLMModelSettingsView.swift +++ b/ui/ui/Views/LLMModelSettingsView.swift @@ -3,13 +3,14 @@ import DBRepository import LLMAgentClient import Structure -private enum LLMModelSettingsSidebarItem: String, CaseIterable, Identifiable, Hashable { +enum LLMModelSettingsSidebarItem: String, CaseIterable, Identifiable, Hashable { case helperModels case multiAgent case containers case networkBlacklist case sensitiveContent case usageLimits + case plugins case pluginBuilder case pluginSafetyReviewer case agentProfiles @@ -31,6 +32,8 @@ private enum LLMModelSettingsSidebarItem: String, CaseIterable, Identifiable, Ha return "Sensitive content" case .usageLimits: return "Usage limits" + case .plugins: + return "Plugins" case .pluginBuilder: return "Plugin builder" case .pluginSafetyReviewer: @@ -56,6 +59,8 @@ private enum LLMModelSettingsSidebarItem: String, CaseIterable, Identifiable, Ha return "eye.slash" case .usageLimits: return "gauge.with.dots.needle.67percent" + case .plugins: + return "puzzlepiece.extension.fill" case .pluginBuilder: return "hammer" case .pluginSafetyReviewer: @@ -166,6 +171,8 @@ struct LLMModelSettingsView: View { sensitiveContentDetail case .usageLimits: usageLimitsDetail + case .plugins: + PluginFactorySettingsListView() case .pluginBuilder: pluginBuilderDetail case .pluginSafetyReviewer: diff --git a/ui/ui/Views/PluginFactorySettingsListView.swift b/ui/ui/Views/PluginFactorySettingsListView.swift new file mode 100644 index 00000000..f62be8d7 --- /dev/null +++ b/ui/ui/Views/PluginFactorySettingsListView.swift @@ -0,0 +1,121 @@ +import Plugin +import Structure +import SwiftUI + +/// Installed factory plugins, shown in Account settings (not the app sidebar). +struct PluginFactorySettingsListView: View { + @ObservedObject private var pluginFactoryList = PluginFactoryListStore.shared + @State private var expandedPluginIDs: Set = [] + + var body: some View { + VStack(alignment: .leading, spacing: SettingsLayout.sectionSpacing) { + VStack(alignment: .leading, spacing: SettingsLayout.headerControlSpacing) { + Text("Installed plugins") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 10) { + Button("Create plugin") { + NotificationCenter.default.post( + name: ChatShellNotification.startPluginCreation, + object: nil + ) + } + .buttonStyle(.borderedProminent) + + if pluginFactoryList.releases.isEmpty { + Text("No plugins yet") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(pluginFactoryList.groups) { group in + pluginGroupRow(group) + } + } + if let error = pluginFactoryList.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.leading, SettingsLayout.fieldIndent) + Text("Type / and the plugin name in Chat to open it in a tab. Create plugin asks one spec slot at a time in a Chat tab.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, SettingsLayout.fieldIndent) + } + } + .task { + await pluginFactoryList.reload() + } + } + + private func pluginGroupRow(_ group: PluginFactoryReleaseGroup) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Button { + if expandedPluginIDs.contains(group.pluginID) { + expandedPluginIDs.remove(group.pluginID) + } else { + expandedPluginIDs.insert(group.pluginID) + } + } label: { + Image(systemName: expandedPluginIDs.contains(group.pluginID) + ? "chevron.down" + : "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Button { + NotificationCenter.default.post( + name: ChatShellNotification.openPluginInChat, + object: nil, + userInfo: [ChatShellNotification.pluginIDUserInfoKey: group.pluginID] + ) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("/\(group.pluginID)") + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + Text(group.releases.count == 1 + ? "v\(group.latest?.version ?? "")" + : "\(group.releases.count) versions") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + + if expandedPluginIDs.contains(group.pluginID) { + ForEach(group.releases) { release in + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("v\(release.version)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(release.reviewSummary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + Button { + Task { await pluginFactoryList.delete(release) } + } label: { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Delete \(release.pluginID) \(release.version)") + } + .padding(.leading, 22) + } + } + } + } +} diff --git a/ui/ui/Views/PluginPresentTabBody.swift b/ui/ui/Views/PluginPresentTabBody.swift new file mode 100644 index 00000000..bdd9d61b --- /dev/null +++ b/ui/ui/Views/PluginPresentTabBody.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// Placeholder Chat-tab body for Present pipes that are not conversation or thread yet. +struct PluginPresentTabBody: View { + let surface: ChatTabSurface + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.title3.weight(.semibold)) + Text(detail) + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 246.0 / 255.0)) + } + + private var title: String { + switch surface { + case .generatedView: return "Generated view" + case .file: return "File" + case .image: return "Image" + case .conversation, .thread: return "Chat" + } + } + + private var detail: String { + switch surface { + case .generatedView: + return "This plugin’s result will show as a view you can scan in this Chat tab." + case .file: + return "This plugin’s result will show as a file in this Chat tab." + case .image: + return "This plugin’s result will show as an image in this Chat tab." + case .conversation, .thread: + return "" + } + } +} diff --git a/ui/ui/Views/SidebarView.swift b/ui/ui/Views/SidebarView.swift index b719f13d..d9107974 100644 --- a/ui/ui/Views/SidebarView.swift +++ b/ui/ui/Views/SidebarView.swift @@ -1,6 +1,5 @@ import SwiftUI import DBRepository -import Plugin import Structure private let sideMenuRecentsFontSize = CGFloat(12) @@ -15,7 +14,6 @@ struct SidebarView: View { /// Reference type must not be recreated every `View` value; hold via `@State`. @State private var helperModelSettingsPanelController = LLMModelSettingsPanelController() @ObservedObject private var pluginFactoryList = PluginFactoryListStore.shared - @State private var expandedPluginIDs: Set = [] var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -49,37 +47,12 @@ struct SidebarView: View { row: SidebarRow( id: "chats", icon: "message.fill", - title: "Chats", + title: "Chat", isProminent: workspace == .chats ) ) { workspace = .chats } - SidebarActionRow( - row: SidebarRow( - id: "plugins", - icon: "puzzlepiece.extension.fill", - title: "Plugins", - isProminent: workspace == .plugins - ) - ) { - workspace = .plugins - Task { - await pluginFactoryList.reload() - await messaging.syncConnectorsFromFactory() - } - } - SidebarActionRow( - row: SidebarRow( - id: "messaging", - icon: "bubble.left.and.bubble.right.fill", - title: "Messaging", - isProminent: workspace == .messaging - ) - ) { - workspace = .messaging - Task { await messaging.syncConnectorsFromFactory() } - } if isDebugEnabled { SidebarActionRow( row: SidebarRow( @@ -94,15 +67,10 @@ struct SidebarView: View { } } - if workspace == .plugins { - pluginsList - } else if workspace == .messaging { - messagingList - } else if workspace == .debugLogs { + if workspace == .debugLogs { debugLogsHint - } else { - recentsList } + recentsList Spacer() @@ -143,10 +111,6 @@ struct SidebarView: View { await pluginFactoryList.reload() await messaging.syncConnectorsFromFactory() } - .onChange(of: workspace) { _, newValue in - guard newValue == .messaging else { return } - Task { await messaging.syncConnectorsFromFactory() } - } .onChange(of: chatSessions.selectedTab?.turns.count ?? 0) { _, _ in Task { await pluginFactoryList.reload() @@ -220,154 +184,6 @@ struct SidebarView: View { } } } - - private var messagingList: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Connectors") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.top, 4) - - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - if messaging.connectors.isEmpty { - Text("No messaging connectors yet") - .font(.system(size: sideMenuRecentsFontSize)) - .foregroundStyle(.secondary) - } else { - ForEach(messaging.connectors) { connector in - VStack(alignment: .leading, spacing: 4) { - Button { - workspace = .messaging - Task { await messaging.openConnector(pluginID: connector.pluginID) } - } label: { - HStack(spacing: 8) { - Text(connector.displayName) - .font(.system(size: sideMenuRecentsFontSize)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle( - messaging.selectedPluginID == connector.pluginID - ? Color.primary - : Color.primary.opacity(0.9) - ) - let unread = messaging.unreadTotal(for: connector.pluginID) - if unread > 0 { - MessagingUnreadBadge(count: unread) - } - } - } - .buttonStyle(.plain) - } - } - } - if let error = messaging.lastError { - Text(error) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private var pluginsList: some View { - VStack(alignment: .leading, spacing: 8) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - if pluginFactoryList.releases.isEmpty { - Text("No plugins yet") - .font(.system(size: sideMenuRecentsFontSize)) - .foregroundStyle(.secondary) - } else { - pluginGroupRows(pluginFactoryList.groups) - } - if let error = pluginFactoryList.lastError { - Text(error) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private func pluginSectionTitle(_ title: String) -> some View { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - } - - @ViewBuilder - private func pluginGroupRows(_ groups: [PluginFactoryReleaseGroup]) -> some View { - ForEach(groups) { group in - VStack(alignment: .leading, spacing: 4) { - Button { - if expandedPluginIDs.contains(group.pluginID) { - expandedPluginIDs.remove(group.pluginID) - } else { - expandedPluginIDs.insert(group.pluginID) - } - } label: { - HStack(spacing: 8) { - Image(systemName: expandedPluginIDs.contains(group.pluginID) - ? "chevron.down" - : "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text("/\(group.pluginID)") - .font(.system(size: sideMenuRecentsFontSize, design: .monospaced)) - .lineLimit(1) - Text(group.releases.count == 1 - ? "v\(group.latest?.version ?? "")" - : "\(group.releases.count) versions") - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - if expandedPluginIDs.contains(group.pluginID) { - ForEach(group.releases) { release in - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - Text("v\(release.version)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - Text(release.reviewSummary) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(2) - } - .frame(maxWidth: .infinity, alignment: .leading) - Button { - Task { await pluginFactoryList.delete(release) } - } label: { - Image(systemName: "trash") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help("Delete \(release.pluginID) \(release.version)") - } - .padding(.leading, 18) - } - } - } - } - } - } #Preview { diff --git a/ui/uiTests/ChatTabRoutingTests.swift b/ui/uiTests/ChatTabRoutingTests.swift new file mode 100644 index 00000000..a4bfb391 --- /dev/null +++ b/ui/uiTests/ChatTabRoutingTests.swift @@ -0,0 +1,80 @@ +import Testing +@testable import ui + +@Suite struct ChatTabRoutingTests { + @Test func pluginRootAndThreadTabIDsAreStableAndDistinct() { + let plugin = ChatTab.pluginRootID("slack-bot") + let thread = ChatTab.pluginThreadID(pluginID: "slack-bot", threadID: "C123") + #expect(plugin == "plugin:slack-bot") + #expect(thread == "plugin:slack-bot:thread:C123") + #expect(plugin != thread) + } + + @MainActor + @Test func openOrFocusPluginReusesTheSameTab() { + let store = ChatSessionStore() + let first = store.openOrFocusPlugin(pluginID: "weather-tool", surface: .conversation) + let second = store.openOrFocusPlugin(pluginID: "weather-tool", surface: .conversation) + #expect(first == second) + #expect(store.tabs.filter { $0.pluginID == "weather-tool" }.count == 1) + #expect(store.selectedSessionID == first) + #expect(store.selectedTab?.surface == .conversation) + } + + @MainActor + @Test func openOrFocusThreadReusesTheSameTabAndKeepsPluginRootSeparate() { + let store = ChatSessionStore() + let root = store.openOrFocusPlugin(pluginID: "slack-bot", surface: .thread) + let thread = store.openOrFocusThread( + pluginID: "slack-bot", + threadID: "thread-1", + title: "#general" + ) + let again = store.openOrFocusThread( + pluginID: "slack-bot", + threadID: "thread-1", + title: "#general" + ) + #expect(root != thread) + #expect(thread == again) + #expect(store.tabs.count == 2) + #expect(store.selectedSessionID == thread) + #expect(store.selectedTab?.surface == .thread) + #expect(store.selectedTab?.title == "#general") + } + + @Test func settingsSidebarIncludesPlugins() { + #expect(LLMModelSettingsSidebarItem.plugins.title == "Plugins") + #expect(LLMModelSettingsSidebarItem.allCases.contains(.plugins)) + } + + @Test func hostBindsSurfaceWithoutAskingTheHuman() { + #expect(ChatTabSurfacePolicy.bind(isMessagingConnector: true) == .decided(.thread)) + #expect(ChatTabSurfacePolicy.bind(isMessagingConnector: false) == .decided(.conversation)) + #expect(ChatTabSurfacePolicy.bind(isMessagingConnector: true) != .needsHumanChoice) + #expect(ChatTabSurfacePolicy.bind(isMessagingConnector: false) != .needsHumanChoice) + } + + @Test func returnClassBindsGeneratedViewFileAndImageSurfaces() { + #expect( + ChatTabSurfacePolicy.bind(spec: PluginSpecDraft(returnClass: .list)) + == .decided(.generatedView) + ) + #expect( + ChatTabSurfacePolicy.bind(spec: PluginSpecDraft(returnClass: .file)) + == .decided(.file) + ) + #expect( + ChatTabSurfacePolicy.bind(spec: PluginSpecDraft(returnClass: .image)) + == .decided(.image) + ) + #expect( + ChatTabSurfacePolicy.bind(present: .generatedView) == .decided(.generatedView) + ) + } + + @Test func creatorTabIDIsStable() { + #expect(PluginSpecProcession.creatorTabID == "plugin-creator") + #expect(ChatTabSurface(PluginPresent.file) == .file) + } +} diff --git a/ui/uiTests/PluginFactoryUserFacingFormatterTests.swift b/ui/uiTests/PluginFactoryUserFacingFormatterTests.swift index 4dcf7940..af2adf69 100644 --- a/ui/uiTests/PluginFactoryUserFacingFormatterTests.swift +++ b/ui/uiTests/PluginFactoryUserFacingFormatterTests.swift @@ -44,7 +44,7 @@ import Testing #expect(text.contains("Slack connector ready.")) } - @Test func savedConnectorPluginNavigatesToMessaging() { + @Test func savedConnectorPluginOpensChatTab() { let text = PluginFactoryUserFacingFormatter.savedPluginNextStepText( pluginID: "slack-connector", isMessagingConnector: true, @@ -52,30 +52,33 @@ import Testing willNavigateToMessaging: true ) #expect(text.contains("**Plugin approved and saved.**")) - #expect(text.contains("Messaging → slack-connector")) + #expect(text.contains("Chat → /slack-connector")) #expect(text.contains("Taking you there now")) #expect(!text.contains("Running it now")) + #expect(!text.contains("Messaging →")) } - @Test func savedConnectorWithoutCredentialsPointsToMessaging() { + @Test func savedConnectorWithoutCredentialsPointsToChat() { let text = PluginFactoryUserFacingFormatter.savedPluginNextStepText( pluginID: "slack-connector", isMessagingConnector: true, credentialsReady: false, willNavigateToMessaging: false ) - #expect(text.contains("Messaging → slack-connector")) + #expect(text.contains("Chat → /slack-connector")) #expect(text.contains("add credentials")) + #expect(!text.contains("Messaging →")) } - @Test func savedNonConnectorPluginDoesNotMentionMessaging() { + @Test func savedNonConnectorPluginRunsFromChat() { let text = PluginFactoryUserFacingFormatter.savedPluginNextStepText( pluginID: "weather-tool", isMessagingConnector: false, credentialsReady: true, willNavigateToMessaging: false ) - #expect(text.contains("Run **weather-tool** from Plugins")) + #expect(text.contains("Run **/weather-tool** in Chat")) #expect(!text.contains("Messaging")) + #expect(!text.contains("from Plugins")) } } From 26beaa0c79969122001c421c61e368f603e50f4b Mon Sep 17 00:00:00 2001 From: David Choi Date: Mon, 14 Sep 2026 22:46:26 -0400 Subject: [PATCH 14/17] fix new plugin system --- .cursor/skills/swiftui-expert-skill/SKILL.md | 150 ++++ .../swiftui-expert-skill/agents/openai.yaml | 6 + .../assets/logo-small.png | Bin 0 -> 29649 bytes .../swiftui-expert-skill/assets/logo.png | Bin 0 -> 398841 bytes .../swiftui-expert-skill/assets/logo.svg | 23 + .../references/accessibility-patterns.md | 215 +++++ .../references/animation-advanced.md | 429 ++++++++++ .../references/animation-basics.md | 284 +++++++ .../references/animation-transitions.md | 328 ++++++++ .../references/charts-accessibility.md | 135 +++ .../swiftui-expert-skill/references/charts.md | 602 +++++++++++++ .../references/document-apps.md | 209 +++++ .../references/environment-patterns.md | 162 ++++ .../references/focus-patterns.md | 299 +++++++ .../references/image-optimization.md | 243 ++++++ .../references/latest-apis.md | 568 +++++++++++++ .../references/layout-best-practices.md | 288 +++++++ .../references/liquid-glass.md | 441 ++++++++++ .../references/list-patterns.md | 537 ++++++++++++ .../references/localization.md | 194 +++++ .../references/macos-scenes.md | 320 +++++++ .../references/macos-views.md | 360 ++++++++ .../references/macos-window-styling.md | 303 +++++++ .../references/modifier-patterns.md | 64 ++ .../references/performance-patterns.md | 403 +++++++++ .../references/previews.md | 313 +++++++ .../references/scroll-patterns.md | 352 ++++++++ .../references/sheet-navigation-patterns.md | 388 +++++++++ .../references/soft-deprecation.md | 39 + .../references/state-management.md | 484 +++++++++++ .../references/styled-text-editing.md | 198 +++++ .../references/text-patterns.md | 36 + .../references/toolbar-patterns.md | 171 ++++ .../references/trace-analysis.md | 295 +++++++ .../references/trace-recording.md | 157 ++++ .../references/view-structure.md | 794 ++++++++++++++++++ .../references/webkit-integration.md | 242 ++++++ .../scripts/analyze_trace.py | 301 +++++++ .../scripts/instruments_parser/__init__.py | 1 + .../scripts/instruments_parser/causes.py | 187 +++++ .../scripts/instruments_parser/correlate.py | 179 ++++ .../scripts/instruments_parser/events.py | 291 +++++++ .../scripts/instruments_parser/hangs.py | 108 +++ .../scripts/instruments_parser/hitches.py | 145 ++++ .../scripts/instruments_parser/summary.py | 243 ++++++ .../scripts/instruments_parser/swiftui.py | 195 +++++ .../instruments_parser/time_profiler.py | 135 +++ .../scripts/instruments_parser/xctrace.py | 117 +++ .../scripts/instruments_parser/xml_utils.py | 224 +++++ .../scripts/record_trace.py | 288 +++++++ AGENTS.md | 20 +- derrick.xcworkspace/contents.xcworkspacedata | 3 + .../xcshareddata/swiftpm/Package.resolved | 2 +- docker/worker/Dockerfile | 6 +- .../ConnectorAuthDiscoverWorkflow.swift | 164 ++-- .../MessagingIngressAdapter.swift | 22 +- .../MessagingIngressService.swift | 67 +- .../PluginFactoryCreateWorkflow.swift | 118 +-- .../PluginMessagingIngressAdapter.swift | 65 +- .../DerrickBackend/VendorDocsFetch.swift | 209 +++++ .../PluginMessagingIngressAdapterTests.swift | 137 ++- .../VendorDocsFetchTests.swift | 144 ++++ .../WorkflowIntegrationTests.swift | 11 + .../DockerRunRequestValidator.swift | 10 +- .../DockerRunnerXPCTests.swift | 16 + .../EgressProxyTests/EgressProxyTests.swift | 9 + .../LiveFactoryModels.swift | 27 +- .../MCPServer/DerrickDockerRunQueue.swift | 3 + .../MCPServer/DockerImageInspector.swift | 2 +- .../DockerProductImagePrewarmer.swift | 65 +- .../Script/GoGuestDockerExecutor.swift | 106 ++- .../MCPServer/Script/GuestHopLoop.swift | 21 +- .../MCPServer/WebCrawlerDockerExecutor.swift | 4 +- .../MCPServer/WebSearchDockerExecutor.swift | 120 +++ .../MCPServer/WebSearchToolModule.swift | 254 ++++++ .../Tests/MCPServerTests/MCPServerTests.swift | 276 +++++- .../MCPToolCatalogTests.swift | 1 + .../Factory/PluginFactoryImplementation.swift | 26 +- .../PluginTests/PluginFactoryTests.swift | 67 ++ .../ConnectorMessagingClientError.swift | 8 + .../DerrickMessagingForegroundPresence.swift | 33 + .../MCPService/ConnectorAuthDiscovery.swift | 25 +- .../MCPService/MCPToolCallTimeouts.swift | 5 + .../PluginFactoryCreateFailureMessage.swift | 10 + .../MCPService/PluginFactoryCreateInput.swift | 49 +- .../MCPService/WorkflowChatProgress.swift | 4 +- .../Plugin/ConnectorAuthPreference.swift | 127 +++ .../Plugin/PluginAccessAskPolicy.swift | 252 ++++++ .../Plugin/PluginSpecDraft.swift | 38 +- .../Plugin/PluginSpecProcession.swift | 373 +++++++- .../Plugin/VendorDocsLocator.swift | 197 +++++ .../PluginSecretDescriptor.swift | 16 +- .../PluginSecretKeychain.swift | 15 +- .../SharedAgentRuntime/PromptResources.swift | 4 + .../Contract/ConnectorContractPrompts.swift | 32 +- .../ScriptExecContract.generated.swift | 2 +- .../Sources/Contract/GuestContract.swift | 3 + .../Contract/GuestContractValidation.swift | 4 + .../Resources/contracts/host-ui-library.json | 96 +++ .../schemas/envelope-list.schema.json | 9 + .../schemas/host-ui-library.schema.json | 32 + .../schemas/host-ui-node.schema.json | 22 + .../schemas/web-search-result.schema.json | 7 + .../schemas/worker-product.schema.json | 26 +- .../DerrickDockerRuntimeIdentity.swift | 5 +- .../DockerRunnerXPC/DockerImageDigest.swift | 8 +- .../DockerProductImageDigests.generated.swift | 5 +- .../DockerRunnerXPC/DockerWorkerRuntime.swift | 21 +- .../WorkerImageFailureDisplay.swift | 47 ++ .../LLMAgentClient/AgentClientTypes.swift | 3 +- .../MCPToolCatalog/AllowedMCPTool.swift | 6 +- .../Plugin/Factory/PluginFactoryTypes.swift | 128 ++- .../Sources/Plugin/UI/HostUILibrary.swift | 206 +++++ .../AppLayerServicesWireTests.swift | 50 ++ .../ConnectorContractTests.swift | 5 + .../DockerWorkerDockerfileTests.swift | 34 + .../StructureTests/GuestContractTests.swift | 16 + .../StructureTests/HostUILibraryTests.swift | 66 ++ .../PluginSpecProcessionTests.swift | 456 +++++++++- readme.md | 2 +- scripts/prune-dangling-worker-images.sh | 45 + scripts/record-docker-image-digests.sh | 4 +- ui/MCPService/MCPServiceToolHost.swift | 18 +- .../Conversation/ConversationModel.swift | 1 + .../ConversationPipelinePolicy.swift | 2 + .../Conversation/ToolOutcomeLogger.swift | 76 ++ .../Job/JobNetworkPreflight.swift | 2 + .../Resources/mcp_tool_instructions.md | 2 +- .../Resources/web_search_skill.md | 19 + .../Services/ConnectorMessagingClient.swift | 4 +- .../Support/ConnectorAuthClassifier.swift | 24 +- .../Support/PluginFactoryModels.swift | 48 +- ui/ui.xcodeproj/project.pbxproj | 25 +- .../xcschemes/JobKeepAlive.xcscheme | 4 +- .../AgentProfileHighlightedText.swift | 30 +- .../Jobs/PluginCredentialPanelPresenter.swift | 1 + .../ConnectorCredentialService.swift | 33 +- .../Messaging/ConnectorMessagingRuntime.swift | 27 +- .../Messaging/MessagingConversationView.swift | 38 +- ui/ui/Messaging/MessagingSessionStore.swift | 21 +- ui/ui/Messaging/MessagingStore.swift | 70 +- ui/ui/Plugins/PluginCreationController.swift | 126 +-- .../PluginCreatorAccessDocsReview.swift | 130 +++ ui/ui/Plugins/PluginCredentialFieldCopy.swift | 16 + ui/ui/Plugins/PluginsWorkspaceView.swift | 18 +- ui/ui/Session/ChatSessionStore.swift | 416 ++++++++- ui/ui/Views/ChatTabBarView.swift | 1 + .../ConnectorCredentialsSettingsView.swift | 18 +- ui/ui/Views/ContentView.swift | 71 +- ui/ui/Views/PluginCreatorIntroHeader.swift | 44 + ui/ui/Views/SidebarView.swift | 16 +- ui/uiTests/ChatTabRoutingTests.swift | 156 ++++ ui/uiTests/MessagingMarkdownTextTests.swift | 20 + ui/uiTests/PromptResourcesTests.swift | 14 + ui/uiTests/uiTests.swift | 24 + workers/go/cmd/derrick-web-search/main.go | 94 +++ workers/go/internal/contract/contract.go | 5 + workers/go/internal/contract/contract_test.go | 14 + .../schemas/envelope-list.schema.json | 9 + .../schemas/host-ui-library.schema.json | 32 + .../contract/schemas/host-ui-node.schema.json | 22 + .../schemas/web-search-result.schema.json | 7 + .../schemas/worker-product.schema.json | 26 +- workers/go/internal/search/engine.go | 138 +++ workers/go/internal/search/parse.go | 101 +++ workers/go/internal/search/parse_test.go | 64 ++ workers/go/internal/search/types.go | 46 + workers/go/internal/search/validate.go | 61 ++ 168 files changed, 18395 insertions(+), 656 deletions(-) create mode 100644 .cursor/skills/swiftui-expert-skill/SKILL.md create mode 100644 .cursor/skills/swiftui-expert-skill/agents/openai.yaml create mode 100644 .cursor/skills/swiftui-expert-skill/assets/logo-small.png create mode 100644 .cursor/skills/swiftui-expert-skill/assets/logo.png create mode 100644 .cursor/skills/swiftui-expert-skill/assets/logo.svg create mode 100644 .cursor/skills/swiftui-expert-skill/references/accessibility-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/animation-advanced.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/animation-basics.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/animation-transitions.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/charts-accessibility.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/charts.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/document-apps.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/environment-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/focus-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/image-optimization.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/latest-apis.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/layout-best-practices.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/liquid-glass.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/list-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/localization.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/macos-scenes.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/macos-views.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/macos-window-styling.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/modifier-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/performance-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/previews.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/scroll-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/soft-deprecation.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/state-management.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/styled-text-editing.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/text-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/toolbar-patterns.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/trace-analysis.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/trace-recording.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/view-structure.md create mode 100644 .cursor/skills/swiftui-expert-skill/references/webkit-integration.md create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/analyze_trace.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/__init__.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/causes.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/correlate.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/events.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hangs.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hitches.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/summary.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/swiftui.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/time_profiler.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xctrace.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xml_utils.py create mode 100644 .cursor/skills/swiftui-expert-skill/scripts/record_trace.py create mode 100644 packages/DerrickBackend/Sources/DerrickBackend/VendorDocsFetch.swift create mode 100644 packages/DerrickBackend/Tests/DerrickBackendTests/VendorDocsFetchTests.swift create mode 100644 packages/MCPServer/Sources/MCPServer/WebSearchDockerExecutor.swift create mode 100644 packages/MCPServer/Sources/MCPServer/WebSearchToolModule.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/ConnectorAuthPreference.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/PluginAccessAskPolicy.swift create mode 100644 packages/Structure/Sources/AppLayerServices/Plugin/VendorDocsLocator.swift create mode 100644 packages/Structure/Sources/Contract/Resources/contracts/host-ui-library.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/host-ui-library.schema.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/host-ui-node.schema.json create mode 100644 packages/Structure/Sources/Contract/Resources/schemas/web-search-result.schema.json create mode 100644 packages/Structure/Sources/DockerRunnerXPC/WorkerImageFailureDisplay.swift create mode 100644 packages/Structure/Sources/Plugin/UI/HostUILibrary.swift create mode 100644 packages/Structure/Tests/StructureTests/DockerWorkerDockerfileTests.swift create mode 100644 packages/Structure/Tests/StructureTests/HostUILibraryTests.swift create mode 100755 scripts/prune-dangling-worker-images.sh create mode 100644 ui/SharedAgentRuntime/Resources/web_search_skill.md create mode 100644 ui/ui/Plugins/PluginCreatorAccessDocsReview.swift create mode 100644 ui/ui/Plugins/PluginCredentialFieldCopy.swift create mode 100644 ui/ui/Views/PluginCreatorIntroHeader.swift create mode 100644 ui/uiTests/MessagingMarkdownTextTests.swift create mode 100644 workers/go/cmd/derrick-web-search/main.go create mode 100644 workers/go/internal/contract/schemas/host-ui-library.schema.json create mode 100644 workers/go/internal/contract/schemas/host-ui-node.schema.json create mode 100644 workers/go/internal/contract/schemas/web-search-result.schema.json create mode 100644 workers/go/internal/search/engine.go create mode 100644 workers/go/internal/search/parse.go create mode 100644 workers/go/internal/search/parse_test.go create mode 100644 workers/go/internal/search/types.go create mode 100644 workers/go/internal/search/validate.go diff --git a/.cursor/skills/swiftui-expert-skill/SKILL.md b/.cursor/skills/swiftui-expert-skill/SKILL.md new file mode 100644 index 00000000..6fc4cd2a --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/SKILL.md @@ -0,0 +1,150 @@ +--- +name: swiftui-expert-skill +description: Use when writing, reviewing, or refactoring SwiftUI code for iOS or macOS, including state and `@Observable` data flow, view composition, performance, lists, environment, localization, animation, Liquid Glass, and API migration. Also use for `@State` initialization or synthesized-property diagnostics, `@ContentBuilder` ambiguity, `reorderable` drag/drop, custom `AsyncImage` `URLSession`, swipe actions outside List, item-bound `alert`/`confirmationDialog`, `ToolbarOverflowMenu`, `AnimatableValues`, Document APIs (`Document`/`DocumentReader`), and Instruments `.trace` capture or analysis. +--- + +# SwiftUI Expert Skill + +## Operating Rules + +- Treat each `View` type as an invalidation boundary: give it only the data it reads and keep frequently changing dependencies close to the smallest affected subtree +- Search `references/latest-apis.md` when writing, reviewing, or migrating API usage; look up only the APIs relevant to the task +- Replace hard-deprecated APIs with modern equivalents. During feature work, flag soft-deprecated APIs and leave them in place (see `references/soft-deprecation.md`) +- Prefer native SwiftUI APIs over UIKit/AppKit bridging unless bridging is necessary +- Focus on correctness and performance; do not enforce specific architectures (MVVM, VIPER, etc.) +- Encourage separating business logic from views for testability without mandating how +- Follow Apple's Human Interface Guidelines and API design patterns +- Only adopt Liquid Glass when explicitly requested by the user (see `references/liquid-glass.md`) +- Present performance optimizations as suggestions, not requirements +- Use `#available` gating with sensible fallbacks for version-specific APIs + +## Task Workflow + +### Review existing SwiftUI code +- Read the code under review and identify which topics apply +- Flag deprecated APIs (compare against `references/latest-apis.md`); replace hard-deprecated APIs, and flag soft-deprecated APIs without rewriting them unless the user asked to migrate +- Run the Topic Router below for each relevant topic +- Validate `#available` gating and fallback paths for version-specific features +- For broad codebase reviews, first identify smaller focus areas and present them one at a time; if the user requests a whole-codebase review, divide it into a TODO list + +### Improve existing SwiftUI code +- Audit current implementation against the Topic Router topics +- Replace hard-deprecated APIs with modern equivalents from `references/latest-apis.md`; flag soft-deprecated APIs and do not rewrite them during feature work +- Refactor hot paths to reduce unnecessary state updates +- Extract complex view bodies into separate subviews +- Suggest image downsampling when `UIImage(data:)` is encountered (optional optimization, see `references/image-optimization.md`) + +### Implement new SwiftUI feature +- Design data flow first: identify owned vs injected state +- Structure views for optimal diffing (extract subviews early) +- Apply correct animation patterns (implicit vs explicit, transitions) +- Use `Button` for all tappable elements; add accessibility grouping and labels +- Gate version-specific APIs with `#available` and provide fallbacks + +### Record a new Instruments trace +Trigger when the user asks to "record a trace", "profile the app", "capture a session", etc. Full reference: `references/trace-recording.md`. + +1. **Confirm target** — attach to a running app, launch an app, or record all processes? If the user didn't say, ask. List connected devices when useful: + ```bash + python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices + ``` +2. **Pick a template based on target kind** — the `SwiftUI` template populates the SwiftUI lane on any **real device**: a physical iOS/iPadOS device **or the host Mac**. The only exception is the **iOS Simulator**, where the SwiftUI lane comes back empty — switch to `--template "Time Profiler"` in that case (still gives Time Profiler + Hangs + Animation Hitches). Always check `--list-devices`: `simulators` kind → `Time Profiler`; `devices` kind (real devices and the host Mac) → default `SwiftUI`. Full decision table in `references/trace-recording.md`. +3. **Start the recording**. For agent-driven sessions where the user says "I'll tell you when I'm done", start in the background and use a stop-file: + ```bash + python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --device "" --attach "" \ + --stop-file /tmp/stop-trace --output ~/Desktop/session.trace + ``` + For interactive sessions, just tell the user to press Ctrl+C when done. +4. **Signal stop** — when the user says they've finished exercising the app, `touch /tmp/stop-trace`. The script cleanly SIGINTs xctrace and waits up to 60s for finalisation. +5. **Analyse** the resulting trace (flow into the "Trace-driven improvement" workflow below). + +### Trace-driven improvement (Instruments `.trace` provided) +Trigger whenever the user's request references a `.trace` file. A target SwiftUI source file is **optional** — if given, cite specific lines; if not, recommend where to look based on view names and symbols the trace already reveals. + +Full reference: `references/trace-analysis.md`. Summary of the composition pattern: + +1. **Scope the analysis.** Ask yourself: does the user want the whole trace, or a slice? + - "focus on X / after X / between X and Y / during X" → **resolve to a window first** (see step 2). + - No scoping cue → analyse the whole trace. +2. **Resolve a window (only if the user scoped).** The parser exposes two discovery modes: + ```bash + # Find a log that marks the start/end of the region of interest: + python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --list-logs --log-message-contains "loaded feed" --log-limit 5 + # Or list os_signpost intervals (paired begin/end), filterable by name: + python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --list-signposts --signpost-name-contains "ImageDecode" + ``` + Both modes accept `--window START_MS:END_MS` to scope discovery. Pick the `time_ms` (for logs) or `start_ms`/`end_ms` (for signposts) that match the user's description. Build a window like `--window 10400:11700`. +3. **Run the main analysis** (with or without `--window`): + ```bash + python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --json-only --top 10 [--window START_MS:END_MS] + ``` +4. **Interpret with `references/trace-analysis.md`** — key diagnostics: + - `main_running_coverage_pct` inside each correlation (<25% = blocked; ≥75% = CPU-bound). + - `swiftui-causes.top_sources` reveals *why* updates keep happening — high-edge-count sources like `UserDefaultObserver.send()` or wide `EnvironmentWriter` entries are structural invalidation bugs. Fixing one often collapses many downstream hot views. +5. **When a specific view shows as expensive, ask who's invalidating it.** Use `--fanin-for ""` to get the ranked list of source nodes driving the updates. +6. **Optionally ground in source.** If the user pointed at a file, read it and match view names / user-code symbols against identifiers there. If not, recommend which files to open based on the view names SwiftUI reported. +7. **Return a prioritised plan.** Cite evidence (coverage %, hot symbol, overlapping view, log timestamp, cause-graph edges) and route each recommendation to a Topic Router reference. +8. Only edit code if the user asked for edits. + +### Topic Router + +Consult the reference file for each topic relevant to the current task: + +| Topic | Reference | +|-------|-----------| +| State management | `references/state-management.md` | +| Environment and `@Entry` | `references/environment-patterns.md` | +| View composition | `references/view-structure.md` | +| View modifiers and identity | `references/modifier-patterns.md` | +| Performance | `references/performance-patterns.md` | +| Lists and ForEach | `references/list-patterns.md` | +| Layout | `references/layout-best-practices.md` | +| Sheets and navigation | `references/sheet-navigation-patterns.md` | +| ScrollView, scroll position, and scroll geometry | `references/scroll-patterns.md` | +| Focus management | `references/focus-patterns.md` | +| Animations (basics) | `references/animation-basics.md` | +| Animations (transitions) | `references/animation-transitions.md` | +| Animations (advanced) | `references/animation-advanced.md` | +| Accessibility | `references/accessibility-patterns.md` | +| Swift Charts | `references/charts.md` | +| Charts accessibility | `references/charts-accessibility.md` | +| Image optimization | `references/image-optimization.md` | +| Toolbars | `references/toolbar-patterns.md` | +| Document-based apps | `references/document-apps.md` | +| WebKit | `references/webkit-integration.md` | +| Styled text editing | `references/styled-text-editing.md` | +| Liquid Glass (iOS 26+) | `references/liquid-glass.md` | +| macOS scenes | `references/macos-scenes.md` | +| macOS window styling | `references/macos-window-styling.md` | +| macOS views | `references/macos-views.md` | +| Text patterns | `references/text-patterns.md` | +| Localization | `references/localization.md` | +| Deprecated API lookup | `references/latest-apis.md` | +| Handling soft-deprecated APIs | `references/soft-deprecation.md` | +| Previews | `references/previews.md` | +| Instruments trace analysis | `references/trace-analysis.md` | +| Instruments trace recording | `references/trace-recording.md` | + +## Correctness Checklist + +These are hard rules -- violations are always bugs: + +- [ ] `@State` properties are `private` +- [ ] `@Binding` only where a child modifies parent state +- [ ] Changing parent-owned inputs are not stored as `@State`/`@StateObject`; intentional state seeds are documented as one-time +- [ ] `@StateObject` for view-owned objects; `@ObservedObject` for injected +- [ ] iOS 17+: `@State` with `@Observable`; `@Bindable` for injected observables needing bindings +- [ ] `ForEach` uses stable identity (never `.indices`/`\.offset`; id outlives the view and isn't derived from mutable content) +- [ ] Constant number of views per `ForEach` element; `List` rows are unary +- [ ] No closures stored in custom `@Environment`/`@FocusedValue` keys +- [ ] Custom `@Entry` default values are stable (no `Model()`/`Date()`/`UUID()` expressions) +- [ ] `.animation(_:value:)` always includes the `value` parameter +- [ ] `@FocusState` properties are `private` +- [ ] No redundant `@FocusState` writes inside tap gesture handlers on `.focusable()` views +- [ ] Version-specific APIs are gated with `#available` and have sensible fallbacks +- [ ] `import Charts` present in files using chart types +- [ ] Previews use self-contained mock data; no dependency on live services or network diff --git a/.cursor/skills/swiftui-expert-skill/agents/openai.yaml b/.cursor/skills/swiftui-expert-skill/agents/openai.yaml new file mode 100644 index 00000000..dc0a5c53 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "SwiftUI Expert" + short_description: "Review SwiftUI invalidation, state flow, and modern SDK APIs." + icon_small: "./assets/logo-small.png" + icon_large: "./assets/logo.png" + brand_color: "#006BFF" diff --git a/.cursor/skills/swiftui-expert-skill/assets/logo-small.png b/.cursor/skills/swiftui-expert-skill/assets/logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..58c6eb6734ee6246ebfba0f551f955bd879578e7 GIT binary patch literal 29649 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 J<&s80{|DSKMvDLd literal 0 HcmV?d00001 diff --git a/.cursor/skills/swiftui-expert-skill/assets/logo.png b/.cursor/skills/swiftui-expert-skill/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7af7d764198eff72132f6da95e6ac213898cb288 GIT binary patch literal 398841 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

X+eLxg@4{pWELqPicvGhqFbpaoRt6o;kF*m9 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π@Pl!qoCc{Q<554nDkpIz(~QVPrs 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 j; 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 + + SwiftUI SVG Cursor Logo + + + + + + + + + + + + + diff --git a/.cursor/skills/swiftui-expert-skill/references/accessibility-patterns.md b/.cursor/skills/swiftui-expert-skill/references/accessibility-patterns.md new file mode 100644 index 00000000..6f0ea0c1 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/accessibility-patterns.md @@ -0,0 +1,215 @@ +# SwiftUI Accessibility Patterns Reference + +## Table of Contents + +- [Core Principle](#core-principle) +- [Dynamic Type and @ScaledMetric](#dynamic-type-and-scaledmetric) +- [Accessibility Traits](#accessibility-traits) +- [Decorative Images](#decorative-images) +- [Element Grouping](#element-grouping) +- [Custom Controls](#custom-controls) +- [Summary Checklist](#summary-checklist) + +## Core Principle + +Prefer `Button` over `onTapGesture` for tappable elements. `Button` provides VoiceOver support, focus handling, and proper traits for free. + +## Dynamic Type and @ScaledMetric + +System text styles scale with Dynamic Type automatically. Prefer built-in styles like `.largeTitle`, `.title`, `.title2`, `.title3`, `.headline`, `.subheadline`, `.body`, `.callout`, `.footnote`, `.caption`, and `.caption2` when they fit your UI: + +```swift +VStack(alignment: .leading) { + Text("Inbox") + .font(.title2) + Text("3 unread messages") + .font(.body) + Text("Updated just now") + .font(.caption) +} +``` + +For custom fonts, use a Dynamic Type-aware font initializer so the text still follows the user's preferred content size: + +```swift +VStack(alignment: .leading) { + Text("Article") + .font(.custom("SourceSerif4-Semibold", size: 28, relativeTo: .title2)) + Text("Body copy") + .font(.custom("SourceSerif4-Regular", size: 17)) +} +``` + +`Font.custom(_:size:relativeTo:)` lets you match a specific text style. `Font.custom(_:size:)` scales relative to the body style. Avoid fixed-size custom fonts for primary content that should respond to Dynamic Type. + +For non-text numeric values like padding, spacing, and image sizes, use `@ScaledMetric`: + +```swift +struct ProfileHeader: View { + @ScaledMetric private var avatarSize = 60.0 + @ScaledMetric private var spacing = 12.0 + + var body: some View { + HStack(spacing: spacing) { + Image("avatar") + .resizable() + .frame(width: avatarSize, height: avatarSize) + Text("Username") + } + } +} +``` + +Specify a `relativeTo` text style when the value should track a specific Dynamic Type style, including for images or icons that should stay proportional to nearby text: + +```swift +struct StatusRow: View { + @ScaledMetric(relativeTo: .body) private var iconSize = 18.0 + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: iconSize)) + Text("Synced") + .font(.custom("AvenirNext-Regular", size: 17, relativeTo: .body)) + } + } +} +``` + +## Accessibility Traits + +Use `accessibilityAddTraits` and `accessibilityRemoveTraits` for state-driven traits: + +```swift +Text(item.title) + .accessibilityAddTraits(item.isSelected ? [.isSelected, .isButton] : .isButton) +``` + +Use `.disabled(true)` to make VoiceOver announce "Dimmed" for non-interactive elements. + +## Decorative Images + +Use `Image(decorative:bundle:)` when an asset image is purely visual and should not appear in the accessibility tree. + +```swift +Image(decorative: "confetti") +``` + +This is appropriate for backgrounds, flourishes, and icons that do not add meaning beyond nearby text. + +If the image conveys information, keep it accessible and provide a clear label: + +```swift +Image("receipt") + .accessibilityLabel("Receipt") +``` + +For non-asset images, such as SF Symbols, hide decorative content with `accessibilityHidden(true)` instead: + +```swift +Image(systemName: "sparkles") + .accessibilityHidden(true) +``` + +## Element Grouping + +### .combine -- Auto-join child labels + +```swift +HStack { + Image(systemName: "star.fill") + Text("Favorites") + Text("(\(count))") +} +.accessibilityElement(children: .combine) +``` + +VoiceOver reads all child labels as one element, separated by commas. + +### .ignore -- Manual label for container + +```swift +HStack { + Text(item.name) + Spacer() + Text(item.price) +} +.accessibilityElement(children: .ignore) +.accessibilityLabel("\(item.name), \(item.price)") +``` + +### .contain -- Semantic grouping + +```swift +HStack { + ForEach(tabs) { tab in + TabButton(tab: tab) + } +} +.accessibilityElement(children: .contain) +.accessibilityLabel("Tab bar") +``` + +VoiceOver announces the container name when focus enters/exits. + +## Custom Controls + +### Adjustable controls (increment/decrement) + +```swift +PageControl(selectedIndex: $selectedIndex, pageCount: pageCount) + .accessibilityElement() + .accessibilityValue("Page \(selectedIndex + 1) of \(pageCount)") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + guard selectedIndex < pageCount - 1 else { break } + selectedIndex += 1 + case .decrement: + guard selectedIndex > 0 else { break } + selectedIndex -= 1 + @unknown default: + break + } + } +``` + +### Representing custom views as native controls + +When a custom view should behave like a native control for accessibility: + +```swift +HStack { + Text(label) + Toggle("", isOn: $isOn) +} +.accessibilityRepresentation { + Toggle(label, isOn: $isOn) +} +``` + +### Label-content pairing + +```swift +@Namespace private var ns + +HStack { + Text("Volume") + .accessibilityLabeledPair(role: .label, id: "volume", in: ns) + Slider(value: $volume) + .accessibilityLabeledPair(role: .content, id: "volume", in: ns) +} +``` + +## Summary Checklist + +- [ ] Use `Button` instead of `onTapGesture` for tappable elements +- [ ] Use built-in text styles or Dynamic Type-aware custom fonts for text +- [ ] Use `@ScaledMetric` for custom values that should scale with Dynamic Type +- [ ] Mark purely decorative images as decorative or hidden from accessibility +- [ ] Group related elements with `accessibilityElement(children:)` +- [ ] Provide `accessibilityLabel` when default labels are unclear +- [ ] Use `accessibilityRepresentation` for custom controls +- [ ] Use `accessibilityAdjustableAction` for increment/decrement controls +- [ ] Ensure navigation flow is logical when using VoiceOver grouping diff --git a/.cursor/skills/swiftui-expert-skill/references/animation-advanced.md b/.cursor/skills/swiftui-expert-skill/references/animation-advanced.md new file mode 100644 index 00000000..94256396 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/animation-advanced.md @@ -0,0 +1,429 @@ +# SwiftUI Advanced Animations + +Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), completion handlers (iOS 17+), and `@Animatable` macro (iOS 26+). + +## Table of Contents +- [Transactions](#transactions) +- [Phase Animations (iOS 17+)](#phase-animations-ios-17) +- [Keyframe Animations (iOS 17+)](#keyframe-animations-ios-17) +- [Animation Completion Handlers (iOS 17+)](#animation-completion-handlers-ios-17) +- [@Animatable Macro (iOS 26+)](#animatable-macro-ios-26) + +--- + +## Transactions + +The underlying mechanism for all animations in SwiftUI. + +### Basic Usage + +```swift +// withAnimation is shorthand for withTransaction +withAnimation(.default) { flag.toggle() } + +// Equivalent explicit transaction +var transaction = Transaction(animation: .default) +withTransaction(transaction) { flag.toggle() } +``` + +### The .transaction Modifier + +```swift +Rectangle() + .frame(width: flag ? 100 : 50, height: 50) + .transaction { t in + t.animation = .default + } +``` + +**Note:** This behaves like the deprecated `.animation(_:)` without value parameter - it animates on every state change. + +### Animation Precedence + +**Implicit animations override explicit animations** (later in view tree wins). + +```swift +Button("Tap") { + withAnimation(.linear) { flag.toggle() } +} +.animation(.bouncy, value: flag) // .bouncy wins! +``` + +### Disabling Animations + +```swift +// Prevent implicit animations from overriding +.transaction { t in + t.disablesAnimations = true +} + +// Remove animation entirely +.transaction { $0.animation = nil } +``` + +### Custom Transaction Keys (iOS 17+) + +Pass metadata through transactions. + +```swift +struct ChangeSourceKey: TransactionKey { + static let defaultValue: String = "unknown" +} + +extension Transaction { + var changeSource: String { + get { self[ChangeSourceKey.self] } + set { self[ChangeSourceKey.self] = newValue } + } +} + +// Set source +var transaction = Transaction(animation: .default) +transaction.changeSource = "server" +withTransaction(transaction) { flag.toggle() } + +// Read in view tree +.transaction { t in + if t.changeSource == "server" { + t.animation = .smooth + } else { + t.animation = .bouncy + } +} +``` + +--- + +## Phase Animations (iOS 17+) + +Cycle through discrete phases automatically. Each phase change is a separate animation. + +### Basic Usage + +```swift +// GOOD - triggered phase animation +Button("Shake") { trigger += 1 } + .phaseAnimator( + [0.0, -10.0, 10.0, -5.0, 5.0, 0.0], + trigger: trigger + ) { content, offset in + content.offset(x: offset) + } + +// Infinite loop (no trigger) +Circle() + .phaseAnimator([1.0, 1.2, 1.0]) { content, scale in + content.scaleEffect(scale) + } +``` + +### Enum Phases (Recommended for Clarity) + +```swift +// GOOD - enum phases are self-documenting +enum BouncePhase: CaseIterable { + case initial, up, down, settle + + var scale: CGFloat { + switch self { + case .initial: 1.0 + case .up: 1.2 + case .down: 0.9 + case .settle: 1.0 + } + } +} + +Circle() + .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in + content.scaleEffect(phase.scale) + } +``` + +### Custom Timing Per Phase + +```swift +.phaseAnimator([0, -20, 20], trigger: trigger) { content, offset in + content.offset(x: offset) +} animation: { phase in + switch phase { + case -20: .bouncy + case 20: .linear + default: .smooth + } +} +``` + +### Good vs Bad + +```swift +// GOOD - use phaseAnimator for multi-step sequences +.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in + content.offset(x: offset) +} + +// BAD - manual DispatchQueue sequencing +Button("Animate") { + withAnimation(.easeOut(duration: 0.1)) { offset = -10 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { offset = 10 } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation { offset = 0 } + } +} +``` + +--- + +## Keyframe Animations (iOS 17+) + +Precise timing control with exact values at specific times. + +### Basic Usage + +```swift +Button("Bounce") { trigger += 1 } + .keyframeAnimator( + initialValue: AnimationValues(), + trigger: trigger + ) { content, value in + content + .scaleEffect(value.scale) + .offset(y: value.verticalOffset) + } keyframes: { _ in + KeyframeTrack(\.scale) { + SpringKeyframe(1.2, duration: 0.15) + SpringKeyframe(0.9, duration: 0.1) + SpringKeyframe(1.0, duration: 0.15) + } + KeyframeTrack(\.verticalOffset) { + LinearKeyframe(-20, duration: 0.15) + LinearKeyframe(0, duration: 0.25) + } + } + +struct AnimationValues { + var scale: CGFloat = 1.0 + var verticalOffset: CGFloat = 0 +} +``` + +### Keyframe Types + +| Type | Behavior | +|------|----------| +| `CubicKeyframe` | Smooth interpolation | +| `LinearKeyframe` | Straight-line interpolation | +| `SpringKeyframe` | Spring physics | +| `MoveKeyframe` | Instant jump (no interpolation) | + +### Multiple Synchronized Tracks + +Tracks run **in parallel**, each animating one property. + +```swift +// GOOD - bell shake with synchronized rotation and scale +struct BellAnimation { + var rotation: Double = 0 + var scale: CGFloat = 1.0 +} + +Image(systemName: "bell.fill") + .keyframeAnimator( + initialValue: BellAnimation(), + trigger: trigger + ) { content, value in + content + .rotationEffect(.degrees(value.rotation)) + .scaleEffect(value.scale) + } keyframes: { _ in + KeyframeTrack(\.rotation) { + CubicKeyframe(15, duration: 0.1) + CubicKeyframe(-15, duration: 0.1) + CubicKeyframe(10, duration: 0.1) + CubicKeyframe(-10, duration: 0.1) + CubicKeyframe(0, duration: 0.1) + } + KeyframeTrack(\.scale) { + CubicKeyframe(1.1, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } + } + +// BAD - manual timer-based animation +Image(systemName: "bell.fill") + .onTapGesture { + withAnimation(.easeOut(duration: 0.1)) { rotation = 15 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { rotation = -15 } + } + // ... more manual timing - error prone + } +``` + +### KeyframeTimeline (iOS 17+) + +Query animation values directly for testing or non-SwiftUI use. + +```swift +let timeline = KeyframeTimeline(initialValue: AnimationValues()) { + KeyframeTrack(\.scale) { + CubicKeyframe(1.2, duration: 0.25) + CubicKeyframe(1.0, duration: 0.25) + } +} + +let midpoint = timeline.value(time: 0.25) +print(midpoint.scale) // Value at 0.25 seconds +``` + +--- + +## Animation Completion Handlers (iOS 17+) + +Execute code when animations finish. + +### With withAnimation + +```swift +// GOOD - completion with withAnimation +Button("Animate") { + withAnimation(.spring) { + isExpanded.toggle() + } completion: { + showNextStep = true + } +} +``` + +### With Transaction (For Reexecution) + +```swift +// GOOD - completion fires on every trigger change +Circle() + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .transaction(value: bounceCount) { transaction in + transaction.animation = .spring + transaction.addAnimationCompletion { + message = "Bounce \(bounceCount) complete" + } + } + +// BAD - completion only fires ONCE (no value parameter) +Circle() + .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2) + .animation(.spring, value: bounceCount) + .transaction { transaction in // No value! + transaction.addAnimationCompletion { + completionCount += 1 // Only fires once, ever + } + } +``` + +--- + +## @Animatable Macro (iOS 26+) + +The `@Animatable` macro auto-synthesizes `animatableData` from all animatable stored properties, eliminating verbose manual conformance. Use `@AnimatableIgnored` to exclude properties that should not animate. + +### Before (Manual) + +```swift +struct Wedge: Shape { + var startAngle: Angle + var endAngle: Angle + var drawClockwise: Bool + + var animatableData: AnimatablePair { + get { AnimatablePair(startAngle.radians, endAngle.radians) } + set { + startAngle = .radians(newValue.first) + endAngle = .radians(newValue.second) + } + } + + func path(in rect: CGRect) -> Path { /* ... */ } +} +``` + +### After (@Animatable) + +```swift +@Animatable +struct Wedge: Shape { + var startAngle: Angle + var endAngle: Angle + @AnimatableIgnored var drawClockwise: Bool + + func path(in rect: CGRect) -> Path { /* ... */ } +} +``` + +### When to Use +- **Prefer `@Animatable`** for any custom `Shape` or type conforming to `Animatable` with multiple properties +- **Conform `ViewModifier` types to `Animatable` directly** — not `AnimatableModifier` (soft-deprecated in SDK 27) +- **Use `@AnimatableIgnored`** for properties that control behavior but should not interpolate (e.g., directions, flags, identifiers) +- The macro works with any type conforming to `Animatable`, not just `Shape` + +> Source: "What's new in SwiftUI" (WWDC25, session 256) + +### When to Implement `animatableData` Manually + +Reach for an explicit `animatableData` (instead of the macro) when the interpolated value needs custom logic that doesn't map 1:1 to a stored property — normalization, clamping, or driving a derived value. For a deployment target of iOS 26+, use `AnimatableValues`; for earlier targets, use `AnimatablePair`. + +```swift +// iOS 26+: keep phase in 0..<2π and clamp amplitude during interpolation +struct WaveShape: Shape { + var amplitude: CGFloat + var phase: CGFloat + var maxAmplitude: CGFloat + + var animatableData: AnimatableValues { + get { AnimatableValues(amplitude, phase) } + set { + amplitude = min(max(newValue.value.0, 0), maxAmplitude) + phase = newValue.value.1.truncatingRemainder(dividingBy: 2 * .pi) + } + } + + func path(in rect: CGRect) -> Path { /* ... */ } +} +``` + +On earlier deployment targets, the same logic uses `AnimatablePair` with `newValue.first` / `newValue.second`. + +--- + +## Quick Reference + +### Transactions (All iOS versions) +- `withTransaction` is the explicit form of `withAnimation` +- Implicit animations override explicit (later in view tree wins) +- Use `disablesAnimations` to prevent override +- Use `.transaction { $0.animation = nil }` to remove animation + +### Custom Transaction Keys (iOS 17+) +- Pass metadata through animation system via `TransactionKey` + +### Phase Animations (iOS 17+) +- Use for multi-step sequences returning to start +- Prefer enum phases for clarity +- Each phase change is a separate animation +- Use `trigger` parameter for one-shot animations + +### Keyframe Animations (iOS 17+) +- Use for precise timing control +- Tracks run in parallel +- Use `KeyframeTimeline` for testing/advanced use +- Prefer over manual DispatchQueue timing + +### Completion Handlers (iOS 17+) +- Use `withAnimation(.animation) { } completion: { }` for one-shot completion handlers +- Use `.transaction(value:)` for handlers that should refire on every value change +- Without `value:` parameter, completion only fires once + +### @Animatable Macro (iOS 26+) +- Use `@Animatable` to auto-synthesize `animatableData` from stored properties +- Use `@AnimatableIgnored` to exclude non-animatable properties +- Replaces verbose manual `animatableData` getters/setters diff --git a/.cursor/skills/swiftui-expert-skill/references/animation-basics.md b/.cursor/skills/swiftui-expert-skill/references/animation-basics.md new file mode 100644 index 00000000..859682a9 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/animation-basics.md @@ -0,0 +1,284 @@ +# SwiftUI Animation Basics + +Core animation concepts, implicit vs explicit animations, timing curves, and performance patterns. + +## Table of Contents +- [Core Concepts](#core-concepts) +- [Implicit Animations](#implicit-animations) +- [Explicit Animations](#explicit-animations) +- [Animation Placement](#animation-placement) +- [Selective Animation](#selective-animation) +- [Timing Curves](#timing-curves) +- [Animation Performance](#animation-performance) +- [Disabling Animations](#disabling-animations) +- [Debugging](#debugging) + +--- + +## Core Concepts + +State changes trigger view updates. SwiftUI provides mechanisms to animate these changes. + +**Animation Process:** +1. State change triggers view tree re-evaluation +2. SwiftUI compares new tree to current render tree +3. Animatable properties are identified and interpolated (~60 fps) + +**Key Characteristics:** +- Animations are additive and cancelable +- Always start from current render tree state +- Blend smoothly when interrupted + +--- + +## Implicit Animations + +Use `.animation(_:value:)` to animate when a specific value changes. + +```swift +// GOOD - uses value parameter +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + .onTapGesture { isExpanded.toggle() } + +// BAD - deprecated, animates all changes unexpectedly +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring) // Deprecated! +``` + +--- + +## Explicit Animations + +Use `withAnimation` for event-driven state changes. + +```swift +// GOOD - explicit animation +Button("Toggle") { + withAnimation(.spring) { + isExpanded.toggle() + } +} + +// BAD - no animation context +Button("Toggle") { + isExpanded.toggle() // Abrupt change +} +``` + +**When to use which:** +- **Implicit**: Animations tied to specific value changes, precise view tree scope +- **Explicit**: Event-driven animations (button taps, gestures) + +--- + +## Animation Placement + +Place animation modifiers after the properties they should animate. + +```swift +// GOOD - animation after properties +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .foregroundStyle(isExpanded ? .blue : .red) + .animation(.default, value: isExpanded) // Animates both + +// BAD - animation before properties +Rectangle() + .animation(.default, value: isExpanded) // Too early! + .frame(width: isExpanded ? 200 : 100, height: 50) +``` + +--- + +## Selective Animation + +Animate only specific properties using multiple animation modifiers or scoped animations. + +```swift +// GOOD - selective animation +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) // Animate size + .foregroundStyle(isExpanded ? .blue : .red) + .animation(nil, value: isExpanded) // Don't animate color + +// iOS 17+ scoped animation +Rectangle() + .foregroundStyle(isExpanded ? .blue : .red) // Not animated + .animation(.spring) { + $0.frame(width: isExpanded ? 200 : 100, height: 50) // Animated + } +``` + +--- + +## Timing Curves + +### Built-in Curves + +| Curve | Use Case | +|-------|----------| +| `.spring` | Interactive elements, most UI | +| `.easeInOut` | Appearance changes | +| `.bouncy` | Playful feedback (iOS 17+) | +| `.linear` | Progress indicators only | + +### Modifiers + +```swift +.animation(.default.speed(2.0), value: flag) // 2x faster +.animation(.default.delay(0.5), value: flag) // Delayed start +.animation(.default.repeatCount(3, autoreverses: true), value: flag) +``` + +### Good vs Bad Timing + +```swift +// GOOD - appropriate timing for interaction type +Button("Tap") { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isActive.toggle() + } +} +.scaleEffect(isActive ? 0.95 : 1.0) + +// BAD - too slow for button feedback +Button("Tap") { + withAnimation(.easeInOut(duration: 1.0)) { // Way too slow! + isActive.toggle() + } +} + +// BAD - linear feels robotic +Rectangle() + .animation(.linear(duration: 0.5), value: isActive) // Mechanical +``` + +--- + +## Animation Performance + +### Prefer Transforms Over Layout + +```swift +// GOOD - GPU accelerated transforms +Rectangle() + .frame(width: 100, height: 100) + .scaleEffect(isActive ? 1.5 : 1.0) // Fast + .offset(x: isActive ? 50 : 0) // Fast + .rotationEffect(.degrees(isActive ? 45 : 0)) // Fast + .animation(.spring, value: isActive) + +// BAD - layout changes are expensive +Rectangle() + .frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100) // Expensive + .padding(isActive ? 50 : 0) // Expensive +``` + +### Narrow Animation Scope + +```swift +// GOOD - animation scoped to specific subview +VStack { + HeaderView() // Not affected + ExpandableContent(isExpanded: isExpanded) + .animation(.spring, value: isExpanded) // Only this + FooterView() // Not affected +} + +// BAD - animation at root +VStack { + HeaderView() + ExpandableContent(isExpanded: isExpanded) + FooterView() +} +.animation(.spring, value: isExpanded) // Animates everything +``` + +### Avoid Animation in Hot Paths + +```swift +// GOOD - gate by threshold +.onPreferenceChange(ScrollOffsetKey.self) { offset in + let shouldShow = offset.y < -50 + if shouldShow != showTitle { // Only when crossing threshold + withAnimation(.easeOut(duration: 0.2)) { + showTitle = shouldShow + } + } +} + +// BAD - animating every scroll change +.onPreferenceChange(ScrollOffsetKey.self) { offset in + withAnimation { // Fires constantly! + self.offset = offset.y + } +} +``` + +--- + +## Disabling Animations + +```swift +// GOOD - disable with transaction +Text("Count: \(count)") + .transaction { $0.animation = nil } + +// GOOD - disable from parent context +DataView() + .transaction { $0.disablesAnimations = true } + +// BAD - hacky zero duration +Text("Count: \(count)") + .animation(.linear(duration: 0), value: count) // Hacky +``` + +--- + +## Debugging + +```swift +// Slow down for inspection +#if DEBUG +.animation(.linear(duration: 3.0).speed(0.2), value: isExpanded) +#else +.animation(.spring, value: isExpanded) +#endif + +// Debug modifier to log values +struct AnimationDebugModifier: ViewModifier, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { + value = newValue + print("Animation: \(newValue)") + } + } + func body(content: Content) -> some View { + content.opacity(value) + } +} +``` + +--- + +## Quick Reference + +### Do +- Use `.animation(_:value:)` with value parameter +- Use `withAnimation` for event-driven animations +- Prefer transforms over layout changes +- Scope animations narrowly +- Choose appropriate timing curves + +### Don't +- Use deprecated `.animation(_:)` without value +- Animate layout properties in hot paths +- Apply broad animations at root level +- Use linear timing for UI (feels robotic) +- Animate on every frame in scroll handlers diff --git a/.cursor/skills/swiftui-expert-skill/references/animation-transitions.md b/.cursor/skills/swiftui-expert-skill/references/animation-transitions.md new file mode 100644 index 00000000..e5224661 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/animation-transitions.md @@ -0,0 +1,328 @@ +# SwiftUI Transitions + +Transitions for view insertion/removal, custom transitions, and the Animatable protocol. + +## Table of Contents +- [Property Animations vs Transitions](#property-animations-vs-transitions) +- [Basic Transitions](#basic-transitions) +- [Asymmetric Transitions](#asymmetric-transitions) +- [Custom Transitions](#custom-transitions) +- [Identity and Transitions](#identity-and-transitions) +- [The Animatable Protocol](#the-animatable-protocol) + +--- + +## Property Animations vs Transitions + +**Property animations**: Interpolate values on views that exist before AND after state change. + +**Transitions**: Animate views being inserted or removed from the render tree. + +```swift +// Property animation - same view, different properties +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) + +// Transition - view inserted/removed +if showDetail { + DetailView() + .transition(.scale) +} +``` + +--- + +## Basic Transitions + +### Critical: Transitions Require Animation Context + +```swift +// GOOD - animation outside conditional +VStack { + Button("Toggle") { showDetail.toggle() } + if showDetail { + DetailView() + .transition(.slide) + } +} +.animation(.spring, value: showDetail) + +// GOOD - explicit animation +Button("Toggle") { + withAnimation(.spring) { + showDetail.toggle() + } +} +if showDetail { + DetailView() + .transition(.scale.combined(with: .opacity)) +} + +// BAD - animation inside conditional (removed with view!) +if showDetail { + DetailView() + .transition(.slide) + .animation(.spring, value: showDetail) // Won't work on removal! +} + +// BAD - no animation context +Button("Toggle") { + showDetail.toggle() // No animation +} +if showDetail { + DetailView() + .transition(.slide) // Ignored - just appears/disappears +} +``` + +### Built-in Transitions + +| Transition | Effect | +|------------|--------| +| `.opacity` | Fade in/out (default) | +| `.scale` | Scale up/down | +| `.slide` | Slide from leading edge | +| `.move(edge:)` | Move from specific edge | +| `.offset(x:y:)` | Move by offset amount | + +### Combining Transitions + +```swift +// Parallel - both simultaneously +.transition(.slide.combined(with: .opacity)) + +// Chained +.transition(.scale.combined(with: .opacity).combined(with: .offset(y: 20))) +``` + +--- + +## Asymmetric Transitions + +Different animations for insertion vs removal. + +```swift +// GOOD - different animations for insert/remove +if showCard { + CardView() + .transition( + .asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .move(edge: .bottom).combined(with: .opacity) + ) + ) +} + +// BAD - same transition when different behaviors needed +if showCard { + CardView() + .transition(.slide) // Same both ways - may feel awkward +} +``` + +--- + +## Custom Transitions + +### Pre-iOS 17 + +```swift +struct BlurModifier: ViewModifier { + var radius: CGFloat + func body(content: Content) -> some View { + content.blur(radius: radius) + } +} + +extension AnyTransition { + static func blur(radius: CGFloat) -> AnyTransition { + .modifier( + active: BlurModifier(radius: radius), + identity: BlurModifier(radius: 0) + ) + } +} + +// Usage +.transition(.blur(radius: 10)) +``` + +### iOS 17+ (Transition Protocol) + +```swift +struct BlurTransition: Transition { + var radius: CGFloat + + func body(content: Content, phase: TransitionPhase) -> some View { + content + .blur(radius: phase.isIdentity ? 0 : radius) + .opacity(phase.isIdentity ? 1 : 0) + } +} + +// Usage +.transition(BlurTransition(radius: 10)) +``` + +### Good vs Bad Custom Transitions + +```swift +// GOOD - reusable transition +if showContent { + ContentView() + .transition(BlurTransition(radius: 10)) +} + +// BAD - inline logic (won't animate on removal!) +if showContent { + ContentView() + .blur(radius: showContent ? 0 : 10) // Not a transition + .opacity(showContent ? 1 : 0) +} +``` + +--- + +## Identity and Transitions + +View identity changes trigger transitions, not property animations. + +```swift +// Triggers transition - different branches have different identities +if isExpanded { + Rectangle().frame(width: 200, height: 50) +} else { + Rectangle().frame(width: 100, height: 50) +} + +// Triggers transition - .id() changes identity +Rectangle() + .id(flag) // Different identity when flag changes + .transition(.scale) + +// Property animation - same view, same identity +Rectangle() + .frame(width: isExpanded ? 200 : 100, height: 50) + .animation(.spring, value: isExpanded) +``` + +--- + +## The Animatable Protocol + +Enables custom property interpolation during animations. + +### Protocol Definition + +```swift +protocol Animatable { + associatedtype AnimatableData: VectorArithmetic + var animatableData: AnimatableData { get set } +} +``` + +### Basic Implementation + +```swift +// GOOD - explicit animatableData +struct ShakeModifier: ViewModifier, Animatable { + var shakeCount: Double + + var animatableData: Double { + get { shakeCount } + set { shakeCount = newValue } + } + + func body(content: Content) -> some View { + content.offset(x: sin(shakeCount * .pi * 2) * 10) + } +} + +extension View { + func shake(count: Int) -> some View { + modifier(ShakeModifier(shakeCount: Double(count))) + } +} + +// Usage +Button("Shake") { shakeCount += 3 } + .shake(count: shakeCount) + .animation(.default, value: shakeCount) + +// BAD - missing animatableData (silent failure!) +struct BadShakeModifier: ViewModifier { + var shakeCount: Double + // Missing animatableData! Uses EmptyAnimatableData + + func body(content: Content) -> some View { + content.offset(x: sin(shakeCount * .pi * 2) * 10) + } +} +// Animation jumps to final value instead of interpolating +``` + +### Multiple Properties with AnimatablePair + +For deployment targets below iOS 26, use `AnimatablePair` to combine multiple animated properties. For iOS 26+ targets, prefer `@Animatable` or `AnimatableValues` — see [@Animatable Macro (iOS 26+)](animation-advanced.md#animatable-macro-ios-26) in `animation-advanced.md`. + +```swift +// GOOD (below iOS 26) - AnimatablePair for two properties +struct ComplexModifier: ViewModifier, Animatable { + var scale: CGFloat + var rotation: Double + + var animatableData: AnimatablePair { + get { AnimatablePair(scale, rotation) } + set { + scale = newValue.first + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .scaleEffect(scale) + .rotationEffect(.degrees(rotation)) + } +} + +// GOOD (below iOS 26) - nested AnimatablePair for 3+ properties +struct ThreePropertyModifier: ViewModifier, Animatable { + var x: CGFloat + var y: CGFloat + var rotation: Double + + var animatableData: AnimatablePair, Double> { + get { AnimatablePair(AnimatablePair(x, y), rotation) } + set { + x = newValue.first.first + y = newValue.first.second + rotation = newValue.second + } + } + + func body(content: Content) -> some View { + content + .offset(x: x, y: y) + .rotationEffect(.degrees(rotation)) + } +} +``` + +--- + +## Quick Reference + +### Do +- Place transitions outside conditional structures +- Use `withAnimation` or `.animation` outside the `if` +- Implement `animatableData` explicitly for custom Animatable +- Use `AnimatablePair` for multiple animated properties on deployment targets below iOS 26; for iOS 26+, use `@Animatable` or `AnimatableValues` (see `animation-advanced.md`) +- Use asymmetric transitions when insert/remove need different effects + +### Don't +- Put animation modifiers inside conditionals for transitions +- Forget `animatableData` implementation (silent failure) +- Use inline blur/opacity instead of proper transitions +- Expect property animation when view identity changes diff --git a/.cursor/skills/swiftui-expert-skill/references/charts-accessibility.md b/.cursor/skills/swiftui-expert-skill/references/charts-accessibility.md new file mode 100644 index 00000000..a0f4cd65 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/charts-accessibility.md @@ -0,0 +1,135 @@ +# Swift Charts Accessibility, Fallback, and Resources + +## Table of Contents + +- [Accessibility](#accessibility) + - [Meaningful Labels](#meaningful-labels) + - [Custom Audio Graphs](#custom-audio-graphs) +- [Composite Example](#composite-example) +- [Fallback Strategies](#fallback-strategies) + - [Version Breakdown](#version-breakdown) +- [WWDC Sessions](#wwdc-sessions) +- [Summary Checklist](#summary-checklist) + +--- + +## Accessibility + +Swift Charts provides built-in accessibility support. VoiceOver users get three rotor actions automatically: + +- **Describe Chart** — overview of axes and data series +- **Audio Graph** — sonification where pitch represents data values +- **Chart Detail** — interactive mode for exploring individual data points + +### Meaningful Labels + +**Always** use clear, descriptive strings in `.value(_, _)` calls. These labels are read by VoiceOver and used in the Audio Graph. + +```swift +// Good — descriptive labels +LineMark( + x: .value("Date", entry.date), + y: .value("Daily Steps", entry.count) +) + +// Bad — generic labels +LineMark( + x: .value("X", entry.date), + y: .value("Y", entry.count) +) +``` + +### Custom Audio Graphs + +For advanced accessibility, conform your chart view to `AXChartDescriptorRepresentable` and implement `makeChartDescriptor()`. Attach it with `.accessibilityChartDescriptor(self)`. + +```swift +struct StepsChart: View, AXChartDescriptorRepresentable { + let steps: [DailySteps] + + var body: some View { + Chart(steps) { day in + LineMark(x: .value("Date", day.date), y: .value("Steps", day.count)) + } + .accessibilityChartDescriptor(self) + } + + func makeChartDescriptor() -> AXChartDescriptor { + guard let first = steps.first, let last = steps.last else { + return AXChartDescriptor(title: "Daily Step Count", summary: nil, + xAxis: AXNumericDataAxisDescriptor(title: "Date", range: 0...1, gridlinePositions: []) { "\($0)" }, + yAxis: AXNumericDataAxisDescriptor(title: "Steps", range: 0...1, gridlinePositions: []) { "\($0)" }, + additionalAxes: [], series: []) + } + let xAxis = AXDateDataAxisDescriptor( + title: "Date", range: first.date...last.date, gridlinePositions: []) + let yAxis = AXNumericDataAxisDescriptor( + title: "Steps", range: 0...Double(steps.map(\.count).max() ?? 0), + gridlinePositions: []) { "\(Int($0)) steps" } + let series = AXDataSeriesDescriptor( + name: "Daily Steps", isContinuous: true, + dataPoints: steps.map { .init(x: $0.date, y: Double($0.count)) }) + return AXChartDescriptor(title: "Daily Step Count", summary: nil, + xAxis: xAxis, yAxis: yAxis, additionalAxes: [], series: [series]) + } +} +``` + +## Composite Example + +A scrollable bar chart with range selection combining multiple iOS 17+ APIs: + +```swift +@State private var selectedRange: ClosedRange? + +Chart(weeklyRevenue) { week in + BarMark(x: .value("Week", week.index), y: .value("Revenue", week.revenue)) + .foregroundStyle(by: .value("Region", week.region)) +} +.chartScrollableAxes(.horizontal) +.chartXVisibleDomain(length: 8) +.chartXSelection(range: $selectedRange) +.chartXAxis { + AxisMarks(values: .stride(by: 1)) { + AxisGridLine() + AxisValueLabel { Text("W\($0.as(Int.self) ?? 0)") } + } +} +``` + +## Fallback Strategies + +Gate advanced APIs with `#available` and provide a fallback chart without the gated features. Because chart modifiers like `.chartXSelection` change the return type, you must duplicate the entire `Chart` — you cannot conditionally apply the modifier: + +### Version Breakdown + +- iOS 16+: `Chart`, custom axes, scales, `BarMark`, `LineMark`, `AreaMark`, `PointMark`, `RectangleMark`, `RuleMark`, `ChartProxy`, `chartOverlay`, `chartBackground` +- iOS 17+: `SectorMark`, `chartXSelection`, `chartYSelection`, `chartAngleSelection`, `chartScrollableAxes`, visible-domain scrolling APIs, `chartGesture` +- iOS 18+: `AreaPlot`, `BarPlot`, `LinePlot`, `PointPlot`, `RectanglePlot`, `RulePlot`, `SectorPlot`, function plotting +- iOS 26+: `Chart3D`, `SurfacePlot`, Z-axis marks, 3D camera and pose APIs + +## WWDC Sessions + +- [Hello Swift Charts](https://developer.apple.com/videos/play/wwdc2022/10136/) (WWDC 2022) — introduction to the framework +- [Swift Charts: Raise the bar](https://developer.apple.com/videos/play/wwdc2022/10137/) (WWDC 2022) — marks, composition, customization +- [Design an effective chart](https://developer.apple.com/videos/play/wwdc2022/110340/) (WWDC 2022) — chart design principles +- [Design app experiences with charts](https://developer.apple.com/videos/play/wwdc2022/110342/) (WWDC 2022) — integrating charts into app UX +- [Explore pie charts and interactivity in Swift Charts](https://developer.apple.com/videos/play/wwdc2023/10037/) (WWDC 2023) — SectorMark, selection, scrolling +- [Swift Charts: Vectorized and function plots](https://developer.apple.com/videos/play/wwdc2024/10155/) (WWDC 2024) — LinePlot, AreaPlot, function plotting +- [Bring Swift Charts to the third dimension](https://developer.apple.com/videos/play/wwdc2025/313/) (WWDC 2025) — Chart3D, SurfacePlot, 3D marks + +## Summary Checklist + +- [ ] `import Charts` is present in files using chart types +- [ ] Deployment target matches the APIs used (`Chart` on iOS 16+, selection and `SectorMark` on iOS 17+, plot types on iOS 18+, `Chart3D` on iOS 26+) +- [ ] Chart data models use `Identifiable` (or `Chart(data, id:)` is provided) +- [ ] All chart families are represented with the correct mark type +- [ ] Axes use `AxisMarks` when default ticks are too dense or unclear +- [ ] `chartXScale` or `chartYScale` is set when fixed domains matter +- [ ] Chart-wide modifiers are applied to `Chart`, not individual marks +- [ ] `foregroundStyle(by:)` used for categorical series (not manual per-mark colors) +- [ ] Single-value selection uses `chartXSelection(value:)` or `chartYSelection(value:)` +- [ ] Range selection uses `chartXSelection(range:)` or `chartYSelection(range:)` +- [ ] `SectorMark` selection uses `chartAngleSelection(value:)` +- [ ] iOS 17+, iOS 18+, and iOS 26+ APIs are guarded with `#available` +- [ ] `.value()` labels are descriptive for VoiceOver and Audio Graph accessibility diff --git a/.cursor/skills/swiftui-expert-skill/references/charts.md b/.cursor/skills/swiftui-expert-skill/references/charts.md new file mode 100644 index 00000000..014adcf1 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/charts.md @@ -0,0 +1,602 @@ +# SwiftUI Charts Reference + +## Table of Contents + +- [Overview](#overview) +- [Availability](#availability) +- [Core APIs](#core-apis) +- [Chart Types](#chart-types) +- [Axis Tweaks](#axis-tweaks) +- [Selection APIs](#selection-apis) +- [Annotations](#annotations) +- [ChartProxy and Custom Touch Handling](#chartproxy-and-custom-touch-handling) +- [Modifier Scope](#modifier-scope) +- [Styling and Visual Channels](#styling-and-visual-channels) +- [Composing Multiple Marks](#composing-multiple-marks) +- [Animating Chart Data](#animating-chart-data) +- [Best Practices](#best-practices) + +## Overview + +Swift Charts is Apple's native charting framework for SwiftUI. Use `Chart` with one or more marks to build bar, line, area, point, rule, rectangle, and sector charts. This reference covers the standard 2D chart APIs, axis customization, built-in selection APIs, annotations, and custom touch handling. + +## Availability + +Base `Chart`, custom axes, scales, and most marks require iOS 16 or later. + +- `BarMark`, `LineMark`, `AreaMark`, `PointMark`, `RectangleMark`, and `RuleMark` are available on iOS 16+ +- `SectorMark`, built-in selection, and scrollable chart axes require iOS 17+ +- Data-driven plot types such as `BarPlot` and `LinePlot` require iOS 18+ +- Chart3D and Z-axis APIs exist on iOS 26+; this reference is primarily about 2D `Chart`, with a dedicated Chart3D section below + +```swift +if #available(iOS 17, *) { + // Selection, SectorMark, scrollable axes +} else { + // Base Chart, axes, scales, and core marks +} +``` + +## Core APIs + +### Import the Framework + +Always check that the file imports `Charts` before using `Chart`, `Chart3D`, `BarMark`, `SectorMark`, or `ChartProxy`. + +```swift +import SwiftUI +import Charts +``` + +If chart types are unresolved, the first thing to verify is that `Charts` is imported in that file. + +### Chart Container + +`Chart` is the root view. Add one or more marks inside it. + +```swift +Chart(sales) { item in + BarMark( + x: .value("Month", item.month), + y: .value("Revenue", item.revenue) + ) +} +``` + +### Data Models Should Be Identifiable + +Prefer `Identifiable` models for chart data so identity stays stable as data changes. + +```swift +struct SalesPoint: Identifiable { + let id: UUID + let month: String + let revenue: Double +} +``` + +If your model cannot conform to `Identifiable`, provide an explicit id key path: + +```swift +Chart(sales, id: \.month) { item in + BarMark( + x: .value("Month", item.month), + y: .value("Revenue", item.revenue) + ) +} +``` + +### Plottable Values + +Use `.value(_, _)` to describe what each axis value means. Those labels are reused by axes, legends, and accessibility. + +```swift +LineMark( + x: .value("Day", entry.date), + y: .value("Steps", entry.count) +) +``` + +## Chart Types + +### BarMark + +```swift +BarMark( + x: .value("Product", product.name), + y: .value("Units", product.units) +) +``` + +Stacking via `MarkStackingMethod`: `.standard`, `.normalized`, `.center`, `.unstacked`. + +### LineMark + +```swift +LineMark( + x: .value("Day", day.date), + y: .value("Steps", day.count) +) +.interpolationMethod(.monotone) +``` + +Interpolation methods: `.linear`, `.monotone`, `.cardinal`, `.catmullRom`, `.stepStart`, `.stepCenter`, `.stepEnd`. Cardinal and Catmull-Rom accept optional tension/alpha parameters. + +### AreaMark + +```swift +AreaMark( + x: .value("Hour", sample.hour), + y: .value("Temperature", sample.value), + stacking: .unstacked +) +``` + +Ranged areas use `yStart`/`yEnd` for bands like min/max or confidence intervals: + +```swift +AreaMark( + x: .value("Day", sample.day), + yStart: .value("Low", sample.low), + yEnd: .value("High", sample.high) +) +``` + +### PointMark + +```swift +PointMark( + x: .value("Time", measurement.time), + y: .value("Value", measurement.value) +) +``` + +### RectangleMark + +```swift +RectangleMark( + xStart: .value("Start Day", cell.startDay), + xEnd: .value("End Day", cell.endDay), + yStart: .value("Low", cell.low), + yEnd: .value("High", cell.high) +) +``` + +### RuleMark + +```swift +RuleMark(y: .value("Goal", 10_000)) + .foregroundStyle(.red) +``` + +### SectorMark + +Use `SectorMark` for pie and donut-style charts. `SectorMark` requires iOS 17 or later. + +```swift +Chart(expenses) { expense in + SectorMark( + angle: .value("Amount", expense.amount), + innerRadius: .ratio(0.6), + angularInset: 2 + ) + .foregroundStyle(by: .value("Category", expense.category)) +} +``` + +Use `innerRadius` to turn a pie chart into a donut chart, and `angularInset` to separate slices visually. + +### Plot Types (iOS 18+) + +iOS 18 adds data-driven plot wrappers: `AreaPlot`, `BarPlot`, `LinePlot`, `PointPlot`, `RectanglePlot`, `RulePlot`, and `SectorPlot`. + +`LinePlot` and `AreaPlot` also accept function closures for plotting mathematical functions without discrete data: + +```swift +if #available(iOS 18, *) { + Chart { + LinePlot(x: "x", y: "sin(x)") { x in + sin(x) + } + } + .chartXScale(domain: -Double.pi ... Double.pi) + .chartYScale(domain: -1.5 ... 1.5) +} +``` + +Use plot types when you want a data-first API surface or need function plotting. The underlying chart families stay the same. + +### Chart3D (iOS 26+) + +`Chart3D` is a separate API for 3D chart content. It supports 3D `PointMark`, `RectangleMark`, `RuleMark`, and `SurfacePlot`. + +```swift +if #available(iOS 26, *) { + Chart3D(points) { point in + PointMark( + x: .value("X", point.x), + y: .value("Y", point.y), + z: .value("Z", point.z) + ) + } + .chart3DPose(.front) + .chart3DCameraProjection(.perspective) +} +``` + +`SurfacePlot` visualizes mathematical surfaces by evaluating a two-variable function: + +```swift +if #available(iOS 26, *) { + Chart3D { + SurfacePlot(x: "x", y: "height", z: "z") { x, z in + sin(x) * cos(z) + } + } + .chartXScale(domain: -Double.pi ... Double.pi) + .chartZScale(domain: -Double.pi ... Double.pi) +} +``` + +Camera and pose configuration: + +- **Projection**: `.chart3DCameraProjection(.orthographic)` (default, precise measurements) or `.perspective` (depth effect) +- **Pose presets**: `.chart3DPose(.default)`, `.front`, `.back`, `.left`, `.right` +- **Custom pose**: `.chart3DPose(azimuth: .degrees(45), inclination: .degrees(30))` +- On visionOS, Chart3D supports natural 3D interaction gestures for rotation and exploration + +**Always** gate `Chart3D` with `#available(iOS 26, *)` — it is not available on earlier OS versions. + +## Axis Tweaks + +### Axis Visibility and Labels + +Use `chartXAxis`, `chartYAxis`, `chartXAxisLabel`, and `chartYAxisLabel` on the `Chart` container. +Axis visibility supports `.automatic`, `.visible`, and `.hidden`. + +```swift +Chart(data) { item in + BarMark( + x: .value("Month", item.month), + y: .value("Revenue", item.revenue) + ) +} +.chartXAxis(.visible) +.chartYAxis(.hidden) +.chartXAxisLabel("Month") +.chartYAxisLabel("Revenue") +``` + +### Custom Axis Marks + +Use `AxisMarks` to control tick placement, labels, and grid lines. + +```swift +Chart(steps) { day in + LineMark( + x: .value("Day", day.date), + y: .value("Steps", day.count) + ) +} +.chartXAxis { + AxisMarks( + preset: .aligned, + position: .bottom, + values: .stride(by: .day) + ) { + AxisGridLine() + AxisTick(length: .label) + AxisValueLabel(format: .dateTime.weekday(.abbreviated)) + } +} +``` + +Useful `AxisMarks` inputs: + +- `preset`: `.automatic`, `.extended`, `.aligned`, `.inset` +- `position`: `.automatic`, `.leading`, `.trailing`, `.top`, `.bottom` +- `values`: `.automatic`, `.automatic(desiredCount:)`, `.stride(by:)`, `.stride(by:count:)`, or an explicit array + +### Axis Components + +Within `AxisMarks`, combine the built-in axis components as needed: + +```swift +AxisGridLine() +AxisTick() +AxisValueLabel() +``` + +`AxisValueLabel` can be tuned for dense axes: + +```swift +AxisValueLabel( + collisionResolution: .greedy(minimumSpacing: 8), + orientation: .vertical +) +``` + +Label orientations: `.automatic`, `.horizontal`, `.vertical`, `.verticalReversed`. + +Collision strategies: `.automatic`, `.greedy`, `.greedy(priority:minimumSpacing:)`, `.truncate`, `.disabled`. + +### Axis Domains and Plot Area Tweaks + +Use scales when you need explicit axis domains or plot area control. + +```swift +Chart(data) { item in + LineMark( + x: .value("Index", item.index), + y: .value("Score", item.score) + ) +} +.chartXScale(domain: 0...30) +.chartYScale(domain: 0...100) +.chartPlotStyle { plotArea in + plotArea + .background(.gray.opacity(0.08)) +} +``` + +You can set one axis domain without forcing the other: + +```swift +.chartXScale(domain: startDate...endDate) +``` + +### Scrollable Axes (iOS 17+) + +For larger datasets, make the plot area scroll and control the visible domain. + +```swift +@State private var scrollX = 7 + +Chart(data) { item in + BarMark( + x: .value("Day", item.day), + y: .value("Value", item.value) + ) +} +.chartScrollableAxes(.horizontal) +.chartXVisibleDomain(length: 7) +.chartScrollPosition(x: $scrollX) +``` + +## Selection APIs + +### Single-Value Selection + +Use `chartXSelection(value:)` or `chartYSelection(value:)` for one selected value. + +```swift +@State private var selectedDate: Date? + +Chart(steps) { day in + LineMark(x: .value("Day", day.date), y: .value("Steps", day.count)) + + if let selectedDate { + RuleMark(x: .value("Selected Day", selectedDate)) + .foregroundStyle(.secondary) + } +} +.chartXSelection(value: $selectedDate) +``` + +### Range Selection + +Use `chartXSelection(range:)` or `chartYSelection(range:)` for a dragged range. Bind to a `ClosedRange` whose bound type matches the plotted axis value. + +```swift +@State private var selectedWeeks: ClosedRange? + +Chart(weeks) { week in + BarMark(x: .value("Week", week.index), y: .value("Revenue", week.revenue)) +} +.chartXSelection(range: $selectedWeeks) +``` + +### Choosing Single vs Range + +- Use `value:` bindings when only one point or axis value should be selected. +- Use `range:` bindings when users should brush a span (for zoom windows, comparisons, or grouped summaries). + +### Angle Selection + +Use `chartAngleSelection(value:)` with `SectorMark` charts. No built-in range overload for angle selection. + +```swift +@State private var selectedAmount: Double? + +Chart(expenses) { expense in + SectorMark(angle: .value("Amount", expense.amount)) + .foregroundStyle(by: .value("Category", expense.category)) +} +.chartAngleSelection(value: $selectedAmount) +``` + +**Important**: Selection bindings return the plottable axis value, not the full data element. Map back to your model if you need the selected record. + +## Annotations + +Use `annotation(position:)` on a mark when you need labels, callouts, or highlighted values attached to the plotted content. + +```swift +BarMark( + x: .value("Month", item.month), + y: .value("Revenue", item.revenue) +) +.annotation(position: .top) { + Text(item.revenue.formatted()) +} +``` + +This is useful for selected values, thresholds, summaries, and direct labeling. Common positions include `.overlay`, `.top`, `.bottom`, `.leading`, and `.trailing`. + +## ChartProxy and Custom Touch Handling + +Use `chartOverlay`/`chartBackground` (iOS 16+) or `chartGesture` (iOS 17+) with `ChartProxy` when built-in selection modifiers are not enough. + +```swift +.chartOverlay { proxy in + GeometryReader { geometry in + Rectangle().fill(.clear).contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + guard let plotFrame = proxy.plotFrame else { return } // iOS 16: use proxy.plotAreaFrame + let frame = geometry[plotFrame] + let x = value.location.x - frame.origin.x + guard x >= 0, x <= frame.size.width else { return } + selectedDate = proxy.value(atX: x, as: Date.self) + } + .onEnded { _ in selectedDate = nil } + ) + } +} +``` + +Use `proxy.plotFrame` (iOS 17+) or `proxy.plotAreaFrame` (iOS 16) to get the plot area anchor. + +`ChartProxy` gives you lower-level access to: + +- `value(atX:as:)`, `value(atY:as:)`, and `value(at:as:)` for converting gesture coordinates into chart values +- `position(forX:)`, `position(forY:)`, and `position(for:)` for placing custom overlays or indicators +- `selectXValue(at:)`, `selectYValue(at:)`, `selectXRange(from:to:)`, and `selectYRange(from:to:)` for driving built-in selection from custom gestures +- `plotFrame` (iOS 17+) or `plotAreaFrame` (iOS 16) with `plotSize` for converting between gesture coordinates and the plot area + +`select*` ChartProxy selection methods and `chartGesture` are available on iOS 17+. + +## Modifier Scope + +Apply chart-wide modifiers to the `Chart` container and mark-specific modifiers to the individual mark. + +```swift +Chart(data) { item in + LineMark( + x: .value("Day", item.date), + y: .value("Value", item.value) + ) + .interpolationMethod(.monotone) // Mark-level modifier +} +.chartXAxis { AxisMarks() } // Chart-level modifier +.chartYScale(domain: 0...100) // Chart-level modifier +.chartPlotStyle { $0.background(.thinMaterial) } +``` + +## Styling and Visual Channels + +### Categorical Coloring + +Use `foregroundStyle(by: .value(...))` to color marks by a data property. Swift Charts generates a legend automatically. + +```swift +Chart(sales) { item in + BarMark( + x: .value("Month", item.month), + y: .value("Revenue", item.revenue) + ) + .foregroundStyle(by: .value("Region", item.region)) +} +``` + +**Avoid** applying `.foregroundStyle(.red)` per mark for categorical data — this suppresses the automatic legend and breaks accessibility. + +### Custom Color Scales + +Use `chartForegroundStyleScale` to control the mapping from data values to colors. + +```swift +.chartForegroundStyleScale([ + "North": .blue, + "South": .orange, + "East": .green +]) +``` + +For dynamic data where not all series appear at every point, use the mapping overload: + +```swift +.chartForegroundStyleScale(domain: regions, mapping: { region in + colorForRegion(region) +}) +``` + +### Symbol and Size Channels + +Use `symbol(by:)` and `symbolSize(by:)` to encode additional data dimensions on `PointMark` and `LineMark`. + +```swift +Chart(measurements) { item in + PointMark( + x: .value("Time", item.time), + y: .value("Value", item.value) + ) + .foregroundStyle(by: .value("Category", item.category)) + .symbol(by: .value("Category", item.category)) + .symbolSize(by: .value("Weight", item.weight)) +} +``` + +### Legend Control + +```swift +.chartLegend(.visible) +.chartLegend(.hidden) +.chartLegend(position: .bottom, alignment: .center) +``` + +## Composing Multiple Marks + +Combine different mark types inside the same `Chart` closure: + +```swift +// Line with points +LineMark(x: .value("Day", day.date), y: .value("Steps", day.count)) + .interpolationMethod(.monotone) +PointMark(x: .value("Day", day.date), y: .value("Steps", day.count)) + +// Bars with threshold line +BarMark(x: .value("Month", item.month), y: .value("Revenue", item.revenue)) +RuleMark(y: .value("Target", 10_000)) + .foregroundStyle(.red) + .lineStyle(StrokeStyle(dash: [5, 3])) +``` + +## Animating Chart Data + +Chart marks animate automatically when data identity is stable and changes are wrapped in an animation. + +```swift +withAnimation(.easeInOut) { + chartData = updatedData +} +``` + +**Always** use `Identifiable` models (or explicit `id:`) so Swift Charts can match old and new data points and animate transitions between them. + +## Best Practices + +### Do + +- Use semantic `.value(_, _)` labels so axes and accessibility read clearly +- Prefer `Identifiable` models (or explicit `id:`) for stable chart data identity +- Use `foregroundStyle(by:)` for categorical series to get automatic legends and accessibility +- Use `RuleMark` for goals, thresholds, and selected-value indicators +- Use explicit `AxisMarks(values:)` when automatic tick generation gets crowded +- Use `chartXScale` and `chartYScale` when you need stable visual comparisons +- Use `chartXSelection(range:)` or `chartYSelection(range:)` for brushed selection +- Gate iOS 17+ APIs such as `SectorMark` and selection with `#available` + +### Don't + +- Put chart-wide modifiers such as `chartXAxis` or `chartXSelection` on individual marks +- Apply manual `.foregroundStyle(.color)` per mark for categorical data — use `foregroundStyle(by:)` instead +- Rely on unstable identities when chart data can be inserted, removed, or reordered +- Use string values for naturally numeric or date-based axes unless you want categorical behavior +- Stack unrelated series by default just because `BarMark` and `AreaMark` allow it +- Force every tick label to display when collision handling or stride values would be clearer +- Assume selection returns a model object; it only returns the plottable axis value +- Forget that range selection is available only for X and Y axes, not angle selection + +For chart accessibility (VoiceOver, Audio Graph, `AXChartDescriptorRepresentable`), fallback strategies, WWDC sessions, and a full summary checklist, see `charts-accessibility.md`. diff --git a/.cursor/skills/swiftui-expert-skill/references/document-apps.md b/.cursor/skills/swiftui-expert-skill/references/document-apps.md new file mode 100644 index 00000000..5e7bd695 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/document-apps.md @@ -0,0 +1,209 @@ +# Document-Based Apps (SDK 27+) + +> The `Document` protocol family replaces `FileDocument` / `ReferenceFileDocument` for new code on iOS 27, macOS 27, and visionOS 27. **Unavailable on watchOS and tvOS.** For older deployment targets, see the `FileDocument` section in `references/macos-scenes.md`. + +## Table of Contents + +- [Protocol Map](#protocol-map) +- [Read and Write Flow](#read-and-write-flow) +- [Flat-File Document](#flat-file-document) +- [Undo Registration Is Required for Autosave](#undo-registration-is-required-for-autosave) +- [DocumentGroup and Launch Scenes](#documentgroup-and-launch-scenes) +- [Custom Readers and Writers](#custom-readers-and-writers-direct-url-access) +- [Package Documents](#package-documents) +- [Progress Reporting](#progress-reporting-with-subprogress) +- [Coordinated Access Outside Read/Write](#coordinated-access-outside-readwrite) +- [Export](#export) +- [Migrating from FileDocument](#migrating-from-filedocument) +- [Content Types](#content-types) + +--- + +## Protocol Map + +| Symbol | Role | +|---|---| +| `ReadableDocument` | Read-only. Requires `readableContentTypes`, `reader(configuration:)`, `apply(snapshot:previous:)`. | +| `WritableDocument` | Saving. Requires `writer(configuration:)` and `snapshot(contentType:)`; `writableContentTypes` defaults to `readableContentTypes` when both protocols are adopted. | +| `Document` | `ReadableDocument & WritableDocument`, no requirements of its own. | +| `DocumentReader` | `@concurrent func read(from:progress:) async throws -> sending Snapshot` | +| `DocumentWriter` | `@concurrent func write(snapshot:to:previous:progress:) async throws` | +| `FileWrapperDocumentReader` | Convenience reader; closure `(FileWrapper) async throws -> sending Snapshot`. | +| `FileWrapperDocumentWriter` | Convenience writer; closure `(Snapshot, FileWrapper?) async throws -> FileWrapper`. | +| `URLDocumentConfiguration` | `@MainActor @Observable`. `fileURL`, `lastContentModificationDate`, `makeFileCoordinator()`. | +| `DocumentCreationContext` | `creationSource: DocumentCreationSource?`. | + +Documents are `AnyObject`-constrained, so a document is a class. Mark it `@Observable` so SwiftUI tracks individual property changes instead of recreating the model. + +A **snapshot** is the document's state at one moment. It can be any type — `String`, a struct, or the document itself — and reading and writing may use different snapshot types. Keep snapshot, reader, and writer types at `internal` access; protocol requirements expose them in signatures, so `private` or `fileprivate` fails to compile. + +## Read and Write Flow + +**Open:** `reader(configuration:)` → `read(from:progress:)` in the background → `apply(snapshot:previous:)` on the main actor. + +**Save:** `snapshot(contentType:)` on the main actor → `writer(configuration:)` → `write(snapshot:to:previous:progress:)` in the background with coordinated file access. + +`snapshot(contentType:)` and `apply(snapshot:previous:)` are `@MainActor async throws` — keep them cheap and serialize inside `read(…)` / `write(…)`. Inside those methods, use the `source` / `destination` parameter rather than `configuration.fileURL`; the framework hands you the URL for *this* operation, which is not necessarily the document's URL. + +## Flat-File Document + +`FileWrapperDocumentReader` and `FileWrapperDocumentWriter` handle file coordination for you. `readableContentTypes` drives the document browser; `writableContentTypes` drives the save panel. + +```swift +@Observable +final class TextDocument: Document { + static let readableContentTypes = [UTType.plainText] + + var text: String = "" + + func reader(configuration: sending ReadConfiguration) -> sending FileWrapperDocumentReader { + FileWrapperDocumentReader(configuration) { fileWrapper in + guard let data = fileWrapper.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) + } + return String(decoding: data, as: UTF8.self) + } + } + + func writer(configuration: sending WriteConfiguration) -> sending FileWrapperDocumentWriter { + FileWrapperDocumentWriter(configuration) { snapshot, previous in + FileWrapper(regularFileWithContents: Data(snapshot.utf8)) + } + } + + @MainActor + func snapshot(contentType: UTType) async throws -> sending String { text } + + @MainActor + func apply(snapshot: sending String, previous: sending String?) async throws { + text = snapshot + } +} +``` + +## Undo Registration Is Required for Autosave + +SwiftUI detects unsaved changes through the undo stack. **Without registered undo actions, autosave never runs.** Read `\.undoManager` from the environment and register an undo action for every change: + +```swift +struct TextDocumentView: View { + @Bindable var document: TextDocument + @Environment(\.undoManager) private var undoManager + + var body: some View { + TextEditor(text: $document.text) + .onChange(of: document.text) { oldValue, _ in + undoManager?.registerUndo(withTarget: document) { document in + document.text = oldValue + } + } + } +} +``` + +Registering with `withTarget: document` gives redo for free — SwiftUI replays the same closure with the restored value. + +## DocumentGroup and Launch Scenes + +`DocumentGroup` (or `DocumentGroupLaunchScene`) must be the app's first scene to opt into autosave, file coordination, file dialogs, undo management, and conflict resolution. On iOS, set `UISupportsDocumentBrowser` to `YES` to present a document browser. + +```swift +DocumentGroup { document in + TextDocumentView(document: document) +} makeDocument: { configuration, context in + TextDocument() +} +``` + +Read-only apps conform only to `ReadableDocument` and use `viewer:` / `makeReadableDocument:`, with `CFBundleTypeRole` set to `Viewer` instead of `Editor`. + +`makeDocument` is `async` and runs on the main actor, so you can suspend it to show a template picker or import preview before the document appears; throw `CancellationError` to cancel. On macOS and visionOS, drive that from a separate `Window` scene via a stored `CheckedContinuation`; on iOS, present a `.sheet` from a `NewDocumentButton`. + +`DocumentGroupLaunchScene` (iOS/visionOS) hosts `NewDocumentButton`s, each carrying a `DocumentCreationSource`. Read `context.creationSource` in `makeDocument` to configure the new document: + +```swift +DocumentGroupLaunchScene("My Notes") { + NewDocumentButton("New Note", source: .note) + NewDocumentButton("New List", source: .list) +} background: { + Color.accentColor.gradient +} + +extension DocumentCreationSource { + static let note = DocumentCreationSource(id: "note") +} +``` + +## Custom Readers and Writers (Direct URL Access) + +Implement `DocumentReader` / `DocumentWriter` directly when you need streaming, custom write logic, or a file URL for frameworks such as Core Graphics, AVFoundation, or PDFKit. Their `Source` and `Destination` associated types default to `URL`; specialize them only when the backing store requires another type. + +```swift +struct Reader: DocumentReader { + @concurrent + func read(from source: URL, progress: consuming Subprogress) async throws -> sending ImageSnapshot { + guard let imageSource = CGImageSourceCreateWithURL(source as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + throw CocoaError(.fileReadCorruptFile) + } + return ImageSnapshot(image: image) + } +} +``` + +The writer's `previous` parameter holds the last successfully written snapshot; ignoring it and rewriting everything is the simplest correct behavior. + +## Package Documents + +A package is a directory the system presents as one item. `FileWrapperDocumentReader` / `FileWrapperDocumentWriter` work here too: read children from `directory.fileWrappers`, and build a fresh `FileWrapper(directoryWithFileWrappers:)` with `preferredFilename` set on each child when writing. + +Rewriting the whole package on every save is the default recommendation. `FileWrapper` loads contents **on demand**, so a child can be gone by the time you call `regularFileContents` — always handle errors when reading children. + +Incremental writes are worth it only against a measured problem. The pattern: carry a per-child `isChanged` flag, reuse the previous `FileWrapper` from the writer closure's second parameter, replace only changed children, remove children no longer listed in your metadata, and clear the flags in `snapshot(contentType:)`. + +## Progress Reporting with `Subprogress` + +Custom readers and writers receive a `Subprogress` (Foundation). The `FileWrapper` convenience closures do **not**. `Subprogress` is `~Copyable`, so the compiler enforces single use; unconsumed units auto-complete. + +```swift +let progressManager = progress.start(totalCount: 2) +let data = try Data(contentsOf: source) +progressManager.complete(count: 1) +let image = try decodeImage(from: data) +progressManager.complete(count: 1) +``` + +Pick a coarse `totalCount` — chunks or files, not bytes. SwiftUI decides case by case whether to show an indicator. + +## Coordinated Access Outside Read/Write + +SwiftUI coordinates `read` and `write` for you. For any other disk access — loading one file from a package on tap, for instance — retain the `URLDocumentConfiguration` passed to `makeDocument`, call `makeFileCoordinator()` on it for each operation, then use `coordinate(readingItemAt:options:error:)` (or the writing variant) and check the `NSError` out-parameter. Skipping coordination risks corruption when another process edits the same document. + +## Export + +Export to another location or format with `fileExporter(isPresented:document:contentType:defaultFilename:onCompletion:)`, passing the `WritableDocument` itself. + +## Migrating from FileDocument + +| Before | After | +|---|---| +| `FileDocument` struct / `ReferenceFileDocument` class | `@Observable final class` conforming to `Document` | +| `init(configuration:)` | `DocumentReader` + `apply(snapshot:previous:)` | +| `fileWrapper(configuration:)` | `DocumentWriter` + `snapshot(contentType:)` | +| `DocumentGroup(newDocument:editor:)` | `DocumentGroup { editor } makeDocument: { configuration, context in }` | +| Single `Snapshot` type | Separate read and write snapshot types | +| Change tracked by value comparison | Undo registration required | + +`FileDocument`, `ReferenceFileDocument`, and their `DocumentGroup(newDocument:)` APIs are soft-deprecated in the SDK 27 toolchain. They remain the compatible option for deployment targets below the aligned 27 releases; follow `references/soft-deprecation.md` when deciding whether migration belongs in the current task. When migrating a `ReferenceFileDocument`, drop `ObservableObject` and `@Published` rather than layering `@Observable` on top. + +## Content Types + +Built-in formats need no custom type (`UTType.plainText`, `.jpeg`, `.pdf`). Mirror a custom document type in code: + +```swift +extension UTType { + static let notebook = UTType(exportedAs: "com.mycompany.notebook") +} +``` + +Use `static let` for exported types and `static var` for `UTType(importedAs:)` types. Custom identifiers use lowercase reverse-DNS syntax. Flat files conform to `public.data`; packages conform to `com.apple.package`. diff --git a/.cursor/skills/swiftui-expert-skill/references/environment-patterns.md b/.cursor/skills/swiftui-expert-skill/references/environment-patterns.md new file mode 100644 index 00000000..85e2e2b4 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/environment-patterns.md @@ -0,0 +1,162 @@ +# SwiftUI Environment Patterns + +Use this reference for `@Environment`, `EnvironmentValues`, `@Entry`, `@FocusedValue`, and values propagated through the environment. + +## Read Framework Values + +Use `@Environment` to read framework-provided values and actions from the nearest ancestor: + +```swift +struct DetailView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button("Done") { dismiss() } + } +} +``` + +Keep the declaration in the smallest view that uses it so environment changes do not invalidate a broader parent. + +## Share Observable Models + +On iOS 17+ and aligned platforms, inject an `@Observable` model by type and read it with type-based `@Environment`: + +```swift +@MainActor +@Observable +final class AppState { + var isLoggedIn = false +} + +RootView() + .environment(AppState()) + +struct AccountView: View { + @Environment(AppState.self) private var appState + + var body: some View { + Text(appState.isLoggedIn ? "Signed in" : "Signed out") + } +} +``` + +Observation tracks the model properties read during `body`, so unrelated properties do not invalidate the view. Use `@Bindable` locally when a control needs bindings to the injected model. + +For older deployment targets, `@EnvironmentObject` with `.environmentObject(...)` remains the corresponding `ObservableObject` pattern. Do not create the shared model inline at multiple reader sites. + +## Prefer `@Entry` for Custom Values + +Use `@Entry` instead of manual key conformances when defining custom environment, transaction, container, or focused values: + +```swift +extension EnvironmentValues { + @Entry var accentTheme: Theme = .default +} + +extension FocusedValues { + @Entry var selectedDocument: Document? +} +``` + +Focused-value entries are optional and do not specify a non-`nil` default. + +When reviewing existing manual `EnvironmentKey` / `FocusedValueKey` boilerplate, surface an `@Entry` refactor as a top-line finding. Do not rewrite it unprompted. + +`@FocusedValue` uses the same comparison model as `@Environment`. Rules below for closures, defaults, unused reads, and high-frequency updates apply to both. + +## Never Store Closures in Custom Keys + +SwiftUI cannot reliably compare functions. A closure in a custom environment or focused-value key can therefore make every reader invalidate whenever the environment propagates. Wrapping the closure in a struct or storing it on a `View` does not fix comparison; the closure is still present. + +Framework action values such as `\.dismiss`, `\.openURL`, and `\.refresh` are designed for this purpose and are not affected by this rule. + +Represent custom behavior with a value that stores comparable inputs and exposes a method or `callAsFunction`, or share an `@Observable` model when the behavior belongs with shared state: + +```swift +// AVOID +extension EnvironmentValues { + @Entry var submit: (String) -> Void = { _ in } +} + +// PREFER +struct SubmitAction { + func callAsFunction(_ draft: String) { /* submit */ } +} + +extension EnvironmentValues { + @Entry var submit = SubmitAction() +} +``` + +When injected handlers differ by context (form vs cart, independent implementations), one option is a protocol plus concrete handler types stored as the `@Entry` value. When handlers share state and the set is closed, a single `@Observable` model can be simpler. + +## Keep Default Values Stable + +An `@Entry` default expression is evaluated when a reader falls back to it. The default is unstable when repeated evaluation produces a different value, such as a fresh class instance, `Date()`, `UUID()`, or a struct containing a newly allocated reference. Any unrelated environment write can then make fallback readers appear changed. + +```swift +// AVOID: creates a different instance on each fallback read +extension EnvironmentValues { + @Entry var model = Model() +} + +// PREFER: resolves to the same instance +extension EnvironmentValues { + @Entry var model = defaultModel + private static let defaultModel = Model() +} +``` + +Use an optional with a `nil` default when absence is meaningful. This is preferable when readers currently test a sentinel such as an empty identifier: + +```swift +extension EnvironmentValues { + @Entry var editingSession: EditingSession? +} +``` + +`Equatable` conformance does not repair an unstable default: the expression still allocates or changes on every read. Conversely, do not rewrite already-stable defaults. Literals, enum cases without associated values, `nil`, and structs built only from deterministic values or stable references are stable even without `Equatable`. + +A live unstable default has readers falling back and paying invalidation now. A latent one is currently covered by an upstream `.environment` injection; fixing it is still correct but is a regression guard, not a current-cost recovery. + +A manual `EnvironmentKey` with `static let defaultValue` is a deliberate stability fix (evaluated once). Do not use `static var defaultValue: T { Model() }` — that re-evaluates on every fallback read, the same problem `@Entry` has with an inline allocation. + +## Avoid High-Frequency Environment Updates + +Every environment write propagates through the subtree and makes environment readers check their values. Do not put per-frame or rapidly changing measurements such as scroll offsets, drag positions, geometry, timer ticks, or animation progress in custom environment keys. + +For visual scroll effects, prefer `scrollTransition` or `visualEffect(in:)`. When the value drives logic, consider an `@Observable` model that exposes a coarsened property such as `isWide` instead of raw width: + +```swift +@MainActor +@Observable +final class ViewportModel { + var width: CGFloat = 0 { + didSet { isWide = width > 600 } + } + + private(set) var isWide = false +} +``` + +The model alone is not the optimization: readers must observe a value that changes less often than the raw input. + +A shared `Set` of visible indices on one `@Observable` fires only on boundary crosses, which is better than a raw offset. Observation still tracks the whole `Set` property, so every row that read it invalidates. Persist a per-item `@Observable` whose own properties (for example `isVisible`) each row reads. + +## Remove Unused Reads + +An unused key-path declaration such as `@Environment(\.theme)` still subscribes the view to that key. Remove it when neither `body` nor anything called from `body` reads the value. + +Type-based `@Environment(Model.self)` uses Observation's property-level tracking. Merely declaring the model without reading one of its properties does not establish the same live dependency, though removing dead declarations still improves clarity. + +## Checklist + +- [ ] Custom values use `@Entry`; flag leftover manual keys without rewriting them unprompted +- [ ] Custom environment and focused-value keys do not store closures +- [ ] Default expressions return the same result on every fallback read (live or latent) +- [ ] `@FocusedValue` follows the same comparison and unused-read rules as `@Environment` +- [ ] Optional defaults represent semantic absence instead of sentinel instances +- [ ] High-frequency raw values do not flow through the environment +- [ ] Key-path environment declarations are actually read diff --git a/.cursor/skills/swiftui-expert-skill/references/focus-patterns.md b/.cursor/skills/swiftui-expert-skill/references/focus-patterns.md new file mode 100644 index 00000000..74b3d010 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/focus-patterns.md @@ -0,0 +1,299 @@ +# SwiftUI Focus Patterns Reference + +## Table of Contents + +- [@FocusState](#focusstate) +- [Making Views Focusable](#making-views-focusable) +- [Focused Values for Commands and Menus](#focused-values-for-commands-and-menus) +- [Default Focus](#default-focus) +- [Focus Scope and Sections](#focus-scope-and-sections) +- [Focus Effects](#focus-effects) +- [Search Focus](#search-focus) +- [Common Pitfalls](#common-pitfalls) + +## @FocusState + +Always mark `@FocusState` as `private`. Use `Bool` for a single field, an optional `Hashable` enum for multiple fields. + +### Single field + +```swift +@FocusState private var isFocused: Bool + +TextField("Email", text: $email) + .focused($isFocused) +``` + +### Multiple fields + +```swift +enum Field: Hashable { case name, email, password } +@FocusState private var focusedField: Field? + +TextField("Name", text: $name) + .focused($focusedField, equals: .name) +TextField("Email", text: $email) + .focused($focusedField, equals: .email) +``` + +Set `focusedField = .email` to move focus programmatically; set `nil` to dismiss the keyboard. + +### `focused(_:)` vs `focused(_:equals:)` with nested views + +`.focused($bool)` reports `true` when the modified view *or any focusable descendant* has focus. `.focused($enum, equals:)` reports its value only when that specific view receives focus. + +```swift +enum Focus: Hashable { case container, field } +@FocusState private var focus: Focus? + +VStack { + TextField("Name", text: $name) + .focused($focus, equals: .field) +} +.focusable() +.focused($focus, equals: .container) +``` + +With `focused(_:equals:)` and a single `@FocusState`, SwiftUI distinguishes the container *receiving* focus from the container merely *containing* focus. + +### `isFocused` environment value + +Read-only environment value that returns `true` when the nearest focusable ancestor has focus. Useful for styling non-focusable child views. + +```swift +struct HighlightWrapper: View { + @Environment(\.isFocused) private var isFocused + + var body: some View { + content + .background(isFocused ? Color.accentColor.opacity(0.1) : .clear) + } +} +``` + +## Making Views Focusable + +### `.focusable(_:)` + +Makes a non-text-input view participate in the focus system. Focused views can respond to keyboard events via `onKeyPress` and menu commands like Edit > Delete via `onDeleteCommand`. + +```swift +struct SelectableCard: View { + @FocusState private var isFocused: Bool + + var body: some View { + CardContent() + .focusable() + .focused($isFocused) + .border(isFocused ? Color.accentColor : .clear) + .onDeleteCommand { deleteCard() } + } +} +``` + +### `.focusable(_:interactions:)` (iOS 17+) + +Controls which focus-driven interactions the view supports via `FocusInteractions`: + +- `.activate` -- Button-like: only focusable when system-wide keyboard navigation is on (macOS/iOS) +- `.edit` -- Captures keyboard/Digital Crown input +- `.automatic` -- Platform default (both activate and edit) + +```swift +MyTapGestureView(...) + .focusable(interactions: .activate) +``` + +Use `.activate` for custom button-like views that should match system keyboard-navigation behavior. + +## Focused Values for Commands and Menus + +Focused values let parent views (App, Scene, Commands) read state from whichever view currently has focus. Use for enabling/disabling menu commands based on the focused document or selection. + +### Declare with `@Entry` + +```swift +extension FocusedValues { + @Entry var selectedDocument: Binding? +} +``` + +Focused `@Entry` values must be optional and use the implicit `nil` default, representing that no focused view currently publishes the value. + +### Publish from views + +```swift +// View-scoped: available when this view (or descendant) has focus +.focusedValue(\.selectedDocument, $document) + +// Scene-scoped: available when this scene has focus +.focusedSceneValue(\.selectedDocument, $document) +``` + +### Consume in commands + +`@FocusedValue` reads the value; `@FocusedBinding` unwraps a `Binding` automatically. + +```swift +@main +struct MyApp: App { + @FocusedBinding(\.selectedDocument) var document + + var body: some Scene { + WindowGroup { + ContentView() + } + .commands { + CommandGroup(after: .pasteboard) { + Button("Duplicate") { document?.duplicate() } + .disabled(document == nil) + } + } + } +} +``` + +### `@FocusedObject` (iOS 16+) + +For `ObservableObject` types. The view invalidates when the focused object changes. + +```swift +// Publish +.focusedObject(myObservableModel) + +// Consume +@FocusedObject var model: MyModel? +``` + +Scene-scoped variant: `.focusedSceneObject(_:)`. + +## Default Focus + +### `.defaultFocus(_:_:priority:)` (iOS 17+, macOS 13+, tvOS 16+) + +Prefer `.defaultFocus` over setting `@FocusState` in `onAppear` for initial focus placement. + +```swift +@FocusState private var focusedField: Field? + +VStack { + TextField("Name", text: $name) + .focused($focusedField, equals: .name) + TextField("Email", text: $email) + .focused($focusedField, equals: .email) +} +.defaultFocus($focusedField, .email) +``` + +**Priority**: `.automatic` (default) applies on window appearance and programmatic focus changes. `.userInitiated` also applies during user-driven focus navigation. + +### `prefersDefaultFocus(_:in:)` (macOS/tvOS/watchOS) + +Used with `.focusScope(_:)` to mark a preferred default target within a scoped region. + +### `resetFocus` environment action (macOS/tvOS/watchOS) + +Re-evaluates default focus within a namespace. + +```swift +@Namespace var scopeID +@Environment(\.resetFocus) private var resetFocus + +Button("Reset") { resetFocus(in: scopeID) } +``` + +## Focus Scope and Sections + +### `.focusScope(_:)` (macOS/tvOS/watchOS) + +Limits default focus preferences to a namespace. Use with `prefersDefaultFocus` and `resetFocus`. + +### `.focusSection()` (macOS 13+, tvOS 15+) + +Guides directional and sequential focus movement through a group of focusable descendants. Useful when focusable views are spatially separated and directional navigation would otherwise skip them. + +```swift +HStack { + VStack { Button("1") {}; Button("2") {}; Spacer() } + Spacer() + VStack { Spacer(); Button("A") {}; Button("B") {} } + .focusSection() +} +``` + +Without `.focusSection()`, swiping right from buttons 1/2 finds nothing. With it, the VStack receives directional focus and delivers it to its first focusable child. + +## Focus Effects + +### `.focusEffectDisabled(_:)` + +Suppresses the system focus ring (macOS) or hover effect. Use when providing custom focus visuals. + +```swift +MyCustomCard() + .focusable() + .focusEffectDisabled() + .overlay { customFocusRing } +``` + +`isFocusEffectEnabled` environment value reads the current state. + +## Search Focus + +### `.searchFocused(_:)` / `.searchFocused(_:equals:)` + +Bind focus state to the search field associated with the nearest `.searchable` modifier. Works like `.focused` but targets the search bar. + +```swift +@FocusState private var isSearchFocused: Bool + +NavigationStack { + ContentView() + .searchable(text: $query) + .searchFocused($isSearchFocused) +} + +// Programmatically focus the search bar +Button("Search") { isSearchFocused = true } +``` + +## Common Pitfalls + +### Redundant `@FocusState` writes revoke focus + +`.focusable()` + `.focused()` handles focus-on-click natively. Adding a tap gesture that *also* writes to `@FocusState` triggers a redundant state write, causing a second body evaluation that revokes focus. The result: focus briefly appears then disappears, and key commands like `onDeleteCommand` stop working. + +```swift +// WRONG -- tap gesture redundantly sets focus, causing double evaluation +CardView() + .focusable() + .focused($isFocused) + .onTapGesture { isFocused = true } // Remove this line + +// CORRECT -- let .focusable() + .focused() handle it +CardView() + .focusable() + .focused($isFocused) +``` + +### Ambiguous focus bindings + +Binding the same enum case to multiple views is ambiguous. SwiftUI picks the first candidate and emits a runtime warning. + +```swift +// WRONG -- .name bound to two views +TextField("Name", text: $name) + .focused($focusedField, equals: .name) +TextField("Full Name", text: $fullName) + .focused($focusedField, equals: .name) // ambiguous +``` + +Always use distinct enum cases for each focusable view. + +### `.onAppear` focus timing + +Setting `@FocusState` in `.onAppear` may fail if the view tree hasn't settled. Prefer `.defaultFocus` (iOS 17+) for reliable initial focus. If you must use `.onAppear`, wrap in `DispatchQueue.main.async` as a last resort. + +### Missing `.focusable()` for non-text views + +`TextField` and `SecureField` are implicitly focusable. Custom views (stacks, shapes, images) are not. Forgetting `.focusable()` means `.focused()` bindings have no effect and key event handlers never fire. diff --git a/.cursor/skills/swiftui-expert-skill/references/image-optimization.md b/.cursor/skills/swiftui-expert-skill/references/image-optimization.md new file mode 100644 index 00000000..d3341587 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/image-optimization.md @@ -0,0 +1,243 @@ +# SwiftUI Image Optimization Reference + +## Table of Contents + +- [AsyncImage Best Practices](#asyncimage-best-practices) +- [SDK 27 Caching and Request Control](#sdk-27-caching-and-request-control) +- [Image Decoding and Downsampling (Optional Optimization)](#image-decoding-and-downsampling-optional-optimization) +- [UIImage Loading and Memory](#uiimage-loading-and-memory) +- [SF Symbols](#sf-symbols) +- [Summary Checklist](#summary-checklist) + +## AsyncImage Best Practices + +### Basic AsyncImage with Phase Handling + +```swift +// Good - handles loading and error states +AsyncImage(url: imageURL) { phase in + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + case .failure: + Image(systemName: "photo") + .foregroundStyle(.secondary) + @unknown default: + EmptyView() + } +} +.frame(width: 200, height: 200) +``` + +For custom placeholders, replace `ProgressView()` in the `.empty` case with your placeholder view. Add `.transition(.opacity)` to the success case and `.animation(.easeInOut, value: imageURL)` to the container for fade-in transitions. + +## SDK 27 Caching and Request Control + +On aligned 27 runtimes, `AsyncImage(url:)` uses standard HTTP caching according to response headers, with no code change. The runtime behavior also benefits apps built with an older SDK. Do not add custom caching merely to obtain that default. If images still reload, first check the server's cache headers. + +SDK 27 adds three `AsyncImage(request:)` initializer shapes (remaining labels match the `URL` initializers): + +- Bare `AsyncImage(request:)` — renders the loaded image directly, like `AsyncImage(url:)` +- `content:` / `placeholder:` pair +- `transaction:` plus a `content:` closure that receives `AsyncImagePhase` + +The request carries a per-image `URLRequest`, including `cachePolicy` (`.returnCacheDataElseLoad`, `.returnCacheDataDontLoad`, `.reloadIgnoringLocalCacheData`, `.reloadRevalidatingCacheData`, `.useProtocolCachePolicy`). `asyncImageURLSession(_:)` supplies a configured `URLSession` and `URLCache` to a subtree. + +```swift +struct GalleryView: View { + private static let imageSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.urlCache = URLCache( + memoryCapacity: 64 * 1024 * 1024, + diskCapacity: 256 * 1024 * 1024 + ) + return URLSession(configuration: configuration) + }() + + var body: some View { + AsyncImage( + request: URLRequest(url: imageURL, cachePolicy: .returnCacheDataElseLoad) + ) { image in + image.resizable().scaledToFit() + } placeholder: { + ProgressView() + } + .asyncImageURLSession(Self.imageSession) + } +} +``` + +The request initializers and `asyncImageURLSession(_:)` require the aligned OS 27 releases (iOS, macOS, watchOS, tvOS, visionOS 27). Gate them for older deployment targets and retain `AsyncImage(url:)` as the fallback. + +## Image Decoding and Downsampling (Optional Optimization) + +**When you encounter `UIImage(data:)` usage, consider suggesting image downsampling as a potential performance improvement**, especially for large images in lists or grids. + +### Current Pattern That Could Be Optimized + +```swift +// Current pattern - decodes full image on main thread +// Unsafe - force unwrap can crash if imageData is invalid +Image(uiImage: UIImage(data: imageData)!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 200, height: 200) +``` + +### Suggested Optimization Pattern + +```swift +// Suggested optimization - decode and downsample off main thread +struct OptimizedImageView: View { + let imageData: Data + let targetSize: CGSize + @State private var processedImage: UIImage? + + var body: some View { + Group { + if let processedImage { + Image(uiImage: processedImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + ProgressView() + } + } + .task { + processedImage = await decodeAndDownsample(imageData, targetSize: targetSize) + } + } + + private func decodeAndDownsample(_ data: Data, targetSize: CGSize) async -> UIImage? { + await Task.detached { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + + let options: [CFString: Any] = [ + kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height), + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true + ] + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + return UIImage(cgImage: cgImage) + }.value + } +} + +// Usage +OptimizedImageView( + imageData: imageData, + targetSize: CGSize(width: 200, height: 200) +) +``` + +### Reusable Downsampling Actor + +For production use, wrap the logic in an `actor` with scale-aware sizing and cache-disabled source options: + +```swift +actor ImageProcessor { + func downsample(data: Data, targetSize: CGSize) -> UIImage? { + let scale = await UIScreen.main.scale + let maxPixel = max(targetSize.width, targetSize.height) * scale + let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false] + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary) else { return nil } + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixel, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { return nil } + return UIImage(cgImage: cgImage) + } +} +``` + +Key details: `kCGImageSourceShouldCache: false` on the source prevents the full-resolution image from being cached in memory. Multiplying `targetSize` by `UIScreen.main.scale` ensures the thumbnail is sharp on Retina displays. `kCGImageSourceShouldCacheImmediately: true` on the thumbnail forces decoding at creation time rather than at first render. + +### When to Suggest This Optimization + +Mention this optimization when you see `UIImage(data:)` usage, particularly in: +- Scrollable content (List, ScrollView with LazyVStack/LazyHStack) +- Grid layouts with many images +- Image galleries or carousels +- Any scenario where large images are displayed at smaller sizes + +**Don't automatically apply it**—present it as an optional improvement for performance-sensitive scenarios. + +## UIImage Loading and Memory + +### UIImage(named:) Caches in System Cache + +`UIImage(named:)` adds images to the system cache, which can cause memory spikes when loading many images (e.g., in a slider or gallery). For single-use or frequently-rotated images, use `UIImage(contentsOfFile:)` to bypass the cache: + +```swift +// Caches in system cache -- memory builds up +let image = UIImage(named: "Wallpapers/image_001.jpg") + +// No system caching -- memory stays flat +guard let path = Bundle.main.path(forResource: "Wallpapers/image_001.jpg", ofType: nil) else { return nil } +let image = UIImage(contentsOfFile: path) +``` + +### NSCache for Controlled Image Caching + +When image processing (resizing, filtering) is needed, use `NSCache` with a `countLimit` to bound memory instead of relying on system caching: + +```swift +struct ImageCache { + private let cache = NSCache() + + init(countLimit: Int = 50) { + cache.countLimit = countLimit + } + + subscript(key: String) -> UIImage? { + get { cache.object(forKey: key as NSString) } + nonmutating set { + if let newValue { + cache.setObject(newValue, forKey: key as NSString) + } else { + cache.removeObject(forKey: key as NSString) + } + } + } +} +``` + +## SF Symbols + +```swift +Image(systemName: "star.fill") + .foregroundStyle(.yellow) + .symbolRenderingMode(.multicolor) // or .hierarchical, .palette, .monochrome + +// Animated symbols (iOS 17+) +Image(systemName: "antenna.radiowaves.left.and.right") + .symbolEffect(.variableColor) +``` + +Variants are available via naming convention: `star.circle.fill`, `star.square.fill`, `folder.badge.plus`. + +## Summary Checklist + +- [ ] Use `AsyncImage` with proper phase handling +- [ ] Handle empty, success, and failure states +- [ ] On OS 27, rely on default HTTP caching unless a custom cache policy or `URLSession` is needed +- [ ] Consider downsampling for `UIImage(data:)` in performance-sensitive scenarios +- [ ] Decode and downsample images off the main thread +- [ ] Use appropriate target sizes for downsampling +- [ ] Consider image caching for frequently accessed images +- [ ] Use SF Symbols with appropriate rendering modes + +**Performance Note**: Image downsampling is an optional optimization. Only suggest it when you encounter `UIImage(data:)` usage in performance-sensitive contexts like scrollable lists or grids. diff --git a/.cursor/skills/swiftui-expert-skill/references/latest-apis.md b/.cursor/skills/swiftui-expert-skill/references/latest-apis.md new file mode 100644 index 00000000..c4bdb71f --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/latest-apis.md @@ -0,0 +1,568 @@ +# Latest SwiftUI APIs Reference + +> Based on a comparison of Apple's documentation using the Sosumi MCP, we found the latest recommended APIs to use. + +> This file lists *what* the modern replacements are. For *how to behave* when you find a soft-deprecated API — when to migrate, when to leave it alone, and the scoping rule for unrelated edits — see `references/soft-deprecation.md`. To refresh this list after a new SDK release, run the maintenance skill at `.agents/skills/update-swiftui-apis/SKILL.md`. + +## Table of Contents +- [Always Use (iOS 15+)](#always-use-ios-15) +- [When Targeting iOS 16+](#when-targeting-ios-16) +- [When Targeting iOS 17+](#when-targeting-ios-17) +- [When Targeting iOS 18+](#when-targeting-ios-18) +- [When Targeting iOS 26+](#when-targeting-ios-26) +- [When Targeting iOS 27+](#when-targeting-ios-27) + +--- + +## Always Use (iOS 15+) + +These APIs have been deprecated long enough that there is no reason to use the old variants. + +### Compact Replacements + +These replacements have minimal API shape changes. Most are near-direct swaps; a few require an additional parameter or structural adjustment: + +- **`navigationTitle(_:)`** instead of `navigationBarTitle(_:)` +- **`toolbar { ToolbarItem(...) }`** instead of `navigationBarItems(...)` (structural change) +- **`ignoresSafeArea(_:edges:)`** instead of `edgesIgnoringSafeArea(_:)` +- **`preferredColorScheme(_:)`** instead of `colorScheme(_:)` +- **`foregroundStyle(_:)`** instead of `foregroundColor(_:)` (e.g., `.foregroundStyle(.primary)`) +- **`clipShape(.rect(cornerRadius:))`** instead of `cornerRadius()` +- **`textInputAutocapitalization(_:)`** instead of `autocapitalization(_:)` (note: `.never` replaces `.none`) +- **`animation(_:value:)`** instead of `animation(_:)` (adds required `value:` parameter; back-deploys to iOS 13+) +- **`dismiss` or `isPresented` environment values** instead of `PresentationMode` / `presentationMode` +- **`DynamicTypeSize` / `dynamicTypeSize`** instead of `ContentSizeCategory` / `sizeCategory` +- **Closure-based `NavigationLink` destinations** instead of eager `destination:` values +- **Direct `Animatable` conformance** instead of `AnimatableModifier` (use `@Animatable` only when its newer availability fits) + +### Lists and Forms + +**Use trailing-closure `Section` initializers instead of the positional header/footer View initializers.** + +The single-title form is still current and should not be treated as deprecated: + +```swift +// Current - single-title LocalizedStringKey initializer +Section("Settings") { + Toggle("Notifications", isOn: .constant(true)) +} + +// Replacement - content/header/footer trailing-closure initializer +Section { + Toggle("Notifications", isOn: .constant(true)) +} header: { + Text("Settings") +} footer: { + Text("Changes apply immediately.") +} + +// Deprecated/renamed - positional header/footer View arguments +Section(header: Text("Settings"), footer: Text("Changes apply immediately.")) { + Toggle("Notifications", isOn: .constant(true)) +} + +Section(header: Text("Settings")) { + Toggle("Notifications", isOn: .constant(true)) +} + +Section(footer: Text("Changes apply immediately.")) { + Toggle("Notifications", isOn: .constant(true)) +} +``` + +### Presentation + +- **Always use `.confirmationDialog(_:isPresented:actions:message:)`** instead of `actionSheet(...)`. +- **Always use `.alert(_:isPresented:actions:message:)`** instead of `alert(isPresented:content:)`. + +Both take a title `String`, `isPresented: Binding`, an `actions` builder with `Button` items (supporting `role: .destructive` / `.cancel`), and an optional `message` builder: + +```swift +.alert("Delete Item?", isPresented: $showAlert) { + Button("Delete", role: .destructive) { deleteItem() } + Button("Cancel", role: .cancel) { } +} message: { + Text("This action cannot be undone.") +} +``` + +### Text Input + +**Always use `onSubmit(of:_:)` and `focused(_:equals:)` instead of `TextField` `onEditingChanged`/`onCommit` callbacks.** + +```swift +@FocusState private var isFocused: Bool + +TextField("Search", text: $query) + .focused($isFocused) + .onSubmit { performSearch() } +``` + +### Accessibility + +**Always use dedicated accessibility modifiers instead of the generic `accessibility(...)` variants.** Use `.accessibilityLabel()`, `.accessibilityValue()`, `.accessibilityHint()`, `.accessibilityAddTraits()`, `.accessibilityHidden()` instead of `.accessibility(label:)`, `.accessibility(value:)`, etc. + +### Custom Environment / Container Values + +**Always use the `@Entry` macro instead of manual `EnvironmentKey` conformance.** The `@Entry` macro was introduced in Xcode 16 and back-deploys to all OS versions. + +```swift +// Modern — one line replaces ~10 lines of EnvironmentKey boilerplate +extension EnvironmentValues { + @Entry var myCustomValue: String = "Default value" +} +``` + +### Styling + +**Always use `Button` instead of `onTapGesture()` unless you need tap location or count.** + +```swift +Button("Tap me") { performAction() } + +// Use onTapGesture only when you need location or count +Image("photo") + .onTapGesture(count: 2) { handleDoubleTap() } +``` + +--- + +## When Targeting iOS 16+ + +### Navigation + +**Use `NavigationStack` (or `NavigationSplitView`) instead of `NavigationView`.** Value-based `NavigationLink(value:)` with `.navigationDestination(for:)` replaces destination-based links. + +```swift +NavigationStack { + List(items) { item in + NavigationLink(value: item) { Text(item.name) } + } + .navigationDestination(for: Item.self) { DetailView(item: $0) } +} +``` + +### Simple Renames + +- **`tint(_:)`** instead of `accentColor(_:)` +- **`autocorrectionDisabled(_:)`** instead of `disableAutocorrection(_:)` + +### Scroll Indicators and Search Suggestions + +- Replace `ScrollView(..., showsIndicators:)` with `ScrollView(...)` plus `scrollIndicators(_:axes:)`. +- Replace `searchable` overloads with an inline suggestions builder by composing `searchable(...)` with `searchSuggestions { ... }`. + +### Clipboard + +**Prefer `PasteButton` for user-initiated paste UI** to avoid paste prompts. It handles permissions automatically. Use `UIPasteboard` only when you need programmatic or non-`Transferable` clipboard access (triggers the paste permission prompt). + +```swift +PasteButton(payloadType: String.self) { strings in + pastedText = strings.first ?? "" +} +``` + +--- + +## When Targeting iOS 17+ + +### State Management + +- **Prefer `@Observable` over `ObservableObject` for new code.** Use `@State` instead of `@StateObject`; use `@Bindable` instead of `@ObservedObject`. See `state-management.md` for full `@Observable` migration patterns. + +### Events + +**Use `onChange(of:initial:_:)` or `onChange(of:) { }` instead of `onChange(of:perform:)`.** + +The deprecated variant passes only the new value. The modern variants provide either both old and new values, or a no-parameter closure. + +- **No-parameter** (most common): `.onChange(of: value) { doSomething() }` +- **Old and new values**: `.onChange(of: value) { old, new in ... }` +- **With initial trigger**: `.onChange(of: value, initial: true) { ... }` +- **Deprecated**: `.onChange(of: value) { newValue in ... }` — single-parameter closure + +### Sensory Feedback + +**Prefer `sensoryFeedback(_:trigger:)` and related overloads instead of `UIImpactFeedbackGenerator`, `UISelectionFeedbackGenerator`, and `UINotificationFeedbackGenerator` in SwiftUI views.** + +Attach haptics declaratively to the view that owns the state change, rather than imperatively firing UIKit generators inside button actions. + +```swift +@State private var isFavorite = false + +Button("Favorite", systemImage: isFavorite ? "heart.fill" : "heart") { + isFavorite.toggle() +} +.sensoryFeedback(.selection, trigger: isFavorite) +``` + +Use the conditional overload when feedback should fire only for specific transitions: + +```swift +.sensoryFeedback(.selection, trigger: phase) { old, new in + old == .inactive || new == .expanded +} +``` + +### Gestures + +- **`MagnifyGesture`** instead of `MagnificationGesture` (access magnitude via `value.magnification`) +- **`RotateGesture`** instead of `RotationGesture` (access angle via `value.rotation`) + +### Layout + +**Consider `containerRelativeFrame()` or `visualEffect()` as alternatives to `GeometryReader` for sizing and position-based effects.** `GeometryReader` is not deprecated and remains necessary for many measurement-based layouts. + +```swift +Image("hero") + .resizable() + .containerRelativeFrame(.horizontal) { length, axis in length * 0.8 } +``` + +- **`visualEffect { content, geometry in ... }`** — position-based effects (parallax, offsets) without a `GeometryReader` wrapper. +- **`onGeometryChange(for:of:action:)`** — react to geometry changes of a specific view; useful for driving state/effects. `GeometryReader` is still better when layout itself depends on geometry. Note the two-closure shape: + ```swift + .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.height } action: { newHeight in height = newHeight } + ``` +- **`.coordinateSpace(.named("scroll"))`** instead of `.coordinateSpace(name: "scroll")`. + +Prefer overloads accepting `CoordinateSpaceProtocol` for `SpatialTapGesture`, location-aware `onTapGesture`, `onContinuousHover`, and `GeometryProxy.frame(in:)`. + +Resolve a color in the current environment before accessing Core Graphics: + +```swift +let cgColor = color.resolve(in: environment).cgColor +``` + +--- + +## When Targeting iOS 18+ + +### Toolbar Visibility + +Use `toolbarVisibility(_:for:)` instead of `navigationBarHidden(_:)` or the older `toolbar(_:for:)` visibility overload. For deployment targets below iOS 18, retain the older modifier in the fallback branch. + +Use `toolbarBackgroundVisibility(_:for:)` instead of the `toolbarBackground(_:for:)` overload whose first argument is `Visibility`. + +On iOS, prefer `.topBarLeading` / `.topBarTrailing` over `.navigationBarLeading` / `.navigationBarTrailing`. + +### Tabs + +**Use the `Tab` API instead of `tabItem(_:)`.** + +```swift +TabView { + Tab("Home", systemImage: "house") { HomeView() } + Tab("Search", systemImage: "magnifyingglass") { SearchView() } + Tab("Profile", systemImage: "person") { ProfileView() } +} +``` + +When using `Tab(role:)`, all tabs must use the `Tab` syntax. Mixing `Tab(role:)` with `.tabItem()` causes compilation errors. + +On iOS 18.4+ / macOS 15.4+, use the typed customization accessors: + +- `customization[section: id].tabOrder` +- `customization[tab: id].sidebarVisibility` +- `customization[section: id].resetTabOrder()` + +### Previews + +**Use `@Previewable` for dynamic properties in previews.** + +```swift +// Modern (iOS 18+) +#Preview { + @Previewable @State var isOn = false + Toggle("Setting", isOn: $isOn) +} +``` + +--- + +## When Targeting iOS 26+ + +For Liquid Glass APIs (`glassEffect`, `GlassEffectContainer`, glass button styles), see [liquid-glass.md](liquid-glass.md). + +### Scroll Edge Effects + +**Use `scrollEdgeEffectStyle(_:for:)` to configure scroll edge behavior.** + +```swift +ScrollView { + // content +} +.scrollEdgeEffectStyle(.soft, for: .top) +``` + +### Background Extension + +**Use `backgroundExtensionEffect()` for edge-extending blurred backgrounds.** + +Views behind a Liquid Glass sidebar can appear clipped. This modifier mirrors and blurs content outside the safe area so artwork remains visible. + +```swift +Image("hero") + .backgroundExtensionEffect() +``` + +> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323) + +### Tab Bar + +**Use `tabBarMinimizeBehavior(_:)` to control tab bar minimization on scroll.** + +```swift +TabView { + // tabs +} +.tabBarMinimizeBehavior(.onScrollDown) +``` + +**Use `tabViewBottomAccessory` for persistent controls above the tab bar.** Read `tabViewBottomAccessoryPlacement` from the environment to adapt content when the accessory collapses into the tab bar area. + +```swift +TabView { + // tabs +} +.tabViewBottomAccessory { + NowPlayingBar() +} +``` + +**Use `Tab(role: .search)` for a dedicated search tab.** The tab separates from the rest and morphs into a search field when selected. + +```swift +TabView { + Tab("Home", systemImage: "house") { HomeView() } + Tab("Profile", systemImage: "person") { ProfileView() } + Tab(role: .search) { SearchResultsView() } +} +``` + +> Source: "What's new in SwiftUI" (WWDC25, session 256) and "Build a SwiftUI app with the new design" (WWDC25, session 323) + +### Toolbars + +For `ToolbarSpacer`, shared-background visibility, badges, customization, transitions, overflow, and minimization, consult [`toolbar-patterns.md`](toolbar-patterns.md). + +### Search + +Use `searchToolbarBehavior(.minimize)` on iOS or visionOS 26+ to opt into a minimized search button. See [`toolbar-patterns.md`](toolbar-patterns.md) for platform availability. + +### Animations + +**Use `@Animatable` macro instead of manual `animatableData` declarations.** The macro auto-synthesizes `animatableData` from all animatable properties. Use `@AnimatableIgnored` to exclude specific properties. + +```swift +@Animatable +struct Wedge: Shape { + var startAngle: Angle + var endAngle: Angle + @AnimatableIgnored var drawClockwise: Bool + + func path(in rect: CGRect) -> Path { /* ... */ } +} +``` + +> Source: "What's new in SwiftUI" (WWDC25, session 256) + +### Presentations + +**Use `navigationZoomTransition` to morph sheets out of their source view.** Toolbar items and buttons can serve as the transition source. + +```swift +.toolbar { + ToolbarItem { + Button("Add", systemImage: "plus") { showSheet = true } + .navigationTransitionSource(id: "addSheet", namespace: namespace) + } +} +.sheet(isPresented: $showSheet) { + AddItemView() + .navigationTransitionDestination(id: "addSheet", namespace: namespace) +} +``` + +> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323) + +### Controls + +**Use `controlSize(.extraLarge)` for extra-large prominent action buttons.** + +```swift +Button("Get Started") { } + .buttonStyle(.borderedProminent) + .controlSize(.extraLarge) +``` + +**Use `concentric` corner style for buttons that match their container's corners.** + +```swift +Button("Confirm") { } + .clipShape(.rect(cornerRadius: 12, style: .concentric)) +``` + +**Sliders now support tick marks and a neutral value.** + +```swift +Slider(value: $speed, in: 0.5...2.0, step: 0.25) { + Text("Speed") +} ticks: { + SliderTick(value: 0.6) + SliderTick(value: 0.9) +} +.sliderNeutralValue(1.0) +``` + +> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323) + +### Rich Text + +**Use `TextEditor` with an `AttributedString` binding for rich text editing.** Supports bold, italic, underline, strikethrough, custom fonts, foreground/background colors, paragraph styles, and Genmoji. + +```swift +@State private var text: AttributedString = "Hello, world!" + +var body: some View { + TextEditor(text: $text) +} +``` + +> Source: "Cook up a rich text experience in SwiftUI with AttributedString" (WWDC25, session 280) + +### Web Content + +**Use `WebView` to display web content.** For richer interaction, create a `WebPage` observable model. + +```swift +// Simple URL display +WebView(url: URL(string: "https://example.com")!) + +// With observable model +@State private var page = WebPage() + +WebView(page) + .onAppear { page.load(URLRequest(url: myURL)) } + .navigationTitle(page.title ?? "") +``` + +> Source: "Meet WebKit for SwiftUI" (WWDC25, session 231) + +### Drag and Drop + +**Use `dragContainer` for multi-item drag operations.** Combine with `DragConfiguration` for custom drag behavior and `onDragSessionUpdated` to observe events. + +```swift +PhotoGrid(photos: photos) + .dragContainer(for: Photo.self) { selection in + return selection.map { $0.transferable } + } + .onDragSessionUpdated { session in + if session.phase == .endedWithDelete { + deleteSelectedPhotos() + } + } +``` + +Migrate the older location/`isTargeted` `dropDestination` overload to `dropDestination(for:isEnabled:action:)` when targeting iOS, macOS, or visionOS 26+. Its action receives a `DropSession` and returns `Void`, so this is a behavioral migration rather than a label-only rename. + +> Source: "What's new in SwiftUI" (WWDC25, session 256) + +### Scene Bridging + +**UIKit and AppKit lifecycle apps can now request SwiftUI scenes.** This enables using SwiftUI-only scene types like `MenuBarExtra` and `ImmersiveSpace` from imperative lifecycle apps via `UIApplication.shared.activateSceneSession(for:errorHandler:)`. + +> Source: "What's new in SwiftUI" (WWDC25, session 256) + +--- + +## When Targeting iOS 27+ + +Use the focused topic references for detailed guidance: + +- [`state-management.md`](state-management.md) +- [`view-structure.md`](view-structure.md) +- [`list-patterns.md`](list-patterns.md) +- [`image-optimization.md`](image-optimization.md) +- [`sheet-navigation-patterns.md`](sheet-navigation-patterns.md) +- [`toolbar-patterns.md`](toolbar-patterns.md) + +On iOS 27+, use `toolbarVisibility(_:for: .statusBar)` instead of `statusBarHidden(_:)`. `ToolbarPlacement.statusBar` is iOS-only; on visionOS, remove `statusBarHidden` because it has no effect. The newer `dropDestination(for:isEnabled:action:)` overload is also available on visionOS 26+ (as well as iOS/macOS 26+). + +### Additional SDK 27 soft-deprecated families + +Use `Menu` / `MenuStyle` instead of `MenuButton`, `MenuButtonStyle`, and the legacy menu-button styles (`PullDownMenuButtonStyle`, `BorderlessPullDownMenuButtonStyle`, `BorderlessButtonMenuButtonStyle`, `DefaultMenuButtonStyle`, `BorderedButtonMenuStyle`, and `BorderlessButtonMenuStyle`). Use `.menuStyle(.menu)` or `.menuStyle(.button)` with a button style instead of `PopUpButtonPickerStyle`. + +Other lookup entries from the SDK include: + +- `ContextMenu` and `contextMenu(_:)` → `contextMenu(menuItems:)` +- `Section(header:...)/Section(footer:...)/Section(header:footer:...)` → trailing-closure `Section(content:header:footer:)` forms +- `GroupBox(label:content:)` → `GroupBox(content:label:)` +- `Picker(selection:label:content:)` → `Picker(selection:content:label:)` +- `Color(_:)` platform and `CGColor` initializers → `Color(uiColor:)`, `Color(nsColor:)`, and `Color(cgColor:)`; `Color.cgColor` → `resolve(in:).cgColor` +- `onLongPressGesture` overloads with `pressing:` → `onLongPressGesture(minimumDuration:maximumDuration:perform:onPressingChanged:)` or its shorter counterpart +- `Font.system(_:design:)` and legacy `Font.system(size:weight:design:)` forms → `system(_:weight:design:)` and the current size/weight/design overloads +- `Section.collapsible(_:)` → a standard `Section` initializer (collapsibility is no longer enabled by that modifier) +- string-type paste/drop APIs (`PasteButton`, `onPasteCommand`, `onInsert`, and `DropInfo.hasItemsConforming`) → UTType-based APIs + +Platform-specific entries include `CarouselTabViewStyle` → `VerticalTabViewStyle` and `listRowPlatterColor(_:)` → `listItemTint(_:)` on watchOS, `ControlActiveState` → `appearsActive` on macOS, and `SurroundingsEffect.systemDark` → `.dark` on visionOS. + +Search this file's lookup table when migrating an API that the 27 SDK marks soft-deprecated. Do not introduce unrelated migrations during feature work; follow [`soft-deprecation.md`](soft-deprecation.md). + +--- + +## Quick Lookup Table + +| Deprecated | Recommended | Since | +|-----------|-------------|-------| +| `navigationBarTitle(_:)` | `navigationTitle(_:)` | iOS 15+ | +| `navigationBarItems(...)` | `toolbar { ToolbarItem(...) }` | iOS 15+ | +| `navigationBarHidden(_:)` | `toolbarVisibility(.hidden, for: .navigationBar)` | iOS 18+; retain old API in earlier fallback | +| `statusBar(hidden:)` / `statusBarHidden(_:)` | `toolbarVisibility(_:for: .statusBar)` | iOS 27+; retain old API in earlier fallback | +| `edgesIgnoringSafeArea(_:)` | `ignoresSafeArea(_:edges:)` | iOS 15+ | +| `colorScheme(_:)` | `preferredColorScheme(_:)` | iOS 15+ | +| `foregroundColor(_:)` | `foregroundStyle(_:)` | iOS 15+ | +| `cornerRadius(_:)` | `clipShape(.rect(cornerRadius:))` | iOS 15+ | +| `actionSheet(...)` | `confirmationDialog(...)` | iOS 15+ | +| `alert(isPresented:content:)` | `alert(_:isPresented:actions:message:)` | iOS 15+ | +| `autocapitalization(_:)` | `textInputAutocapitalization(_:)` | iOS 15+ | +| `accessibility(label:)` etc. | `accessibilityLabel()` etc. | iOS 15+ | +| `TextField` `onCommit`/`onEditingChanged` | `onSubmit` + `focused` | iOS 15+ | +| `animation(_:)` (no value) | `animation(_:value:)` | Back-deploys (iOS 13+) | +| `Section(header:content:)` | `Section(content:header:)` | Future-deprecated | +| `Section(footer:content:)` | `Section(content:footer:)` | Future-deprecated | +| `Section(header:footer:content:)` | `Section(content:header:footer:)` | Future-deprecated | +| Manual `EnvironmentKey` | `@Entry` macro | Back-deploys (Xcode 16+) | +| `NavigationView` | `NavigationStack` / `NavigationSplitView` | iOS 16+ | +| `accentColor(_:)` | `tint(_:)` | iOS 16+ | +| `disableAutocorrection(_:)` | `autocorrectionDisabled(_:)` | iOS 16+ | +| `UIPasteboard.general` | `PasteButton` | iOS 16+ | +| `onChange(of:perform:)` | `onChange(of:) { }` or `onChange(of:) { old, new in }` | iOS 17+ | +| `UIImpactFeedbackGenerator` / `UISelectionFeedbackGenerator` / `UINotificationFeedbackGenerator` | `sensoryFeedback(_:trigger:)` | iOS 17+ | +| `MagnificationGesture` | `MagnifyGesture` | iOS 17+ | +| `RotationGesture` | `RotateGesture` | iOS 17+ | +| `coordinateSpace(name:)` | `coordinateSpace(.named(...))` | iOS 17+ | +| `ObservableObject` | `@Observable` | iOS 17+ | +| `tabItem(_:)` | `Tab` API | iOS 18+ | +| Manual 1:1 `animatableData` synthesis | `@Animatable` macro; keep manual logic for clamping/normalization | iOS 26+ | +| `presentationBackground(_:)` on sheets | Default Liquid Glass sheet material | iOS 26+ | +| Custom toolbar background hacks | `scrollEdgeEffectStyle(_:for:)` | iOS 26+ | +| `CarouselTabViewStyle` (watchOS) | `VerticalTabViewStyle` | SDK 27 soft-deprecated | +| `ControlActiveState` / `controlActiveState` (macOS) | `appearsActive` | SDK 27 soft-deprecated | +| `AnimatableModifier` | Conform the modifier to `Animatable` directly | SDK 27 soft-deprecated | +| `FileDocument`, `ReferenceFileDocument`, and legacy `DocumentGroup` initializers | `Document` (`ReadableDocument` / `WritableDocument`) and closure-based `DocumentGroup` | SDK 27 soft-deprecated; replacement requires aligned 27 releases | +| `TabView(selection:content:)` legacy builder | `TabContentBuilder`-based `TabView` initializers | SDK 27 soft-deprecated | +| `listRowPlatterColor(_:)` (watchOS) | `listItemTint(_:)` | SDK 27 soft-deprecated | +| `toolbarBackground(_:for:)` visibility overload | `toolbarBackgroundVisibility(_:for:)` | iOS 18+ / macOS 15+ | +| `toolbar(_:for:)` visibility overload | `toolbarVisibility(_:for:)` | iOS 18+ / macOS 15+ | +| `searchable(..., suggestions:)` builder overloads | `searchable(...)` plus `searchSuggestions { ... }` | iOS 16+ / macOS 13+ | +| `ScrollView(..., showsIndicators:)` | `ScrollView(...)` plus `scrollIndicators(_:axes:)` | iOS 16+ / macOS 13+ | +| Eager `NavigationLink(destination:)` initializers | Closure destination or value-based navigation | SDK 27 soft-deprecated | +| String type identifiers in paste/drop APIs | `UTType`-based overloads | SDK 27 soft-deprecated | +| Coordinate-space overloads taking `CoordinateSpace` | `CoordinateSpaceProtocol` overloads | iOS 17+ / macOS 14+ | +| Style initializers with `tint:` | Apply `View.tint(_:)` | SDK 27 soft-deprecated | +| Inset/bordered list or table styles with `alternatesRowBackgrounds:` | Base style plus `alternatingRowBackgrounds()` | SDK 27 soft-deprecated | +| `ToolbarItem(..., showsByDefault:)` | `defaultCustomization(_:options:)` with `.hidden` | SDK 27 soft-deprecated | +| `TabViewCustomization` legacy section/sidebar subscripts | Typed `section`/`tab` subscript properties | iOS 18.4+ / macOS 15.4+ | +| Location/`isTargeted` `dropDestination` overload | `dropDestination(for:isEnabled:action:)` with `DropSession` | iOS/macOS/visionOS 26+ | diff --git a/.cursor/skills/swiftui-expert-skill/references/layout-best-practices.md b/.cursor/skills/swiftui-expert-skill/references/layout-best-practices.md new file mode 100644 index 00000000..d15b37e8 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/layout-best-practices.md @@ -0,0 +1,288 @@ +# SwiftUI Layout Best Practices Reference + +## Table of Contents + +- [Relative Layout Over Constants](#relative-layout-over-constants) +- [Context-Agnostic Views](#context-agnostic-views) +- [Adaptive and Resizable Interfaces](#adaptive-and-resizable-interfaces) +- [Own Your Container](#own-your-container) +- [Layout Performance](#layout-performance) +- [View Logic and Testability](#view-logic-and-testability) +- [Full-Width Views](#full-width-views) +- [Action Handlers](#action-handlers) +- [Summary Checklist](#summary-checklist) + +## Relative Layout Over Constants + +**Use dynamic layout calculations instead of hard-coded values.** + +```swift +// Good - relative to actual layout +GeometryReader { geometry in + VStack { + HeaderView() + .frame(height: geometry.size.height * 0.2) + ContentView() + } +} + +// Avoid - magic numbers that don't adapt +VStack { + HeaderView() + .frame(height: 150) // Doesn't adapt to different screens + ContentView() +} +``` + +**Why**: Hard-coded values don't account for different screen sizes, orientations, or dynamic content (like status bars during phone calls). + +## Context-Agnostic Views + +**Views should work in any context.** Never assume presentation style or screen size. + +```swift +// Good - adapts to given space +struct ProfileCard: View { + let user: User + + var body: some View { + VStack { + Image(user.avatar) + .resizable() + .aspectRatio(contentMode: .fit) + Text(user.name) + Spacer() + } + .padding() + } +} + +// Avoid - assumes full screen +struct ProfileCard: View { + let user: User + + var body: some View { + VStack { + Image(user.avatar) + .frame(width: UIScreen.main.bounds.width) // Wrong! + Text(user.name) + } + } +} +``` + +**Why**: Views should work as full screens, modals, sheets, popovers, or embedded content. + +## Adaptive and Resizable Interfaces + +Size views from the **proposed size**, not from a fixed screen or orientation. Prefer `@Environment(\.horizontalSizeClass)` / `verticalSizeClass`, `ViewThatFits`, and `AnyLayout` when choosing a layout variant. Avoid `UIScreen.main`, `UIScreen.main.bounds`, and portrait/landscape assumptions — those do not track the space actually offered to the view (split view, Stage Manager, windows, sheets). + +```swift +struct AdaptiveStack: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @ViewBuilder let content: Content + + var body: some View { + let layout = horizontalSizeClass == .compact + ? AnyLayout(VStackLayout()) + : AnyLayout(HStackLayout()) + layout { content } + } +} +``` + +Use `ViewThatFits` when a compact alternative should replace a layout that overflows the proposal. Do not branch layout on device orientation or a cached screen size. + +## Own Your Container + +**Custom views should own static containers but not lazy/repeatable ones.** + +```swift +// Good - owns static container +struct HeaderView: View { + var body: some View { + HStack { + Image(systemName: "star") + Text("Title") + Spacer() + } + } +} + +// Avoid - missing container +struct HeaderView: View { + var body: some View { + Image(systemName: "star") + Text("Title") + // Caller must wrap in HStack + } +} + +// Good - caller owns lazy container +struct FeedView: View { + let items: [Item] + + var body: some View { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item) + } + } + } +} +``` + +## Layout Performance + +### Avoid Layout Thrash + +**Minimize deep view hierarchies and excessive layout dependencies.** + +```swift +// Bad - deep nesting, excessive layout passes +VStack { + HStack { + VStack { + HStack { + VStack { + Text("Deep") + } + } + } + } +} + +// Good - flatter hierarchy +VStack { + Text("Shallow") + Text("Structure") +} +``` + +**Avoid excessive `GeometryReader` and preference chains:** + +```swift +// Bad - multiple geometry readers cause layout thrash +GeometryReader { outerGeometry in + VStack { + GeometryReader { innerGeometry in + // Layout recalculates multiple times + } + } +} + +// Good - single geometry reader or use alternatives (iOS 17+) +containerRelativeFrame(.horizontal) { width, _ in + width * 0.8 +} +``` + +**Gate frequent geometry updates:** + +```swift +// Bad - updates on every pixel change +.onPreferenceChange(ViewSizeKey.self) { size in + currentSize = size +} + +// Good - gate by threshold +.onPreferenceChange(ViewSizeKey.self) { size in + let difference = abs(size.width - currentSize.width) + if difference > 10 { // Only update if significant change + currentSize = size + } +} +``` + +## View Logic and Testability + +### Keep Business Logic in Services and Models + +**Business logic belongs in services and models, not in views.** Views should stay simple and declarative — orchestrating UI state, not implementing business rules. This makes logic independently testable without requiring view instantiation. + +> **iOS 17+**: Use `@Observable` with `@State`. + +```swift +@Observable +final class AuthService { + var email = "" + var password = "" + var isValid: Bool { + !email.isEmpty && password.count >= 8 + } + + func login() async throws { + // Business logic here — testable without the view + } +} + +struct LoginView: View { + @State private var authService = AuthService() + + var body: some View { + Form { + TextField("Email", text: $authService.email) + SecureField("Password", text: $authService.password) + Button("Login") { + Task { + try? await authService.login() + } + } + .disabled(!authService.isValid) + } + } +} +``` + +For iOS 16 and earlier, use `ObservableObject` with `@StateObject` -- see `state-management.md` for the legacy pattern. + +Avoid embedding business logic directly in view closures (e.g., validation checks inside a `Button` action). This makes logic untestable without view instantiation. + +**Note**: This is about making business logic testable, not about enforcing a specific architecture. The key is that logic lives outside views where it can be tested independently. + +## Full-Width Views + +**When a single view needs to fill the available width, use `.frame(maxWidth: .infinity, alignment:)` instead of wrapping it in a stack with a `Spacer`.** + +```swift +// Good - frame modifier +Text("Hello") + .frame(maxWidth: .infinity, alignment: .leading) + +// Avoid - unnecessary stack and spacer +HStack { + Text("Hello") + Spacer() +} +``` + +**Why**: `.frame(maxWidth:alignment:)` is a single modifier that clearly communicates intent. Wrapping in an `HStack` with a `Spacer` adds an extra container to the view hierarchy for no benefit. + +## Action Handlers + +**Separate layout from logic.** View body should reference action methods, not contain inline logic. + +```swift +// Good - action references method +Button("Publish Project", action: publishService.handlePublish) + +// Avoid - multi-line logic in closure +Button("Publish Project") { + isLoading = true + apiService.publish(project) { result in /* ... */ } +} +``` + +## Summary Checklist + +- [ ] Use relative layout over hard-coded constants +- [ ] Views work in any context (don't assume screen size) +- [ ] Adapt with proposed size, size classes, `ViewThatFits`, or `AnyLayout` — not `UIScreen.main` or orientation +- [ ] Custom views own static containers +- [ ] Avoid deep view hierarchies (layout thrash) +- [ ] Gate frequent geometry updates by thresholds +- [ ] Business logic kept in services and models (not in views) +- [ ] Action handlers reference methods, not inline logic +- [ ] Use `.frame(maxWidth: .infinity, alignment:)` for full-width views (not `HStack` + `Spacer`) +- [ ] Avoid excessive `GeometryReader` usage +- [ ] Use `containerRelativeFrame()` when appropriate diff --git a/.cursor/skills/swiftui-expert-skill/references/liquid-glass.md b/.cursor/skills/swiftui-expert-skill/references/liquid-glass.md new file mode 100644 index 00000000..802fc7ef --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/liquid-glass.md @@ -0,0 +1,441 @@ +# SwiftUI Liquid Glass Reference (iOS 26+) + +## Table of Contents + +- [Overview](#overview) +- [Availability](#availability) +- [Core APIs](#core-apis) +- [GlassEffectContainer](#glasseffectcontainer) +- [Glass Button Styles](#glass-button-styles) +- [Morphing Transitions](#morphing-transitions) +- [Modifier Order](#modifier-order) +- [Complete Examples](#complete-examples) +- [Fallback Strategies](#fallback-strategies) +- [Design System Notes](#design-system-notes) +- [Best Practices](#best-practices) +- [Checklist](#checklist) + +## Overview + +Liquid Glass is Apple's new design language introduced in iOS 26. It provides translucent, dynamic surfaces that respond to content and user interaction. This reference covers the native SwiftUI APIs for implementing Liquid Glass effects. + +**Only adopt Liquid Glass when explicitly requested by the user.** Do not proactively convert existing UI to glass effects. + +## Availability + +All Liquid Glass APIs require iOS 26 or later. Always provide fallbacks: + +```swift +if #available(iOS 26, *) { + // Liquid Glass implementation +} else { + // Fallback using materials +} +``` + +## Core APIs + +### glassEffect Modifier + +The primary modifier for applying glass effects to views: + +```swift +.glassEffect(_ glass: Glass = .regular, in shape: some Shape = DefaultGlassEffectShape()) +``` + +#### Basic Usage + +```swift +Text("Hello") + .padding() + .glassEffect() // Default regular style, capsule shape +``` + +#### With Shape + +```swift +Text("Rounded Glass") + .padding() + .glassEffect(in: .rect(cornerRadius: 16)) + +Image(systemName: "star") + .padding() + .glassEffect(in: .circle) + +Text("Capsule") + .padding(.horizontal, 20) + .padding(.vertical, 10) + .glassEffect(in: .capsule) +``` + +### Glass + +#### Available Styles + +The `Glass` type exposes three static values — there is no `.prominent`: + +```swift +.glassEffect(.regular) // Standard glass appearance (most common) +.glassEffect(.clear) // Nearly invisible glass surface +.glassEffect(.identity) // No-op / pass-through glass +``` + +To make a surface appear more prominent, increase the tint opacity instead of reaching for a non-existent `.prominent` property. + +#### Tinting + +Add color tint to the glass: + +```swift +.glassEffect(.regular.tint(.blue)) +.glassEffect(.regular.tint(.red.opacity(0.3))) +``` + +#### Interactivity + +Make glass respond to touch/pointer hover: + +```swift +// Interactive glass - responds to user interaction +.glassEffect(.regular.interactive()) + +// Combined with tint +.glassEffect(.regular.tint(.blue).interactive()) +``` + +**Important**: Only use `.interactive()` on elements that actually respond to user input (buttons, tappable views, focusable elements). + +## GlassEffectContainer + +Wraps multiple glass elements for proper visual grouping and spacing. + +**Glass cannot sample other glass.** The glass material reflects and refracts light by sampling content from an area larger than itself. Nearby glass elements in different containers will produce inconsistent visual results because they cannot sample each other. `GlassEffectContainer` gives grouped elements a shared sampling region, ensuring a consistent appearance. + +```swift +GlassEffectContainer { + HStack { + Button("One") { } + .glassEffect() + Button("Two") { } + .glassEffect() + } +} +``` + +### With Spacing + +Control the visual spacing between glass elements: + +```swift +GlassEffectContainer(spacing: 24) { + HStack(spacing: 24) { + GlassChip(icon: "pencil") + GlassChip(icon: "eraser") + GlassChip(icon: "trash") + } +} +``` + +**Note**: The container's `spacing` parameter should match the actual spacing in your layout for proper glass effect rendering. + +> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323) + +## Glass Button Styles + +Built-in button styles for glass appearance: + +```swift +// Standard glass button +Button("Action") { } + .buttonStyle(.glass) + +// Prominent glass button (higher visibility) +Button("Primary Action") { } + .buttonStyle(.glassProminent) +``` + +### Custom Glass Buttons + +For more control, apply glass effect manually: + +```swift +Button(action: { }) { + Label("Settings", systemImage: "gear") + .padding() +} +.glassEffect(.regular.interactive(), in: .capsule) +``` + +## Morphing Transitions + +Create smooth transitions between glass elements using `glassEffectID` and `@Namespace`: + +```swift +struct MorphingExample: View { + @Namespace private var animation + @State private var isExpanded = false + + var body: some View { + GlassEffectContainer { + if isExpanded { + ExpandedCard() + .glassEffect() + .glassEffectID("card", in: animation) + } else { + CompactCard() + .glassEffect() + .glassEffectID("card", in: animation) + } + } + .animation(.smooth, value: isExpanded) + } +} +``` + +### Requirements for Morphing + +1. Both views must have the same `glassEffectID` +2. Use the same `@Namespace` +3. Wrap in `GlassEffectContainer` +4. Apply animation to the container or parent + +### Unioning glass effects + +Use `glassEffectUnion(id:namespace:)` when related glass views should render as one union while remaining separate views in the hierarchy. The ID is optional; use the same ID and namespace for views that belong to the union. + +```swift +@Namespace private var glassNamespace + +HStack { + ActionButton() + .glassEffectUnion(id: "actions", namespace: glassNamespace) + StatusView() + .glassEffectUnion(id: "actions", namespace: glassNamespace) +} +``` + +`glassEffectUnion(id:namespace:)` is available on iOS 26+, macOS 26+, tvOS 26+, and watchOS 26+; it is unavailable on visionOS. + +## Modifier Order + +**Critical**: Apply `glassEffect` after layout and visual modifiers: + +```swift +// CORRECT order +Text("Label") + .font(.headline) // 1. Typography + .foregroundStyle(.primary) // 2. Color + .padding() // 3. Layout + .glassEffect() // 4. Glass effect LAST + +// WRONG order - glass applied too early +Text("Label") + .glassEffect() // Wrong position + .padding() + .font(.headline) +``` + +## Complete Examples + +### Toolbar with Glass Buttons + +```swift +struct GlassToolbar: View { + var body: some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 16) { + HStack(spacing: 16) { + ToolbarButton(icon: "pencil", action: { }) + ToolbarButton(icon: "eraser", action: { }) + ToolbarButton(icon: "scissors", action: { }) + Spacer() + ToolbarButton(icon: "square.and.arrow.up", action: { }) + } + .padding(.horizontal) + } + } else { + // Fallback toolbar + HStack(spacing: 16) { + // ... fallback implementation + } + } + } +} + +struct ToolbarButton: View { + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.title2) + .frame(width: 44, height: 44) + } + .glassEffect(.regular.interactive(), in: .circle) + } +} +``` + +### Card with Glass Effect + +```swift +struct GlassCard: View { + let title: String + let subtitle: String + + var body: some View { + if #available(iOS 26, *) { + cardContent + .glassEffect(.regular, in: .rect(cornerRadius: 20)) + } else { + cardContent + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) + } + } + + private var cardContent: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} +``` + +### Segmented Control + +```swift +struct GlassSegmentedControl: View { + @Binding var selection: Int + let options: [String] + @Namespace private var animation + + var body: some View { + if #available(iOS 26, *) { + GlassEffectContainer(spacing: 4) { + HStack(spacing: 4) { + ForEach(options.indices, id: \.self) { index in + Button(options[index]) { + withAnimation(.smooth) { + selection = index + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .glassEffect( + selection == index + ? .regular.tint(.accentColor.opacity(0.4)).interactive() + : .regular.interactive(), + in: .capsule + ) + .glassEffectID(selection == index ? "selected" : "option\(index)", in: animation) + } + } + .padding(4) + } + } else { + Picker("Options", selection: $selection) { + ForEach(options.indices, id: \.self) { index in + Text(options[index]).tag(index) + } + } + .pickerStyle(.segmented) + } + } +} +``` + +## Fallback Strategies + +### Using Materials + +```swift +if #available(iOS 26, *) { + content.glassEffect() +} else { + content.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) +} +``` + +### Available Materials for Fallback + +- `.ultraThinMaterial` - Closest to glass appearance +- `.thinMaterial` - Slightly more opaque +- `.regularMaterial` - Standard blur +- `.thickMaterial` - More opaque +- `.ultraThickMaterial` - Most opaque + +### Conditional Modifier Extension + +```swift +extension View { + @ViewBuilder + func glassEffectWithFallback( + _ glass: Glass = .regular, + in shape: some Shape = .rect, + fallbackMaterial: Material = .ultraThinMaterial + ) -> some View { + if #available(iOS 26, *) { + self.glassEffect(glass, in: shape) + } else { + self.background(fallbackMaterial, in: shape) + } + } +} +``` + +## Design System Notes + +### Toolbar Icons + +In the new design, toolbar icons use **monochrome rendering** by default. The monochrome palette reduces visual noise and maintains legibility. Use `tint(_:)` only to convey meaning (e.g., a call to action), not for visual effect. + +### Sheet Presentations + +Partial-height sheets use a Liquid Glass background by default. If you previously used `presentationBackground(_:)` with a custom background, consider removing it to let the new material shine. Sheets can morph out of the glass controls that present them using `navigationZoomTransition`. + +### Scroll Edge Effects + +An automatic scroll edge effect blurs and fades content under system toolbars to keep controls legible. Remove any custom background-darkening effects behind bar items, as they will interfere. + +> Source: "Build a SwiftUI app with the new design" (WWDC25, session 323) + +## Best Practices + +### Do + +- Use `GlassEffectContainer` for grouped glass elements (glass cannot sample other glass) +- Apply glass after layout modifiers +- Use `.interactive()` only on tappable elements +- Match container spacing with layout spacing +- Provide material-based fallbacks for older iOS +- Keep glass shapes consistent within a feature +- Remove custom `presentationBackground(_:)` on sheets to use the default glass material + +### Don't + +- Apply glass to every element (use sparingly) +- Use `.interactive()` on static content +- Mix different corner radii arbitrarily +- Forget iOS version checks +- Apply glass before padding/frame modifiers +- Nest `GlassEffectContainer` unnecessarily +- Add custom darkening backgrounds behind toolbars (conflicts with scroll edge effect) +- For toolbar grouping, customization, overflow, and minimization, see [toolbar-patterns.md](toolbar-patterns.md). + +## Checklist + +- [ ] `#available(iOS 26, *)` with fallback +- [ ] `GlassEffectContainer` wraps grouped elements +- [ ] `.glassEffect()` applied after layout modifiers +- [ ] `.interactive()` only on user-interactable elements +- [ ] `glassEffectID` with `@Namespace` for morphing +- [ ] Consistent shapes and spacing across feature +- [ ] Container spacing matches layout spacing +- [ ] Tint opacity used instead of non-existent `.prominent` for emphasis diff --git a/.cursor/skills/swiftui-expert-skill/references/list-patterns.md b/.cursor/skills/swiftui-expert-skill/references/list-patterns.md new file mode 100644 index 00000000..e0fd2898 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/list-patterns.md @@ -0,0 +1,537 @@ +# SwiftUI List Patterns Reference + +## Table of Contents + +- [ForEach Identity and Stability](#foreach-identity-and-stability) +- [Enumerated Sequences](#enumerated-sequences) +- [Reorderable Collections (SDK 27)](#reorderable-collections-sdk-27) +- [Swipe Actions Outside List (SDK 27)](#swipe-actions-outside-list-sdk-27) +- [List with Custom Styling](#list-with-custom-styling) +- [List with Pull-to-Refresh](#list-with-pull-to-refresh) +- [Empty States with ContentUnavailableView (iOS 17+)](#empty-states-with-contentunavailableview-ios-17) +- [Custom List Backgrounds](#custom-list-backgrounds) +- [Table](#table) +- [Summary Checklist](#summary-checklist) + +## ForEach Identity and Stability + +**Always provide stable identity for `ForEach`.** Never use `.indices` for dynamic content. + +The same identity rules apply to any data-driven initializer that behaves like `ForEach`: collection-driven `List` (including selection-aware overloads), `Table`, `OutlineGroup`, `Picker` collections, and `DisclosureGroup` content. Ids must be stable, unique, independent of position or mutable content, and cheap to hash. + +```swift +// Good - stable identity via Identifiable +extension User: Identifiable { + var id: String { userId } +} + +ForEach(users) { user in + UserRow(user: user) +} + +// Good - stable identity via keypath +ForEach(users, id: \.userId) { user in + UserRow(user: user) +} + +// Wrong - indices create static content +ForEach(users.indices, id: \.self) { index in + UserRow(user: users[index]) // Can crash on removal! +} + +// Wrong - unstable identity +ForEach(users, id: \.self) { user in + UserRow(user: user) // Only works if User is Hashable and stable +} +``` + +**Critical**: Ensure **constant number of views per element** in `ForEach`: + +```swift +// Good - consistent view count +ForEach(items) { item in + ItemRow(item: item) +} + +// Bad - variable view count breaks identity +ForEach(items) { item in + if item.isSpecial { + SpecialRow(item: item) + DetailRow(item: item) + } else { + RegularRow(item: item) + } +} +``` + +**Avoid inline filtering:** + +```swift +// Bad - unstable identity, changes on every update +ForEach(items.filter { $0.isEnabled }) { item in + ItemRow(item: item) +} + +// Good - prefilter and cache +@State private var enabledItems: [Item] = [] + +var body: some View { + ForEach(enabledItems) { item in + ItemRow(item: item) + } + .onChange(of: items) { _, newItems in + enabledItems = newItems.filter { $0.isEnabled } + } +} +``` + +Cheap transformations — a small slice, `prefix(n)`, reading an already-prepared array — are fine inline. The rule targets work whose cost scales with the collection or that allocates new elements. + +**Avoid `AnyView` in list rows:** + +```swift +// Bad - hides identity, increases cost +ForEach(items) { item in + AnyView(item.isSpecial ? SpecialRow(item: item) : RegularRow(item: item)) +} + +// Good - Create a unified row view with a single top-level container +ForEach(items) { item in + ItemRow(item: item) +} + +struct ItemRow: View { + let item: Item + + var body: some View { + // The VStack keeps the row "unary" (one top-level view) so the + // List can template row ids without evaluating every row's body. + VStack { + if item.isSpecial { + SpecialRow(item: item) + } else { + RegularRow(item: item) + } + } + } +} +``` + +Replacing `AnyView` with a `@ViewBuilder` helper that still branches at the top level is only half the fix; wrap the branching content in a single-root container so the row stays unary. + +**Why**: Stable identity is critical for performance and animations. Unstable identity causes excessive diffing, broken animations, and potential crashes. + +### Prefer unary rows in `List` + +`List` needs the identity of every row up front. When each row's body produces a **single top-level view** (a "unary" row), SwiftUI can template the row id from the `ForEach` element's id alone, without running each row's `body`. When the body branches between different top-level shapes — a bare top-level `switch`, a top-level `if` without `else`, or an `AnyView` — structural identity varies per row, so SwiftUI falls back to evaluating every row's body just to compute ids. That cost scales with the number of rows. + +The fix is to wrap branching content in any single-root container (`VStack`, `HStack`, `ZStack`, or a custom wrapper) so the row is always exactly one top-level view, as shown above. `Group` is a passthrough rather than a layout container, so it does not make multiple children unary. A top-level `if` without an `else` is also "multi" (0 or 1 views); if some elements shouldn't be rows at all, filter the collection before it reaches the `ForEach` rather than producing a zero-view row. + +To find non-constant row builders in an existing app, launch with `-LogForEachSlowPath YES`; SwiftUI logs each `ForEach` inside a lazy container whose row body produces a non-constant number of views. + +### Keep ids stable, unique, and cheap + +Three more identity rules that prevent subtle bugs: + +- **The id must outlive the view and not change on edit.** Don't derive `id` from a mutable property (e.g. `var id: String { title }`). Editing the title changes the id, so SwiftUI treats it as a removal plus insertion — focus and per-row state are lost mid-edit. Use a stable `let id: UUID` or a server-assigned key. +- **Don't synthesize a fresh id inside `body`.** `ForEach(items.map { Item(title: $0) })` creates new `UUID`s on every body pass, so the whole collection reads as replaced every update. Create ids once in storage that outlives `body` (the model layer), not inline. +- **Keep the id cheap to hash.** Avoid `id: \.self` on a large `Hashable` struct; hashing walks every field on every diff. Use a small primitive (`UUID`, `Int`, short `String`, `URL`) and still pass the full element to the row. The fix is the id, not removing an unrelated `Hashable` conformance that may be used for selection, sets, or navigation. + +### Identifiable ID Must Be Truly Unique + +Non-unique IDs cause SwiftUI to treat different items as identical, leading to duplicate rendering or missing views: + +```swift +// Bug -- two articles with the same URL show identical content +struct Article: Identifiable { + let title: String + let url: URL + var id: String { url.absoluteString } // Not unique if URLs repeat! +} + +// Fix -- use a genuinely unique identifier +struct Article: Identifiable { + let id: UUID + let title: String + let url: URL +} +``` + +**Classes get a default `ObjectIdentifier`-based `id`** when conforming to `Identifiable` without providing one. This is only unique for the object's lifetime and can be recycled after deallocation. + +Do not conform a type to `Identifiable` just to satisfy `ForEach` when it has no meaningful identity. Pass an explicit `id:` key path for the property that acts as identity in that context. + +## Enumerated Sequences + +**Using `.enumerated()` is fine; the index just must not be the identity.** Using `\.offset` as the id is the same anti-pattern as `\.self` on `items.indices` — the id becomes the position, not the element, so inserts and reorders reset row state and break animations. Keep the element's own identity as the id and treat the index as ordinary row data. + +```swift +// Wrong - offset is the position, not the element +ForEach(items.enumerated(), id: \.offset) { index, item in + ItemRow(number: index + 1, item: item) +} + +// Correct - id comes from the element; index is just data +ForEach(items.enumerated(), id: \.element.id) { index, item in + ItemRow(number: index + 1, item: item) +} +``` + +**No `Array(...)` wrapper is needed on Swift 6.1+.** As of Swift 6.1, the sequence returned by `.enumerated()` conditionally conforms to `RandomAccessCollection` when the base collection does, so `ForEach` accepts it directly. On earlier toolchains, wrap it in `Array(...)`. Favor the direct form in new code — it avoids an eager copy on every body evaluation. + +## Reorderable Collections (SDK 27) + +`reorderable()` on `ForEach` plus `reorderContainer(for:)` on the enclosing container bring drag reordering to lists, stacks, grids, and custom layouts: + +```swift +LazyVGrid(columns: columns) { + ForEach(items) { item in + ItemView(item) + } + .reorderable() +} +.reorderContainer(for: Item.self) { difference in + apply(difference, to: &items) // drop moved items in source order, then insert +} +``` + +Availability: iOS, macOS, watchOS, and visionOS 27; unavailable on tvOS. Gate when the deployment target is older. + +`Item` must be `Identifiable` for the `for:` overload (it keys on `\.id`). If the type is not `Identifiable`, or you want a different identifier, use the `itemID:` key-path overload: `reorderContainer(for: Item.self, itemID: \.code)` paired with the same `.reorderable()`. + +`ReorderDifference` provides `sources` and a destination of `.before(id)` or `.end`. Apply it by dropping the moved items in a single pass that **preserves their source order**, then insert that captured sequence at the destination. Reconstructing from a `Set` loses order. For a single-collection container, `CollectionID` is `ReorderableSingleCollectionIdentifier`. For multiple sections, add `collectionID:` to each reorderable collection and use `reorderContainer(for:in:)`; route by `destination.collectionID`. + +### Drag and drop + +`.reorderContainer(for:)` already acts as a drag container and a drop destination. A standalone `.draggable` does not customize the reorder container; provide `dragContainer(for:)` instead. Return an empty collection from the `dragContainer` closure to disable drag for that item. + +Drag/drop customization availability differs from reordering: + +| API | iOS | macOS | watchOS | tvOS | visionOS | +|---|---|---|---|---|---| +| `reorderable()` / `reorderContainer(for:…)` | 27 | 27 | 27 | n/a | 27 | +| `dragContainer` / `draggable(containerItemID:)` | 27 | 26 | n/a | n/a | 27 | +| `DropSession` / `dropDestination(for:…session…)` | 26 | 26 | n/a | n/a | 26 | +| `DropSession.reorderDestination(for:)` | 27 | 27 | n/a | n/a | 27 | + +watchOS can reorder locally but has no system drag/drop integration. + +**Combine by dropping one item onto another.** Put `.dropDestination(for:isEnabled:)` on each child. The closure signature is `(items: [T], session: DropSession) -> Void`. Put the per-item predicate in `isEnabled:`, not inside the closure. Do not use the `dropDestination(for:) { } isTargeted: { }` overload here — that reports hover for custom visuals and does not gate combining. + +**Accept drops at the reorder position.** Put `.dropDestination(for:)` on the container and ask `session.reorderDestination(for:)`. A `nil` destination means the drop did not hover a specific item; append. + +## Swipe Actions Outside List (SDK 27) + +Rows in a scrollable stack or grid can use existing `swipeActions` when the enclosing scroll container has `swipeActionsContainer()`. Without that modifier, row swipe actions outside `List` have no effect. `edge` defaults to `.trailing` and `allowsFullSwipe` defaults to `true`. The `swipeActions(..., onPresentationChanged:)` overload reports whether actions are revealed. + +```swift +ScrollView { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item).swipeActions { /* buttons */ } + } + } +} +.swipeActionsContainer() +``` + +Availability: `swipeActionsContainer()` and `onPresentationChanged` are iOS, macOS, watchOS, and visionOS 27; unavailable on tvOS. The original row `swipeActions(edge:allowsFullSwipe:content:)` has been available since iOS 15 / macOS 12 / watchOS 8 / visionOS 1 and does not need gating inside `List`. + +## List with Custom Styling + +```swift +// Remove default background and separators +List(items) { item in + ItemRow(item: item) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowSeparator(.hidden) +} +.listStyle(.plain) +.scrollContentBackground(.hidden) +.background(Color.customBackground) +.environment(\.defaultMinListRowHeight, 1) // Allows custom row heights +``` + +## List with Pull-to-Refresh + +```swift +List(items) { item in + ItemRow(item: item) +} +.refreshable { + await loadItems() +} +``` + +## Empty States with ContentUnavailableView (iOS 17+) + +Use `ContentUnavailableView` for empty list/search states. The built-in `.search` variant is auto-localized: + +```swift +List { + ForEach(searchResults) { item in + ItemRow(item: item) + } +} +.overlay { + if searchResults.isEmpty, !searchText.isEmpty { + ContentUnavailableView.search(text: searchText) + } +} +``` + +For non-search empty states, use a custom instance: + +```swift +ContentUnavailableView( + "No Articles", + systemImage: "doc.richtext.fill", + description: Text("Articles you save will appear here.") +) +``` + +## Custom List Backgrounds + +Use `.scrollContentBackground(.hidden)` to replace the default list background: + +```swift +List(items) { item in + ItemRow(item: item) +} +.scrollContentBackground(.hidden) +.background(Color.customBackground) +``` + +Without `.scrollContentBackground(.hidden)`, a custom `.background()` has no visible effect on `List`. + +## Table + +> **Availability:** iOS 16.0+, iPadOS 16.0+, visionOS 1.0+ + +A multi-column data container that presents rows of `Identifiable` data with sortable, selectable columns. On compact size classes (iPhone, iPad Slide Over), columns after the first are automatically hidden. + +### Basic Table + +```swift +struct Person: Identifiable { + let givenName: String + let familyName: String + let emailAddress: String + let id = UUID() + var fullName: String { givenName + " " + familyName } +} + +struct PeopleTable: View { + @State private var people: [Person] = [ /* ... */ ] + + var body: some View { + Table(people) { + TableColumn("Given Name", value: \.givenName) + TableColumn("Family Name", value: \.familyName) + TableColumn("E-Mail Address", value: \.emailAddress) + } + } +} +``` + +### Table with Selection + +Bind to a single `ID` for single-selection, or a `Set` for multi-selection: + +```swift +struct SelectableTable: View { + @State private var people: [Person] = [ /* ... */ ] + @State private var selectedPeople = Set() + + var body: some View { + Table(people, selection: $selectedPeople) { + TableColumn("Given Name", value: \.givenName) + TableColumn("Family Name", value: \.familyName) + TableColumn("E-Mail Address", value: \.emailAddress) + } + Text("\(selectedPeople.count) people selected") + } +} +``` + +### Sortable Table + +Provide a binding to `[KeyPathComparator]` and re-sort the data in `.onChange(of:)`: + +```swift +struct SortableTable: View { + @State private var people: [Person] = [ /* ... */ ] + @State private var sortOrder = [KeyPathComparator(\Person.givenName)] + + var body: some View { + Table(people, sortOrder: $sortOrder) { + TableColumn("Given Name", value: \.givenName) + TableColumn("Family Name", value: \.familyName) + TableColumn("E-Mail Address", value: \.emailAddress) + } + .onChange(of: sortOrder) { _, newOrder in + people.sort(using: newOrder) + } + } +} +``` + +**Important:** The table does **not** sort data itself — you must re-sort the collection when `sortOrder` changes. + +### Adaptive Table for Compact Size Classes + +On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information: + +```swift +struct AdaptiveTable: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + private var isCompact: Bool { horizontalSizeClass == .compact } + + @State private var people: [Person] = [ /* ... */ ] + @State private var sortOrder = [KeyPathComparator(\Person.givenName)] + + var body: some View { + Table(people, sortOrder: $sortOrder) { + TableColumn("Given Name", value: \.givenName) { person in + VStack(alignment: .leading) { + Text(isCompact ? person.fullName : person.givenName) + if isCompact { + Text(person.emailAddress) + .foregroundStyle(.secondary) + } + } + } + TableColumn("Family Name", value: \.familyName) + TableColumn("E-Mail Address", value: \.emailAddress) + } + .onChange(of: sortOrder) { _, newOrder in + people.sort(using: newOrder) + } + } +} +``` + +### Table with Static Rows + +Use `init(of:columns:rows:)` when rows are known at compile time: + +```swift +struct Purchase: Identifiable { + let price: Decimal + let id = UUID() +} + +struct TipTable: View { + let currencyStyle = Decimal.FormatStyle.Currency(code: "USD") + + var body: some View { + Table(of: Purchase.self) { + TableColumn("Base price") { purchase in + Text(purchase.price, format: currencyStyle) + } + TableColumn("With 15% tip") { purchase in + Text(purchase.price * 1.15, format: currencyStyle) + } + TableColumn("With 20% tip") { purchase in + Text(purchase.price * 1.2, format: currencyStyle) + } + } rows: { + TableRow(Purchase(price: 20)) + TableRow(Purchase(price: 50)) + TableRow(Purchase(price: 75)) + } + } +} +``` + +### Table with Dynamic Number of Columns + +> **Availability:** iOS 17.4+, iPadOS 17.4+, Mac Catalyst 17.4+, macOS 14.4+, visionOS 1.1+ + +If the number of columns is not known at runtime use `TableColumnForEach` to create columns based on a `RandomAccessCollection` of some data type. Either the collection’s elements must conform to `Identifiable` or you need to provide an id parameter to the `TableColumnForEach` initializer. + +This can be mixed with static compile time known `TableColumn` usage. + +```swift +struct AudioChannel: Identifiable { + let name: String + let id: UUID +} + +struct AudioSample: Identifiable { + let id: UUID + let timestamp: TimeInterval + func level(channel: AudioChannel.ID) -> Double { + 1 + } +} + +@Observable +class AudioSampleTrack { + let channels: [AudioChannel] + var samples: [AudioSample] +} + +struct ContentView: View { + var track: AudioSampleTrack + + var body: some View { + Table(track.samples) { + TableColumn("Timestamp (ms)") { sample in + Text(sample.timestamp, format: .number.scale(1000)) + .monospacedDigit() + } + TableColumnForEach(track.channels) { channel in + TableColumn(channel.name) { sample in + Text(sample.level(channel: channel.id), + format: .number.precision(.fractionLength(2)) + ) + .monospacedDigit() + } + .width(ideal: 70) + .alignment(.numeric) + } + } + } +} +``` + +### Table Styles + +```swift +// Inset (no borders) +Table(people) { /* columns */ } + .tableStyle(.inset) + +// Hide column headers +Table(people) { /* columns */ } + .tableColumnHeaders(.hidden) +``` + +### Platform Behavior + +| Platform | Behavior | +|----------|----------| +| **iPadOS (regular)** | Full multi-column layout; headers and all columns visible | +| **iPadOS (compact)** | Only the first column shown; headers hidden | +| **iPhone (all sizes)** | Only the first column shown; headers hidden; list-like appearance | + +> **Best Practice:** Prefer handling the compact size class by showing combined info in the first column. This provides a seamless transition when the size class changes (e.g., entering/exiting Slide Over on iPad). + +## Summary Checklist + +- [ ] ForEach uses stable identity (never `.indices` or `\.offset` for dynamic content); the same id rules apply to selection-aware `List`, `Picker`, and disclosure collections +- [ ] SDK 27 reordering uses `reorderable()` + `reorderContainer`; apply `ReorderDifference` in source order +- [ ] Swipe actions outside `List` sit inside `swipeActionsContainer()` +- [ ] Identifiable IDs are truly unique across all items +- [ ] id is stable across edits (not derived from a mutable property), created outside `body`, and cheap to hash +- [ ] Constant number of views per ForEach element; rows are unary (single top-level view) +- [ ] No inline filtering in ForEach (prefilter and cache instead) +- [ ] No `AnyView` in list rows +- [ ] `.enumerated()` uses the element's id (not `\.offset`); no `Array(...)` wrapper needed on Swift 6.1+ +- [ ] Use `.refreshable` for pull-to-refresh +- [ ] Use `ContentUnavailableView` for empty states (iOS 17+) +- [ ] Use `.scrollContentBackground(.hidden)` for custom list backgrounds +- [ ] `Table` adapts for compact size classes (first column shows combined info) +- [ ] `Table` sorting re-sorts data in `.onChange(of: sortOrder)` (table doesn't sort itself) +- [ ] `Table` data conforms to `Identifiable` diff --git a/.cursor/skills/swiftui-expert-skill/references/localization.md b/.cursor/skills/swiftui-expert-skill/references/localization.md new file mode 100644 index 00000000..c2f7750d --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/localization.md @@ -0,0 +1,194 @@ +# SwiftUI Localization Reference + +Guidance for user-facing text: `Text`, `Button`, `Label`, navigation/toolbar titles, alerts, and types that carry localizable strings. For the narrower "verbatim vs localized" decision on a single `Text`, see `references/text-patterns.md`. + +## Table of Contents + +- [SwiftUI Localizes String Literals Automatically](#swiftui-localizes-string-literals-automatically) +- [String Catalogs](#string-catalogs) +- [Bundle for Swift Packages and Frameworks](#bundle-for-swift-packages-and-frameworks) +- [Localizing Variables and Custom Types](#localizing-variables-and-custom-types) +- [LocalizedStringResource for Non-View Types](#localizedstringresource-for-non-view-types) +- [Interpolation vs Concatenation](#interpolation-vs-concatenation) +- [Casing](#casing) +- [Formatting Dates, Numbers, and Currencies](#formatting-dates-numbers-and-currencies) +- [Layout for Localization](#layout-for-localization) +- [Reading the Current Locale](#reading-the-current-locale) +- [String(localized:) Outside SwiftUI Views](#stringlocalized-outside-swiftui-views) +- [Comments for Translators](#comments-for-translators) + +## SwiftUI Localizes String Literals Automatically + +Initializers that accept `LocalizedStringKey` (`Text`, `Button`, `Label`, `.navigationTitle`, alert titles, and so on) treat string literals as localization keys automatically. Do not wrap literals in `NSLocalizedString`, `String(localized:)`, or `LocalizedStringResource` — that resolves the string eagerly and ignores `\.locale` overrides. + +```swift +// AVOID: double work, and resolves eagerly +Text(String(localized: "start_workout")) + +// PREFER: pass the literal directly +Text("start_workout") +``` + +Both opaque keys (`"start_workout"`) and natural-language strings (`"Start Workout"`) work as keys — pick whichever convention the project already uses. Use `Text(verbatim:)` only to opt a literal out of localization (e.g. a debug label interpolating a runtime value). When the argument is already a `String` variable, `Text(value)` calls the `StringProtocol` overload and skips localization on its own. + +## String Catalogs + +Most projects localize through String Catalogs (`.xcstrings`). Each build syncs new keys from code into the catalog, but the catalog file must already exist — Xcode doesn't create one automatically. If a project already uses `.strings` / `.stringsdict`, add new strings there rather than migrating. Route groups of strings to a specific catalog with `tableName:`. + +```swift +Text("Explore", tableName: "Navigation", + comment: "Tab bar item title for the Explore screen.") +``` + +## Bundle for Swift Packages and Frameworks + +Apps, app extensions, and XPC services are their own main bundle, so `bundle` can be omitted. Frameworks and Swift packages need an explicit `bundle:` — without one, SwiftUI looks up strings in `Bundle.main`, the lookup fails silently, and the string appears unlocalized at runtime. + +```swift +// AVOID (inside a framework/package): searches the app's catalog +Text("Save to Favorites") + +// PREFER: #bundle resolves to the current target's bundle +Text("Save to Favorites", bundle: #bundle, + comment: "Button to bookmark a recipe.") +``` + +`#bundle` is the preferred form; `Bundle.module` and `Bundle(for:)` still work but are older patterns. + +## Localizing Variables and Custom Types + +A `String` variable passed to `Text` runs the `StringProtocol` overload and is **not** localized. Wrapping it in `LocalizedStringKey(_:)` doesn't help — Xcode can't extract a literal from a runtime value, so nothing lands in the catalog. To localize a value chosen from a known set, model it with a type that exposes `LocalizedStringResource`: + +```swift +enum Category { + case appetizers, mains, desserts + var name: LocalizedStringResource { + switch self { + case .appetizers: "Appetizers" + case .mains: "Mains" + case .desserts: "Desserts" + } + } +} + +Text(category.name) +``` + +When a view or model exposes user-facing text, type the property as `LocalizedStringKey` or `LocalizedStringResource` rather than `String`. Every SwiftUI view that takes localized text accepts both, so deferring resolution costs nothing at the display site and preserves locale/bundle context. + +## LocalizedStringResource for Non-View Types + +When a non-view type carries user-facing text — a model object, a tip, a queued notification — use `LocalizedStringResource` instead of `String`. It defers resolution to display time, so it honors the locale active when the value actually renders, not when it was created. + +```swift +// AVOID: resolved at creation time, can't re-render in another locale +struct Tip { let headline: String } +let tip = Tip(headline: String(localized: "Tip of the Day")) + +// PREFER: resolution deferred to display time +struct Tip { let headline: LocalizedStringResource } +let tip = Tip(headline: "Tip of the Day") +``` + +Apply this when designing new types or changing user-facing text — don't sweep through existing `String` properties as part of unrelated edits. + +## Interpolation vs Concatenation + +String interpolation preserves `LocalizedStringKey` and produces a format string in the catalog (e.g. `"Welcome, %@"`). Concatenation with `+` produces a plain `String` and is not localized. Never glue separately localized fragments into a sentence — word order varies across languages. + +```swift +// AVOID: + produces String; sentence assembly breaks word order +Text("Error: " + statusMessage) +Text(String(localized: "Created by")) + Text(" ") + Text(authorName) + +// PREFER: one interpolated string translators can rearrange +Text("Error: \(statusMessage)") +Text("Created by \(authorName)") +``` + +## Casing + +Bake the desired case into the string rather than transforming at runtime via `.textCase(_:)`, `.localizedUppercase`, or `.localizedCapitalized`. A runtime transform forces the same casing on every translation, leaving translators no room to adjust per language. + +```swift +// AVOID +Text("Section Header").textCase(.uppercase) + +// PREFER +Text("SECTION HEADER") +``` + +This applies to localized strings; display user-entered text as-is. If a transform is unavoidable, prefer `.localizedUppercase` / `.localizedCapitalized`, which honor the user's locale. + +## Formatting Dates, Numbers, and Currencies + +Use `Text`'s `format:` parameter or `.formatted()` instead of `DateFormatter` / `NumberFormatter` with hardcoded format strings. Format styles adapt to the user's locale; hardcoded format strings don't. + +```swift +// AVOID +let f = DateFormatter(); f.dateFormat = "MM/dd/yyyy" +Text(f.string(from: workout.date)) +Text("$\(product.price, specifier: "%.2f")") + +// PREFER +Text(workout.date, format: .dateTime.month().day().year()) +Text(product.price, format: .currency(code: store.currencyCode)) +``` + +Field components (`.month()`, `.day()`) choose which fields appear; the locale decides the order. For lists, `Array.formatted()` inserts locale-correct separators and conjunctions instead of `joined(separator:)`. When `DateFormatter` is genuinely unavoidable, use `setLocalizedDateFormatFromTemplate(_:)` rather than assigning `dateFormat`. + +## Layout for Localization + +- Use `.leading` / `.trailing` instead of `.left` / `.right` — they flip for right-to-left locales. +- Don't hardcode frame widths/heights for text; translations vary in length and scripts vary in height. Use `ViewThatFits` when a layout might not fit longer translations. +- Use text styles (`.body`, `.headline`) rather than fixed point sizes, so line height adapts per script. + +```swift +// PREFER +Text(recipe.title) + .frame(maxWidth: .infinity, alignment: .leading) + +ViewThatFits { + HStack { actionButtons } + VStack { actionButtons } +} +``` + +## Reading the Current Locale + +Use `@Environment(\.locale)` for locale-dependent logic in views, not `Locale.current` — the environment respects preview overrides and per-view injection. + +## String(localized:) Outside SwiftUI Views + +When you need a localized `String` outside a view, use `String(localized:)`, not `NSLocalizedString`. Don't interpolate inside `NSLocalizedString` — Xcode extracts keys from literals at build time and can't extract interpolated values. `String(localized:)` supports interpolation (it extracts the format string and treats values as runtime arguments) and is preferred over `String(format:)`, which always renders digits as 0–9 regardless of locale. + +```swift +// PREFER +let title = String(localized: "activity_summary", comment: "Dashboard header") +``` + +## Comments for Translators + +Add a `comment:` describing the UI element and its purpose, especially for ambiguous strings. For interpolated strings, describe each placeholder by position — translators don't see Swift variable names. Comments can live at the call site or in the String Catalog's per-string Comment field; keep one source of truth per string. + +```swift +// AVOID: "Edit" could be a noun or a verb +Text("Edit") + +// PREFER +Text("Edit", comment: "Toolbar button that enters editing mode for the list.") +Text("Completed \(count) of \(total)", + comment: "Progress label — first variable is finished items, second is the total.") +``` + +## Summary Checklist + +- [ ] String literals passed directly to `Text`/`Button`/`Label` (not wrapped in `NSLocalizedString`/`String(localized:)`) +- [ ] `bundle: #bundle` on user-facing strings inside frameworks and Swift packages +- [ ] User-facing text on models/non-view types typed as `LocalizedStringResource`, not `String` +- [ ] Interpolation (not `+`) for dynamic strings; no sentence assembly from fragments +- [ ] Case baked into the string, not applied via `.textCase` +- [ ] Dates/numbers/currencies use `format:` / `.formatted()` with locale-aware styles +- [ ] `.leading`/`.trailing` (not `.left`/`.right`); no hardcoded text frame sizes +- [ ] `@Environment(\.locale)` for locale logic in views +- [ ] `comment:` provided for ambiguous strings and interpolated placeholders diff --git a/.cursor/skills/swiftui-expert-skill/references/macos-scenes.md b/.cursor/skills/swiftui-expert-skill/references/macos-scenes.md new file mode 100644 index 00000000..f6cef480 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/macos-scenes.md @@ -0,0 +1,320 @@ +# macOS Scenes Reference + +> SwiftUI scene types for macOS apps — `Settings`, `MenuBarExtra`, `WindowGroup`, `Window`, `UtilityWindow`, and `DocumentGroup`. Covers macOS-only scenes and cross-platform scenes with macOS-specific behavior. + +## Table of Contents + +- [Quick Lookup Table](#quick-lookup-table) +- [Settings (macOS-only)](#settings-macos-only) +- [MenuBarExtra (macOS-only)](#menubarextra-macos-only) +- [WindowGroup (macOS behavior)](#windowgroup-macos-behavior) +- [Window](#window) +- [UtilityWindow (macOS-only)](#utilitywindow-macos-only) +- [DocumentGroup](#documentgroup) +- [Platform Conditionals](#platform-conditionals) +- [Best Practices](#best-practices) + +--- + +## Quick Lookup Table + +| API | Availability | macOS-Only? | macOS-Specific Behavior | +|-----|-------------|:-----------:|------------------------| +| `WindowGroup` | macOS 11.0+ | No | Multiple window instances, tabbed interface, automatic Window menu commands | +| `Window` | macOS 13.0+ | No | App quits when sole window closes; adds itself to Windows menu | +| `UtilityWindow` | macOS 15.0+ | Yes | Floating tool palette; receives `FocusedValues` from active main window | +| `Settings` | macOS 11.0+ | Yes | Presents preferences window (Cmd+,) | +| `MenuBarExtra` | macOS 13.0+ | Yes | Persistent icon/menu in the system menu bar | +| `DocumentGroup` | macOS 11.0+ | No | Document-based menu bar commands (File > New/Open/Save); multiple document windows | + +--- + +## Settings (macOS-only) + +Presents the app's preferences window, accessible via **Cmd+,** or the app menu. SwiftUI automatically enables the Settings menu item and manages the window lifecycle. + +```swift +Settings { + TabView { + Tab("General", systemImage: "gear") { GeneralSettingsView() } + Tab("Advanced", systemImage: "star") { AdvancedSettingsView() } + } + .scenePadding() + .frame(maxWidth: 350, minHeight: 100) +} +``` + +Use `TabView` with `Tab` items for multi-pane preferences. Each tab's content is typically a `Form` with `@AppStorage`-backed controls. + +### SettingsLink (macOS 14.0+) + +A button that opens the Settings scene. Use for in-app navigation to preferences. + +```swift +struct SidebarFooter: View { + var body: some View { + SettingsLink { + Label("Preferences", systemImage: "gear") + } + } +} +``` + +### openSettings environment action (macOS 14.0+) + +Programmatically open (or bring to front) the Settings window. + +```swift +struct OpenSettingsButton: View { + @Environment(\.openSettings) private var openSettings + + var body: some View { + Button("Open Settings") { + openSettings() + } + } +} +``` + +--- + +## MenuBarExtra (macOS-only) + +Renders a persistent control in the system menu bar. Two styles available: +- **`.menu`** (default) — standard dropdown menu +- **`.window`** — popover panel with custom SwiftUI views + +### Menu-style (dropdown) + +```swift +MenuBarExtra("My Utility", systemImage: "hammer") { + Button("Action One") { /* ... */ } + Button("Action Two") { /* ... */ } + Divider() + Button("Quit") { NSApplication.shared.terminate(nil) } +} +``` + +### Window-style (popover panel) + +```swift +MenuBarExtra("Status", systemImage: "chart.bar") { + DashboardView() + .frame(width: 240) +} +.menuBarExtraStyle(.window) +``` + +**Variations:** +- **Toggleable** — pass `isInserted:` with an `@AppStorage` binding to let users show/hide the extra: `MenuBarExtra("Status", systemImage: "chart.bar", isInserted: $showMenuBarExtra)` +- **Menu-bar-only app** — use `MenuBarExtra` as the sole scene + set `LSUIElement = true` in Info.plist to hide the Dock icon. The app auto-terminates if the user removes the extra from the menu bar. + +--- + +## WindowGroup (macOS behavior) + +On macOS, `WindowGroup` supports: +- **Multiple window instances** — users can open many windows from File > New Window +- **Tabbed interface** — users can merge windows into tabs +- **Automatic Window menu** — commands for window management appear automatically + +```swift +@main +struct Mail: App { + var body: some Scene { + // Basic multi-window support + WindowGroup { + MailViewer() + } + + // Data-presenting window opened programmatically + WindowGroup("Message", for: Message.ID.self) { $messageID in + MessageDetail(messageID: messageID) + } + } +} + +// Open a specific window programmatically +struct NewMessageButton: View { + var message: Message + @Environment(\.openWindow) private var openWindow + + var body: some View { + Button("Open Message") { + openWindow(value: message.id) + } + } +} +``` + +> **Key difference from `Window`:** `WindowGroup` keeps the app running even after all windows are closed. `Window` (as sole scene) quits the app when closed. + +--- + +## Window + +A single, unique window scene. The system ensures only one instance exists. + +```swift +@main +struct Mail: App { + var body: some Scene { + WindowGroup { + MailViewer() + } + + // Supplementary singleton window + Window("Connection Doctor", id: "connection-doctor") { + ConnectionDoctor() + } + } +} + +// Open programmatically — brings to front if already open +struct OpenDoctorButton: View { + @Environment(\.openWindow) private var openWindow + + var body: some View { + Button("Connection Doctor") { + openWindow(id: "connection-doctor") + } + } +} +``` + +### Window as sole scene + +If `Window` is the only scene, the app quits when the window closes: + +```swift +@main +struct VideoCall: App { + var body: some Scene { + Window("VideoCall", id: "main") { + CameraView() + } + } +} +``` + +> **Recommendation:** In most cases, prefer `WindowGroup` for the primary scene. Use `Window` for supplementary singleton windows. + +--- + +## UtilityWindow (macOS-only) + +A specialized floating window for tool palettes and inspector panels. Available since macOS 15.0. + +**Key behaviors:** +- Receives `FocusedValues` from the focused main scene (like menu bar commands) +- Floats above main windows (default level: `.floating`) +- Hides when the app is no longer active +- Only becomes focused when explicitly needed (e.g., clicking the title bar) +- Dismissible with the Escape key +- Not minimizable by default +- Automatically adds a show/hide item to the View menu + +```swift +@main +struct PhotoBrowser: App { + var body: some Scene { + WindowGroup { + PhotoGallery() + } + + UtilityWindow("Photo Info", id: "photo-info") { + PhotoInfoViewer() + } + } +} + +struct PhotoInfoViewer: View { + // Automatically updates based on whichever main window is focused + @FocusedValue(PhotoSelection.self) private var selectedPhotos + + var body: some View { + if let photos = selectedPhotos { + Text("\(photos.count) photos selected") + } else { + Text("No selection") + .foregroundStyle(.secondary) + } + } +} +``` + +> **Tip:** Remove the automatic View menu item with `.commandsRemoved()` and place a `WindowVisibilityToggle` elsewhere in your commands. + +--- + +## DocumentGroup + +Document-based apps with automatic file management. On macOS, provides: +- **Document-based menu bar commands** (File > New, Open, Save, Revert) +- **Multiple document windows** simultaneously +- On iOS, shows a document browser instead + +> **SDK 27+:** on iOS 27 / macOS 27 / visionOS 27 and later, prefer the `Document` protocol (`ReadableDocument` / `WritableDocument`) with the closure-based `DocumentGroup` initializer — see `references/document-apps.md`. The rest of this section covers `FileDocument` and `ReferenceFileDocument`, which are soft-deprecated in the SDK 27 toolchain but remain the compatible option for older deployment targets. + +```swift +DocumentGroup(newDocument: TextFile()) { config in + ContentView(document: config.$document) +} +``` + +For deployment targets below the 27 releases, the document type must conform to `FileDocument` (value type) or `ReferenceFileDocument` (reference type). Key requirements: + +```swift +struct TextFile: FileDocument { + static var readableContentTypes: [UTType] { [.plainText] } + var text: String = "" + init() {} + init(configuration: ReadConfiguration) throws { + text = String(data: configuration.file.regularFileContents ?? Data(), encoding: .utf8) ?? "" + } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(text.utf8)) + } +} +``` + +For multiple document types, add additional `DocumentGroup` scenes — use `DocumentGroup(viewing:)` for read-only formats. + +--- + +## Platform Conditionals + +Always wrap macOS-only scenes in `#if os(macOS)`: + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + + #if os(macOS) + Settings { + SettingsView() + } + + MenuBarExtra("Status", systemImage: "bolt") { + StatusMenu() + } + #endif + } +} +``` + +--- + +## Best Practices + +- **Use `Settings`** for preferences — prefer this over a custom preferences window +- **Use `MenuBarExtra`** for menu bar items — prefer this over managing AppKit's `NSStatusItem` directly +- **Use `WindowGroup`** as the primary scene — reserve `Window` for supplementary singletons +- **Use `UtilityWindow`** for inspectors/palettes — it handles floating, focus, and visibility automatically +- **Use `DocumentGroup`** for document-based apps — it provides the full File menu and document lifecycle +- **Gate macOS-only scenes** with `#if os(macOS)` for multiplatform projects +- **Use `openWindow(id:)`** to open windows programmatically — it brings existing windows to front diff --git a/.cursor/skills/swiftui-expert-skill/references/macos-views.md b/.cursor/skills/swiftui-expert-skill/references/macos-views.md new file mode 100644 index 00000000..ff5335d7 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/macos-views.md @@ -0,0 +1,360 @@ +# macOS Views & Components Reference + +> macOS-specific SwiftUI views, file operations, drag & drop, and AppKit interop. Covers `HSplitView`, `VSplitView`, `Table`, `PasteButton`, file dialogs, cross-app drag & drop, and `NSViewRepresentable`. + +## Table of Contents + +- [Quick Lookup Table](#quick-lookup-table) +- [HSplitView & VSplitView (macOS-only)](#hsplitview--vsplitview-macos-only) +- [Table](#table) +- [PasteButton & CopyButton](#pastebutton--copybutton) +- [File Operations](#file-operations) +- [Drag, Drop & Pasteboard](#drag-drop--pasteboard) +- [AppKit Interop](#appkit-interop) +- [Best Practices](#best-practices) + +--- + +## Quick Lookup Table + +### Views + +| API | Availability | macOS-Only? | Usage | +|-----|-------------|:-----------:|-------| +| `HSplitView` | macOS 10.15+ | Yes | Horizontal resizable split layout with user-draggable dividers | +| `VSplitView` | macOS 10.15+ | Yes | Vertical resizable split layout with user-draggable dividers | +| `Table` | macOS 12.0+ | No | Full multi-column layout with sorting; on iOS compact, columns collapse | +| `PasteButton` | macOS 10.15+ | No | System button that reads clipboard; does NOT auto-validate on macOS | +| `CopyButton` | macOS 15.0+ | Yes | System button that copies `Transferable` content to clipboard | + +### File Operations + +| API | Availability | macOS-Only? | Usage | +|-----|-------------|:-----------:|-------| +| `fileImporter()` | macOS 11.0+ | No | Native NSOpenPanel with column/list/gallery view, sidebar, tags, QuickLook | +| `fileExporter()` | macOS 11.0+ | No | Native NSSavePanel with format dropdown, tags field | +| `fileMover()` | macOS 11.0+ | No | Native macOS move panel with Finder-like navigation | +| `fileDialogMessage(_:)` | macOS 13.0+ | Yes | Custom message text in file dialogs | +| `fileDialogConfirmationLabel(_:)` | macOS 13.0+ | Yes | Custom confirm button text in file dialogs | +| `fileExporterFilenameLabel(_:)` | macOS 13.0+ | Yes | Custom filename field label in file exporter | + +### Drag, Drop & Pasteboard + +| API | Availability | macOS-Only? | Usage | +|-----|-------------|:-----------:|-------| +| `onDrag(_:)` / `draggable(_:)` | macOS 11.0+ | No | Drag image follows cursor; items draggable between apps | +| `onDrop(of:delegate:)` / `dropDestination(for:action:)` | macOS 11.0+ | No | Accepts drops from any macOS app including Finder | + +### AppKit Interop + +| API | Availability | macOS-Only? | Usage | +|-----|-------------|:-----------:|-------| +| `NSViewRepresentable` | macOS 10.15+ | Yes | Wrap an AppKit `NSView` in SwiftUI | +| `NSViewControllerRepresentable` | macOS 10.15+ | Yes | Wrap an AppKit `NSViewController` in SwiftUI | +| `NSHostingController` | macOS 10.15+ | Yes | Host SwiftUI inside an AppKit view controller | +| `NSHostingView` | macOS 10.15+ | Yes | Host SwiftUI inside an AppKit `NSView` hierarchy | + +--- + +## HSplitView & VSplitView (macOS-only) + +Resizable split layouts with user-draggable dividers. Use for IDE-style panes where all panels are equal peers. `VSplitView` works identically but splits vertically (use `minHeight` instead). + +```swift +HSplitView { + FileTreeView() + .frame(minWidth: 200) + CodeEditorView() + .frame(minWidth: 400) + PreviewPane() + .frame(minWidth: 200) +} +``` + +> **When to use which:** +> - **`NavigationSplitView`** — sidebar-based navigation (sidebar drives content/detail) +> - **`HSplitView`/`VSplitView`** — IDE-style layouts where all panes are equal peers + +--- + +## Table + +For `Table` basics (creation, selection, sorting, adaptive compact layout), see `list-patterns.md`. This section covers macOS-specific table styling. + +### Table styles + +```swift +// Bordered with visible grid lines (macOS-only) +Table(people) { /* columns */ } + .tableStyle(.bordered) + +// Bordered with alternating row backgrounds +Table(people) { /* columns */ } + .tableStyle(.bordered) + .alternatingRowBackgrounds() + +// Inset (no borders) +Table(people) { /* columns */ } + .tableStyle(.inset) + +// Hide column headers +Table(people) { /* columns */ } + .tableColumnHeaders(.hidden) +``` + +Apply `.alternatingRowBackgrounds()` as a separate modifier instead of passing `alternatesRowBackgrounds:` to `.bordered` (soft-deprecated in SDK 27). + +--- + +## PasteButton & CopyButton + +### PasteButton + +System button that reads clipboard content via `Transferable`. On macOS, it does NOT auto-validate pasteboard changes (unlike iOS). + +```swift +struct ClipboardView: View { + @State private var pastedText = "" + + var body: some View { + HStack { + PasteButton(payloadType: String.self) { strings in + pastedText = strings[0] + } + Divider() + Text(pastedText) + Spacer() + } + } +} +``` + +### CopyButton (macOS 15.0+, macOS-only) + +System button that copies `Transferable` content to the clipboard. + +```swift +struct CopyableContent: View { + let shareableText = "Hello, world!" + + var body: some View { + HStack { + Text(shareableText) + CopyButton(item: shareableText) + } + } +} +``` + +--- + +## File Operations + +### fileImporter + +On macOS, presents a native `NSOpenPanel` with column/list/gallery view, sidebar favorites, tags, and QuickLook. + +```swift +.fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.pdf], + allowsMultipleSelection: false +) { result in + if case .success(let urls) = result, let url = urls.first { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + // use url + } +} +``` + +> **Important:** Always call `startAccessingSecurityScopedResource()` on returned URLs, and `stopAccessingSecurityScopedResource()` when done. These are security-scoped bookmarks — access fails without this. + +### fileExporter + +On macOS, presents a native `NSSavePanel` with format dropdown and tags. + +```swift +.fileExporter( + isPresented: $showExporter, + document: document, + contentType: .plainText, + defaultFilename: "MyFile.txt" +) { result in + // handle Result +} +``` + +### File dialog customization (macOS-only) + +Customize text in file dialogs with these macOS-specific modifiers: + +```swift +// Custom message and confirm button on file importer +.fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.image] +) { result in + // handle result +} +.fileDialogMessage("Select an image to use as your profile photo") +.fileDialogConfirmationLabel("Use This Photo") + +// Custom filename label on file exporter +.fileExporter( + isPresented: $showExporter, + document: myDocument, + contentType: .png +) { result in + // handle result +} +.fileExporterFilenameLabel("Export As:") +``` + +--- + +## Drag, Drop & Pasteboard + +On macOS, drag and drop works **across applications** (e.g., drag from your app to Finder, Mail, or other apps). + +### Modern approach (Transferable) + +```swift +// Drag source +struct DraggableCard: View { + let item: MyItem + + var body: some View { + Text(item.title) + .draggable(item) // Requires Transferable conformance + } +} + +// Drop target +struct DropZone: View { + @State private var droppedItems: [MyItem] = [] + + var body: some View { + VStack { + ForEach(droppedItems) { item in + Text(item.title) + } + } + .dropDestination(for: MyItem.self) { items, location in + droppedItems.append(contentsOf: items) + return true + } + .frame(width: 300, height: 200) + .border(.secondary) + } +} +``` + +### Legacy approach (NSItemProvider) + +```swift +// Drag source +Image(systemName: "doc") + .onDrag { + NSItemProvider(object: fileURL as NSURL) + } + +// Drop target +Text("Drop files here") + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + // handle providers + return true + } +``` + +--- + +## AppKit Interop + +### NSViewRepresentable (macOS-only) + +Wraps an AppKit `NSView` for use in SwiftUI. Implement `makeNSView(context:)` and `updateNSView(_:context:)`. + +```swift +struct WebView: NSViewRepresentable { + let url: URL + func makeNSView(context: Context) -> WKWebView { WKWebView() } + func updateNSView(_ nsView: WKWebView, context: Context) { + nsView.load(URLRequest(url: url)) + } +} +``` + +### NSViewRepresentable with Coordinator + +Use a Coordinator to forward delegate/target-action callbacks to SwiftUI. + +```swift +struct SearchField: NSViewRepresentable { + @Binding var text: String + + func makeNSView(context: Context) -> NSSearchField { + let field = NSSearchField() + field.delegate = context.coordinator + return field + } + func updateNSView(_ nsView: NSSearchField, context: Context) { + nsView.stringValue = text + } + func makeCoordinator() -> Coordinator { Coordinator(text: $text) } + + class Coordinator: NSObject, NSSearchFieldDelegate { + var text: Binding + init(text: Binding) { self.text = text } + func controlTextDidChange(_ obj: Notification) { + if let field = obj.object as? NSSearchField { + text.wrappedValue = field.stringValue + } + } + } +} +``` + +> **Warning:** Never set `frame`/`bounds` directly on the managed `NSView` — SwiftUI owns the layout. + +### NSViewControllerRepresentable (macOS-only) + +Wraps an AppKit `NSViewController` for use in SwiftUI. + +```swift +struct MapViewWrapper: NSViewControllerRepresentable { + func makeNSViewController(context: Context) -> MapViewController { + MapViewController() + } + + func updateNSViewController(_ nsViewController: MapViewController, context: Context) { + // Update the controller when SwiftUI state changes + } +} +``` + +### NSHostingController & NSHostingView (macOS-only) + +Host SwiftUI content inside AppKit (reverse direction — AppKit app embedding SwiftUI views). + +```swift +// Host SwiftUI as a view controller +let hostingController = NSHostingController(rootView: MySwiftUIView()) +window.contentViewController = hostingController + +// Host SwiftUI directly as an NSView +let hostingView = NSHostingView(rootView: MySwiftUIView()) +someNSView.addSubview(hostingView) +``` + +--- + +## Best Practices + +- **Use `NavigationSplitView`** for sidebar-driven navigation — reserve `HSplitView`/`VSplitView` for IDE-style equal peer panes +- **Make `Table` adaptive** — handle compact size classes by showing combined info in the first column +- **Always call `startAccessingSecurityScopedResource()`** on URLs from `fileImporter` — they are security-scoped +- **Use `Transferable`** for drag & drop (modern) — fall back to `NSItemProvider` only for legacy compatibility +- **Use `NSViewRepresentable` with Coordinator** when you need delegate callbacks from AppKit views +- **Never set `frame`/`bounds`** directly on views managed by `NSViewRepresentable` — SwiftUI owns the layout +- **Prefer native SwiftUI** over AppKit interop when possible — only use `NSViewRepresentable` for features SwiftUI doesn't provide diff --git a/.cursor/skills/swiftui-expert-skill/references/macos-window-styling.md b/.cursor/skills/swiftui-expert-skill/references/macos-window-styling.md new file mode 100644 index 00000000..40825d9b --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/macos-window-styling.md @@ -0,0 +1,303 @@ +# macOS Window & Toolbar Styling Reference + +> Window configuration, toolbar styles, sizing, positioning, and navigation patterns specific to macOS SwiftUI apps. + +## Table of Contents + +- [Quick Lookup Table](#quick-lookup-table) +- [Toolbar Styles](#toolbar-styles) +- [Window Style](#window-style) +- [Window Sizing](#window-sizing) +- [MenuBarExtra Style (macOS-only)](#menubarextra-style-macos-only) +- [Navigation Layout (macOS behavior)](#navigation-layout-macos-behavior) +- [Commands & Keyboard](#commands--keyboard) +- [Best Practices](#best-practices) + +--- + +## Quick Lookup Table + +| API | Availability | macOS-Only? | Usage | +|-----|-------------|:-----------:|-------| +| `windowToolbarStyle(_:)` | macOS 11.0+ | Yes | Sets toolbar style: `.unified`, `.unifiedCompact`, `.expanded` | +| `windowStyle(_:)` | macOS 11.0+ | No | Supports `.hiddenTitleBar` for chromeless windows | +| `windowResizability(_:)` | macOS 13.0+ | No | Controls resize handle and green zoom button behavior | +| `defaultSize(width:height:)` | macOS 13.0+ | No | Initial frame size when user creates a new window | +| `defaultPosition(_:)` | macOS 13.0+ | No | Initial window position on screen | +| `windowIdealPlacement(_:)` | macOS 15.0+ | No | Closure with display geometry for precise window positioning | +| `menuBarExtraStyle(_:)` | macOS 13.0+ | Yes | Sets MenuBarExtra to `.menu` or `.window` style | +| `NavigationSplitView` | macOS 13.0+ | No | Columns always visible side-by-side on macOS; translucent sidebar | +| `Inspector` | macOS 14.0+ | No | Trailing-edge sidebar panel; resizable by dragging | + +--- + +## Toolbar Styles + +### windowToolbarStyle (macOS-only) + +Controls how the toolbar and title bar are displayed. Applied to a scene. + +```swift +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + // Title bar and toolbar in a single row + .windowToolbarStyle(.unified) + } +} +``` + +**Available styles:** + +| Style | Description | +|-------|-------------| +| `.automatic` | System default | +| `.unified` | Title bar and toolbar in a single combined row | +| `.unifiedCompact` | Same as unified but with reduced vertical height | +| `.expanded` | Title bar displayed above the toolbar (more toolbar space) | + +```swift +// Unified compact — minimal chrome +.windowToolbarStyle(.unifiedCompact) + +// Expanded — title bar above toolbar +.windowToolbarStyle(.expanded) + +// Unified with title hidden +.windowToolbarStyle(.unified(showsTitle: false)) +``` + +### Toolbar content + +```swift +struct ContentView: View { + @State private var searchText = "" + + var body: some View { + NavigationSplitView { + SidebarView() + } detail: { + DetailView() + } + .toolbar { + ToolbarItem(placement: .automatic) { + Button(action: addItem) { + Label("Add", systemImage: "plus") + } + } + } + .searchable(text: $searchText, placement: .sidebar) + } +} +``` + +--- + +## Window Style + +### windowStyle + +Set the visual style of a window. Use `.hiddenTitleBar` for chromeless, immersive windows. + +```swift +// Standard title bar (default) +WindowGroup { + ContentView() +} +.windowStyle(.titleBar) + +// Hidden title bar — chromeless window +WindowGroup { + ContentView() +} +.windowStyle(.hiddenTitleBar) +``` + +> **Use case:** `.hiddenTitleBar` is useful for media players, custom-chrome apps, or immersive experiences where the standard title bar is unwanted. + +--- + +## Window Sizing + +### windowResizability, defaultSize, defaultPosition + +These modifiers work together to configure window sizing and placement: + +```swift +WindowGroup { + ContentView() + .frame(minWidth: 600, minHeight: 400) +} +.defaultSize(width: 900, height: 600) +.defaultPosition(.center) +.windowResizability(.contentMinSize) +``` + +**`windowResizability` options:** + +| Value | Behavior | +|-------|----------| +| `.automatic` | System decides resize behavior | +| `.contentSize` | Fixed to content size; no user resize; zoom button disabled | +| `.contentMinSize` | Resizable with minimum based on content's `minWidth`/`minHeight` | + +**`defaultPosition` options:** `.center`, `.topLeading`, `.top`, `.topTrailing`, `.leading`, `.trailing`, `.bottomLeading`, `.bottom`, `.bottomTrailing` + +**Guidelines:** +- Set `minWidth`/`minHeight` via `.frame()` on content, enforce with `.contentMinSize` +- Use `.defaultSize()` for initial dimensions (larger than minimums) +- `defaultSize` also accepts `CGSize` + +### windowIdealPlacement (macOS 15.0+) + +For precise programmatic positioning, use a closure with display geometry: + +```swift +.windowIdealPlacement { context in + let screen = context.defaultDisplay.visibleArea + return WindowPlacement(x: screen.midX, y: screen.midY, + width: screen.width / 2, height: screen.height) +} +``` + +--- + +## MenuBarExtra Style (macOS-only) + +Choose between dropdown menu and popover panel for `MenuBarExtra`. + +```swift +// Dropdown menu (default) +MenuBarExtra("Status", systemImage: "chart.bar") { + Button("Action") { /* ... */ } +} +.menuBarExtraStyle(.menu) + +// Popover panel with custom SwiftUI content +MenuBarExtra("Status", systemImage: "chart.bar") { + DashboardView() +} +.menuBarExtraStyle(.window) +``` + +--- + +## Navigation Layout (macOS behavior) + +### NavigationSplitView + +On macOS, `NavigationSplitView` displays columns side-by-side (never overlaid). The sidebar gets a translucent material background. Columns support variable-width resizing by the user. + +```swift +NavigationSplitView { + List(items, selection: $selectedId) { item in + Text(item.name) + } + .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300) +} detail: { + DetailView(id: selectedId) +} +.navigationSplitViewStyle(.balanced) +``` + +Use the three-column variant (`sidebar` / `content` / `detail`) for master-detail-detail layouts. Customize column widths with `.navigationSplitViewColumnWidth(min:ideal:max:)`. + +### Inspector (macOS 14.0+) + +A trailing-edge panel for supplementary information. On macOS, it appears as a sidebar-style panel that can be resized by dragging its edge. + +```swift +struct ContentView: View { + @State private var showInspector = false + + var body: some View { + MainContent() + .inspector(isPresented: $showInspector) { + InspectorView() + .inspectorColumnWidth(min: 200, ideal: 250, max: 400) + } + .toolbar { + ToolbarItem { + Button { + showInspector.toggle() + } label: { + Label("Inspector", systemImage: "info.circle") + } + } + } + } +} +``` + +--- + +## Commands & Keyboard + +### Commands, CommandGroup, CommandMenu + +Define menu bar commands. On macOS, these populate the menu bar directly. On iOS, they create key commands. + +```swift +.commands { + CommandMenu("Tools") { + Button("Run Analysis") { /* ... */ } + .keyboardShortcut("r", modifiers: [.command, .shift]) + } + CommandGroup(after: .newItem) { + Button("New From Template...") { /* ... */ } + } +} +``` + +**`CommandGroup` placement options:** `.replacing(_:)` replaces a system group, `.before(_:)` / `.after(_:)` inserts adjacent to it. Common placements: `.newItem`, `.saveItem`, `.help`, `.toolbar`, `.sidebar`. + +### KeyboardShortcut + +On macOS, shortcuts are displayed alongside menu items and in button tooltips on hover. + +```swift +Button("Save") { + save() +} +.keyboardShortcut("s", modifiers: .command) + +Button("Delete") { + delete() +} +.keyboardShortcut(.delete, modifiers: .command) +``` + +### openWindow + +Programmatically open a window. If the target window is already open, brings it to the front. + +```swift +struct ToolbarActions: View { + @Environment(\.openWindow) private var openWindow + + var body: some View { + Button("Connection Doctor") { + openWindow(id: "connection-doctor") + } + + Button("Show Message") { + openWindow(value: message.id) // Type-matched to WindowGroup + } + } +} +``` + +--- + +## Best Practices + +- **Use `.unified` or `.unifiedCompact`** for most apps — `.expanded` only when you need many toolbar items +- **Set min frame sizes on content** and use `.windowResizability(.contentMinSize)` to enforce them +- **Always provide `defaultSize`** so new windows start at a reasonable size +- **Use `NavigationSplitView`** for sidebar navigation — not `HSplitView` +- **Use `Inspector`** for supplementary panels — it integrates with the toolbar automatically +- **Define `Commands`** for all repeatable actions — users expect keyboard shortcuts on macOS +- **Use `#if os(macOS)`** to wrap macOS-only window configuration in multiplatform projects diff --git a/.cursor/skills/swiftui-expert-skill/references/modifier-patterns.md b/.cursor/skills/swiftui-expert-skill/references/modifier-patterns.md new file mode 100644 index 00000000..03a8d24c --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/modifier-patterns.md @@ -0,0 +1,64 @@ +# SwiftUI Modifier and Identity Patterns + +Use this reference when a modifier is conditional or when modifier composition changes a view's structural identity. + +## Avoid Conditional `.if` Modifiers + +Do not create an `@ViewBuilder` extension that switches between `transform(self)` and `self`. Its branches produce different view types, so toggling the condition can replace the subtree, reset state, and break animations. + +```swift +// AVOID +extension View { + @ViewBuilder + func `if`( + _ condition: Bool, + transform: (Self) -> Content + ) -> some View { + if condition { transform(self) } else { self } + } +} +``` + +When two states describe the same view, keep one structural identity and vary the modifier's value: + +```swift +Text("Hello") + .foregroundStyle(isHighlighted ? .red : .primary) + .opacity(isEnabled ? 1 : 0.5) +``` + +Use `if` when the branches genuinely represent different views or when content is truly optional. Do not silently refactor an existing `.if` modifier during unrelated work; call out the identity risk and keep the behavioral change focused. + +## Use `AnyShapeStyle` When Style Types Differ + +Some `ShapeStyle` branches do not unify in a ternary. Preserve the view's identity by erasing the styles, not the view: + +```swift +Text("Status") + .foregroundStyle( + isActive + ? AnyShapeStyle(.primary) + : AnyShapeStyle(.tint) + ) +``` + +`AnyShapeStyle` is an appropriate value-type eraser and does not have the structural-identity cost of `AnyView`. Add it only when the original ternary does not compile; many combinations involving `Color`, such as `.yellow` and `.primary`, already unify. + +## Prefer No-Effect Values for Visual State + +For visibility or styling changes where the same view should retain state, prefer an always-present modifier with a no-effect value: + +```swift +DetailsView() + .opacity(isVisible ? 1 : 0) +``` + +Opacity keeps the view in layout and accessibility by default. Use conditional inclusion when hidden content should be removed from layout, interaction, or accessibility. + +## Checklist + +- [ ] Conditional styling changes modifier values instead of branching the whole view +- [ ] Existing `.if` helpers are reported as focused identity risks, not rewritten incidentally +- [ ] `AnyShapeStyle` is used only when different style types fail to unify +- [ ] `AnyView` is not introduced to solve a modifier type mismatch +- [ ] Visibility behavior intentionally accounts for layout, hit testing, and accessibility diff --git a/.cursor/skills/swiftui-expert-skill/references/performance-patterns.md b/.cursor/skills/swiftui-expert-skill/references/performance-patterns.md new file mode 100644 index 00000000..b62c68c2 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/performance-patterns.md @@ -0,0 +1,403 @@ +# SwiftUI Performance Patterns Reference + +## Table of Contents + +- [Performance Optimization](#performance-optimization) +- [Anti-Patterns](#anti-patterns) +- [Summary Checklist](#summary-checklist) + +## Performance Optimization + +### 1. Avoid Redundant State Updates + +SwiftUI doesn't compare values before triggering updates: + +```swift +// BAD - triggers update even if value unchanged +.onReceive(publisher) { value in + self.currentValue = value // Always triggers body re-evaluation +} + +// GOOD - only update when different +.onReceive(publisher) { value in + if self.currentValue != value { + self.currentValue = value + } +} +``` + +### 2. Optimize Hot Paths + +Hot paths are frequently executed code (scroll handlers, animations, gestures): + +```swift +// BAD - updates state on every scroll position change +.onPreferenceChange(ScrollOffsetKey.self) { offset in + shouldShowTitle = offset.y <= -32 // Fires constantly during scroll! +} + +// GOOD - only update when threshold crossed +.onPreferenceChange(ScrollOffsetKey.self) { offset in + let shouldShow = offset.y <= -32 + if shouldShow != shouldShowTitle { + shouldShowTitle = shouldShow // Fires only when crossing threshold + } +} +``` + +### 3. Pass Only What Views Need + +**Avoid passing large "config" or "context" objects.** Pass only the specific values each view needs. + +```swift +// Good - pass specific values +ThemeSelector(theme: config.theme) +FontSizeSlider(fontSize: config.fontSize) + +// Avoid - passing entire config (creates broad dependency) +ThemeSelector(config: config) // Notified of ALL config changes +``` + +With `ObservableObject`, any `@Published` change triggers all observers. With `@Observable`, views update only when accessed properties change, but passing entire objects still creates broader dependencies than necessary. + +### 4. Use Equatable Views + +For views with expensive bodies, conform to `Equatable`: + +```swift +struct ExpensiveView: View, Equatable { + let data: SomeData + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.data.id == rhs.data.id // Custom equality check + } + + var body: some View { + // Expensive computation + } +} + +// Usage +ExpensiveView(data: data) + .equatable() // Use custom equality +``` + +**Caution**: If you add new state or dependencies to your view, remember to update your `==` function! + +### 5. POD Views for Fast Diffing + +**POD (Plain Old Data) views use `memcmp` for fastest diffing.** A view is POD if it only contains simple value types and no property wrappers. + +```swift +// POD view - fastest diffing +struct FastView: View { + let title: String + let count: Int + + var body: some View { + Text("\(title): \(count)") + } +} + +// Non-POD view - uses reflection or custom equality +struct SlowerView: View { + let title: String + @State private var isExpanded = false // Property wrapper makes it non-POD + + var body: some View { + Text(title) + } +} +``` + +**Advanced Pattern**: Wrap expensive non-POD views in POD parent views: + +```swift +// POD wrapper for fast diffing +struct ExpensiveView: View { + let value: Int + + var body: some View { + ExpensiveViewInternal(value: value) + } +} + +// Internal view with state +private struct ExpensiveViewInternal: View { + let value: Int + @State private var item: Item? + + var body: some View { + // Expensive rendering + } +} +``` + +**Why**: The POD parent uses fast `memcmp` comparison. Only when `value` changes does the internal view get diffed. + +### 6. Lazy Loading + +Use lazy containers for large collections: + +```swift +// BAD - creates all views immediately +ScrollView { + VStack { + ForEach(items) { item in + ExpensiveRow(item: item) + } + } +} + +// GOOD - creates views on demand +ScrollView { + LazyVStack { + ForEach(items) { item in + ExpensiveRow(item: item) + } + } +} +``` + +**iOS 26+ note**: Nested scroll views containing lazy stacks now automatically defer loading their children until they are about to appear, matching the behavior of top-level lazy stacks. This benefits patterns like horizontal photo carousels inside a vertical scroll view. + +> Source: "What's new in SwiftUI" (WWDC25, session 256) + +### 7. Task Cancellation + +Cancel async work when view disappears: + +```swift +struct DataView: View { + @State private var data: [Item] = [] + + var body: some View { + List(data) { item in + Text(item.name) + } + .task { + // Automatically cancelled when view disappears + data = await fetchData() + } + } +} +``` + +### 8. Debug View Updates + +**Use `Self._printChanges()` or `Self._logChanges()` to debug unexpected view updates.** + +```swift +struct DebugView: View { + @State private var count = 0 + @State private var name = "" + + var body: some View { + #if DEBUG + let _ = Self._logChanges() // Xcode 15.1+: logs to com.apple.SwiftUI subsystem + #endif + + VStack { + Text("Count: \(count)") + Text("Name: \(name)") + } + } +} +``` + +- `Self._printChanges()`: Prints which properties changed to standard output. +- `Self._logChanges()` (iOS 17+): Logs to the `com.apple.SwiftUI` subsystem with category "Changed Body Properties", using `os_log` for structured output. + +Both print `@self` when the view value itself changed and `@identity` when the view's persistent data was recycled. + +**Why**: This helps identify which state changes are causing view updates. Isolating redraw triggers into single-responsibility subviews is often the fix -- extracting a subview means SwiftUI can skip its body when its inputs haven't changed. + +### 9. Eliminate Unnecessary Dependencies + +**Narrow state scope to reduce update fan-out.** Instead of passing an entire `@Observable` model to a row view (which creates a dependency on all accessed properties), pass only the specific values the view needs as `let` properties. + +```swift +// Bad - broad dependency on entire model +struct ItemRow: View { + @Environment(AppModel.self) private var model + let item: Item + var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) } +} + +// Good - narrow dependency +struct ItemRow: View { + let item: Item + let themeColor: Color + var body: some View { Text(item.name).foregroundStyle(themeColor) } +} +``` + +For rapidly changing environment values, stable defaults, and closure comparison pitfalls, consult `references/environment-patterns.md`. Moving a raw value into `@Observable` is not enough by itself; readers need a coarsened or per-item property that changes less often. + +> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306) + +### 10. @Observable Dependency Granularity + +**Consider per-item `@Observable` state holders (one per row/item) to narrow update scope.** When multiple list items share a dependency on the same `@Observable` array, changing one element causes all items to re-evaluate their bodies. + +```swift +// BAD - all item views depend on the full favorites array +@Observable +class ModelData { + var favorites: [Landmark] = [] + + func isFavorite(_ landmark: Landmark) -> Bool { + favorites.contains(landmark) + } +} + +struct LandmarkRow: View { + let landmark: Landmark + @Environment(ModelData.self) private var model + + var body: some View { + HStack { + Text(landmark.name) + if model.isFavorite(landmark) { + Image(systemName: "heart.fill") + } + } + } +} + +// GOOD - each item has its own observable view model +@Observable +class LandmarkViewModel { + var isFavorite: Bool = false +} + +struct LandmarkRow: View { + let landmark: Landmark + let viewModel: LandmarkViewModel + + var body: some View { + HStack { + Text(landmark.name) + if viewModel.isFavorite { + Image(systemName: "heart.fill") + } + } + } +} +``` + +**Why**: With the bad pattern, toggling one favorite marks the entire array as changed, causing every `LandmarkRow` to re-run its body. With per-item view models, only the toggled item's body runs. + +> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306) + +### 11. Off-Main-Thread Closures + +**SwiftUI may call certain closures on a background thread for performance.** These closures must be `Sendable` and should avoid accessing `@MainActor`-isolated state directly. Instead, capture needed values in the closure's capture list. + +Closures that may run off the main thread: +- `Shape.path(in:)` +- `visualEffect` closure +- `Layout` protocol methods +- `onGeometryChange` transform closure + +```swift +// BAD - accessing @MainActor state directly +.visualEffect { content, geometry in + content.blur(radius: self.pulse ? 5 : 0) // Compiler error: @MainActor isolated +} + +// GOOD - capture the value +.visualEffect { [pulse] content, geometry in + content.blur(radius: pulse ? 5 : 0) +} +``` + +> Source: "Explore concurrency in SwiftUI" (WWDC25, session 266) + +### 12. Common Performance Issues + +**Be aware of common performance bottlenecks in SwiftUI:** + +- View invalidation storms from broad state changes +- Unstable identity in lists causing excessive diffing +- Heavy work in `body` (formatting, sorting, image decoding) +- Layout thrash from deep stacks or preference chains + +**When performance issues arise**, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks. + +## Anti-Patterns + +### 1. Creating Objects in Body + +```swift +// BAD - creates new formatter every body call +var body: some View { + let formatter = DateFormatter() + formatter.dateStyle = .long + return Text(formatter.string(from: date)) +} + +// GOOD - static or stored formatter +private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .long + return f +}() + +var body: some View { + Text(Self.dateFormatter.string(from: date)) +} +``` + +### 2. Heavy Computation in Body + +**Keep view body simple and pure.** Avoid side effects, dispatching, or complex logic. + +```swift +// BAD - sorts array every body call +var body: some View { + List(items.sorted { $0.name < $1.name }) { item in Text(item.name) } +} + +// GOOD - compute once, update via onChange or a computed property in the model +@State private var sortedItems: [Item] = [] + +var body: some View { + List(sortedItems) { item in Text(item.name) } + .onChange(of: items) { _, newItems in + sortedItems = newItems.sorted { $0.name < $1.name } + } +} +``` + +Move sorting, filtering, and formatting into models or computed properties. The `body` should be a pure structural representation of state. + +### 3. Unnecessary State + +```swift +// BAD - derived state stored separately +@State private var items: [Item] = [] +@State private var itemCount: Int = 0 // Unnecessary! + +// GOOD - compute derived values +@State private var items: [Item] = [] + +var itemCount: Int { items.count } // Computed property +``` + +## Summary Checklist + +- [ ] State updates check for value changes before assigning +- [ ] Hot paths minimize state updates +- [ ] Pass only needed values to views (avoid large config objects) +- [ ] Large lists use `LazyVStack`/`LazyHStack` +- [ ] No object creation in `body` +- [ ] Heavy computation moved out of `body` +- [ ] Body kept simple and pure (no side effects) +- [ ] Derived state computed, not stored +- [ ] Use `Self._logChanges()` or `Self._printChanges()` to debug unexpected updates +- [ ] Equatable conformance for expensive views (when appropriate) +- [ ] Consider POD view wrappers for advanced optimization +- [ ] Consider using granular @Observable dependencies for list items (smaller observable units per row when it measurably reduces updates) +- [ ] Frequently-changing values not stored in the environment +- [ ] Sendable closures (Shape, visualEffect, Layout) capture values instead of accessing @MainActor state diff --git a/.cursor/skills/swiftui-expert-skill/references/previews.md b/.cursor/skills/swiftui-expert-skill/references/previews.md new file mode 100644 index 00000000..6e19d2bf --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/previews.md @@ -0,0 +1,313 @@ +# SwiftUI Previews Reference + +## Table of Contents + +- [Preview Macro](#preview-macro) +- [Preview with Mock Data](#preview-with-mock-data) +- [@Previewable Property Wrappers](#previewable-property-wrappers) +- [Common Diagnostics](#common-diagnostics) +- [Summary Checklist](#summary-checklist) + +--- + +## Preview Macro + +The `#Preview` macro (Swift 5.9+, Xcode 15+) is the modern way to declare previews. The legacy `PreviewProvider` protocol still works; prefer `#Preview` for new code because it's less verbose and supports inline traits. + +### Basic Usage + +```swift +// Modern: #Preview macro +#Preview { + ContentView() +} + +// Named preview +#Preview("Dark Mode") { + ContentView() + .preferredColorScheme(.dark) +} + +// Legacy: PreviewProvider — still valid, but verbose for new code +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} +``` + +### Multiple Previews + +Declare one `#Preview` per meaningful state so each renders independently in the canvas: + +```swift +#Preview("Default") { + SettingsRow(title: "Notifications", isOn: true) +} + +#Preview("Off State") { + SettingsRow(title: "Notifications", isOn: false) +} + +#Preview("Long Title") { + SettingsRow(title: "Enable Push Notifications for All Events", isOn: true) +} +``` + +### Preview Traits + +Traits configure the preview environment without modifying the view itself: + +```swift +// Fixed size +#Preview(traits: .fixedLayout(width: 300, height: 100)) { + CompactBanner(message: "Welcome") +} + +// Size that fits content +#Preview(traits: .sizeThatFitsLayout) { + BadgeView(count: 5) +} + +// Landscape orientation +#Preview(traits: .landscapeLeft) { + DashboardView() +} +``` + +### Previewing Inside NavigationStack + +Wrap previewed destinations in their navigation container so toolbar items, titles, and back buttons render correctly: + +```swift +#Preview { + NavigationStack { + DetailView(item: .sample) + } +} +``` + +--- + +## Preview with Mock Data + +Previews must compile and render without external dependencies. Live services, network calls, and disk I/O make previews slow, flaky, or broken; use self-contained sample data instead. + +### Static Sample Data + +Expose sample values as static properties on the model itself so any preview can reuse them without reconstructing values inline: + +```swift +struct Item: Identifiable { + let id: UUID + var name: String + var price: Double +} + +extension Item { + static let sample = Item(id: UUID(), name: "Widget", price: 9.99) + + static let samples: [Item] = [ + Item(id: UUID(), name: "Widget", price: 9.99), + Item(id: UUID(), name: "Gadget", price: 19.99), + Item(id: UUID(), name: "Doohickey", price: 4.99), + ] +} + +#Preview { + ItemListView(items: Item.samples) +} +``` + +### Mock Observable Models + +For views driven by an `@Observable` model (see `state-management.md` for fundamentals), expose pre-configured instances on the model itself: + +```swift +@Observable +@MainActor +final class CartModel { + var items: [Item] = [] + var isLoading = false + + static var preview: CartModel { + let model = CartModel() + model.items = Item.samples + return model + } + + static var emptyPreview: CartModel { + CartModel() + } + + static var loadingPreview: CartModel { + let model = CartModel() + model.isLoading = true + return model + } +} + +#Preview("With Items") { + CartView() + .environment(CartModel.preview) +} + +#Preview("Empty") { + CartView() + .environment(CartModel.emptyPreview) +} + +#Preview("Loading") { + CartView() + .environment(CartModel.loadingPreview) +} +``` + +### Preview with Environment Dependencies + +Inject any environment values the view depends on so the preview reflects a realistic runtime context: + +```swift +#Preview { + OrderDetailView(order: .sample) + .environment(CartModel.preview) + .environment(\.locale, Locale(identifier: "ja_JP")) + .environment(\.dynamicTypeSize, .xxxLarge) +} +``` + +### Mocking Async Data Sources + +When a view depends on a network or data service, give the dependency a protocol abstraction so previews can inject a synchronous mock that returns sample data immediately. This is one approach — adapt it to whatever pattern the surrounding codebase already uses. + +```swift +protocol DataFetching { + func fetchItems() async throws -> [Item] +} + +struct LiveDataFetcher: DataFetching { + let url: URL + + func fetchItems() async throws -> [Item] { + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode([Item].self, from: data) + } +} + +struct MockDataFetcher: DataFetching { + var result: Result<[Item], Error> = .success(Item.samples) + + func fetchItems() async throws -> [Item] { + try result.get() + } +} + +#Preview { + ItemListView(fetcher: MockDataFetcher()) +} + +#Preview("Error State") { + ItemListView(fetcher: MockDataFetcher(result: .failure(URLError(.notConnectedToInternet)))) +} +``` + +--- + +## @Previewable Property Wrappers + +`@Previewable` (iOS 18+, Xcode 16+) lets you use `@State`, `@FocusState`, and other property wrappers directly inside a `#Preview` block, removing the need for a wrapper view to host interactive state. + +### Interactive State + +```swift +// @Previewable: interactive toggle inline in the preview +#Preview { + @Previewable @State var isOn = false + Toggle("Notifications", isOn: $isOn) +} + +// Without @Previewable: requires a wrapper view +struct TogglePreviewWrapper: View { + @State private var isOn = false + var body: some View { + Toggle("Notifications", isOn: $isOn) + } +} + +#Preview { + TogglePreviewWrapper() +} +``` + +### Multiple Interactive Controls + +```swift +#Preview { + @Previewable @State var name = "Alice" + @Previewable @State var age = 25.0 + + VStack { + TextField("Name", text: $name) + Slider(value: $age, in: 0...100, step: 1) { + Text("Age: \(Int(age))") + } + Text("Hello, \(name)! Age: \(Int(age))") + } + .padding() +} +``` + +### @Previewable with @FocusState + +When seeding initial focus inside a preview, prefer `.defaultFocus` over writing to `@FocusState` from `.onAppear`. `.onAppear` can race the initial render and the focus assignment may be lost. See `focus-patterns.md` for the underlying rationale. + +```swift +#Preview { + @Previewable @FocusState var isFocused: Bool + + TextField("Search", text: .constant("")) + .focused($isFocused) + .defaultFocus($isFocused, true) +} +``` + +### Fallback for Pre-iOS 18 Targets + +If the project's minimum deployment target is below iOS 18, `@Previewable` is unavailable. Fall back to a wrapper view: + +```swift +private struct SliderPreview: View { + @State private var value = 0.5 + var body: some View { + CustomSlider(value: $value) + } +} + +#Preview { + SliderPreview() +} +``` + +--- + +## Common Diagnostics + +| Symptom | Cause | Fix | +|---|---|---| +| `#Preview` body type mismatch | The closure returns a non-`View` type | Make sure the final expression is a `View` | +| `@Previewable` only available in iOS 18+ | Using `@Previewable` with a lower deployment target | Use a wrapper view, or gate with `#available` | +| Preview crashes with "missing environment" | An `@Environment(SomeType.self)` value is not injected | Add `.environment(SomeType.preview)` to the preview | +| Preview hangs or renders blank | View depends on async data that never resolves | Inject a mock that returns immediately with sample data | +| `@MainActor`-isolated model accessed from non-isolated context | A preview helper touches main-actor-only API off the main actor | Mark the helper or the preview body `@MainActor` | + +--- + +## Summary Checklist + +- [ ] Prefer `#Preview` for new previews; `PreviewProvider` is still valid for older code +- [ ] Provide a named preview for each meaningful state (default, empty, error, loading) +- [ ] Use `@Previewable` for interactive previews when targeting iOS 18+; wrapper views otherwise +- [ ] Expose static `.sample` / `.preview` data on models so previews don't reconstruct values inline +- [ ] Inject mock services through a protocol when a view depends on async data +- [ ] Never depend on live network or disk I/O in a preview +- [ ] Prefer `.defaultFocus` over `.onAppear` writes when seeding `@FocusState` in previews diff --git a/.cursor/skills/swiftui-expert-skill/references/scroll-patterns.md b/.cursor/skills/swiftui-expert-skill/references/scroll-patterns.md new file mode 100644 index 00000000..6827d556 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/scroll-patterns.md @@ -0,0 +1,352 @@ +# SwiftUI ScrollView Patterns Reference + +## Table of Contents + +- [Choose the Appropriate Scroll API](#choose-the-appropriate-scroll-api) +- [ScrollViewReader for Programmatic Scrolling](#scrollviewreader-for-programmatic-scrolling) +- [Scroll Position Tracking](#scroll-position-tracking) +- [Scroll Transitions and Effects](#scroll-transitions-and-effects) +- [Scroll Target Behavior](#scroll-target-behavior) +- [Summary Checklist](#summary-checklist) + +## Choose the Appropriate Scroll API + +- On iOS 18+, use `onScrollGeometryChange(for:of:action:)` to observe scroll geometry. +- On iOS 18+, use `scrollPosition(_:)` with `ScrollPosition` to scroll by identity, offset, or edge. +- On iOS 17+, use `scrollPosition(id:)` when an optional ID binding is sufficient. +- Use `ScrollViewReader` when proxy-based scrolling or support for earlier versions is needed. + +## ScrollViewReader for Programmatic Scrolling + +**Use `ScrollViewReader` for proxy-based scroll-to-top, scroll-to-bottom, and anchor-based jumps.** + +```swift +struct ChatView: View { + @State private var messages: [Message] = [] + private let bottomID = "bottom" + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + ForEach(messages) { message in + MessageRow(message: message) + .id(message.id) + } + Color.clear + .frame(height: 1) + .id(bottomID) + } + } + .onChange(of: messages.count) { _, _ in + withAnimation { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(bottomID, anchor: .bottom) + } + } + } +} +``` + +### Scroll-to-Top Pattern + +```swift +struct FeedView: View { + @State private var items: [Item] = [] + @State private var scrollToTop = false + private let topID = "top" + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack { + Color.clear + .frame(height: 1) + .id(topID) + + ForEach(items) { item in + ItemRow(item: item) + } + } + } + .onChange(of: scrollToTop) { _, shouldScroll in + if shouldScroll { + withAnimation { + proxy.scrollTo(topID, anchor: .top) + } + scrollToTop = false + } + } + } + } +} +``` + +**Why**: `ScrollViewReader` provides proxy-based programmatic scroll control. Use stable IDs for scroll targets, and add animation when an animated transition is appropriate. + +## Scroll Position Tracking + +> **iOS 18+**: Use `onScrollGeometryChange(for:of:action:)` to observe scroll geometry and `scrollPosition(_:)` with a `ScrollPosition` binding for flexible programmatic scrolling. For iOS 17, use `scrollPosition(id:)` with an optional ID binding. + +### Observe Scroll Geometry (iOS 18+) + +`onScrollGeometryChange` transforms frequently changing `ScrollGeometry` into an `Equatable` value and runs its action when that transformed value changes. Extract the smallest value needed by the feature. + +When exact offset tracking is required, extract `contentOffset`. This value normally changes on every scrolling frame, so avoid using it to update large or expensive view hierarchies: + +```swift +struct OffsetTrackingView: View { + @State private var scrollOffset: CGFloat = 0 + + var body: some View { + ScrollView { + content + } + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + } action: { _, newValue in + scrollOffset = newValue + } + } +} +``` + +When only a threshold matters, transform the geometry into a `Bool` so the action runs only when the scroll view crosses that threshold. The header visibility example below demonstrates this pattern. + +### Programmatic Scroll Position (iOS 18+) + +The unlabeled `scrollPosition(_:)` overload requires a `Binding`. Add `scrollTargetLayout()` to the layout containing the identified views: + +```swift +struct ProgrammaticScrollView: View { + @State private var position = ScrollPosition(idType: Item.ID.self) + + var body: some View { + ScrollView { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item) + } + } + .scrollTargetLayout() + } + .scrollPosition($position) + .toolbar { + Button("Scroll to First") { + if let firstID = items.first?.id { + withAnimation { + position.scrollTo(id: firstID) + } + } + } + } + } +} +``` + +For iOS 17, bind the ID using the labeled overload instead: + +```swift +@State private var scrolledID: Item.ID? + +ScrollView { + LazyVStack { + ForEach(items) { item in + ItemRow(item: item) + } + } + .scrollTargetLayout() +} +.scrollPosition(id: $scrolledID) +``` + +### Scroll-Based Header Visibility + +Extracting the threshold as a `Bool` avoids running the action for every offset change: + +```swift +struct ContentView: View { + @State private var showHeader = true + + var body: some View { + VStack(spacing: 0) { + if showHeader { + HeaderView() + .transition(.move(edge: .top)) + } + + ScrollView { + content + } + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.contentInsets.top > 50 + } action: { _, isPastThreshold in + withAnimation { + showHeader = !isPastThreshold + } + } + } + } +} +``` + +

+Pre-iOS 18 compatibility — GeometryReader + PreferenceKey + +Use this approach when supporting iOS 17 or earlier. `GeometryReader` and preferences remain available, but require a named coordinate space and a custom `PreferenceKey`. + +```swift +struct ContentView: View { + @State private var showHeader = true + + var body: some View { + VStack(spacing: 0) { + if showHeader { + HeaderView() + .transition(.move(edge: .top)) + } + + ScrollView { + content + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named("scroll")).minY + ) + } + ) + } + .coordinateSpace(.named("scroll")) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + let shouldShowHeader = offset >= -50 + if shouldShowHeader != showHeader { + withAnimation { + showHeader = shouldShowHeader + } + } + } + } + } +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} +``` + +
+ +## Scroll Transitions and Effects + +> **iOS 17+**: All APIs in this section require iOS 17 or later. + +### Scroll-Based Opacity + +```swift +struct ParallaxView: View { + var body: some View { + ScrollView { + LazyVStack(spacing: 20) { + ForEach(items) { item in + ItemCard(item: item) + .visualEffect { content, geometry in + let frame = geometry.frame(in: .scrollView) + let distance = min(0, frame.minY) + return content + .opacity(1 + distance / 200) + } + } + } + } + } +} +``` + +### Parallax Effect + +```swift +struct ParallaxHeader: View { + var body: some View { + ScrollView { + VStack(spacing: 0) { + Image("hero") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 300) + .visualEffect { content, geometry in + let offset = geometry.frame(in: .scrollView).minY + return content + .offset(y: offset > 0 ? -offset * 0.5 : 0) + } + .clipped() + + ContentView() + } + } + } +} +``` + +## Scroll Target Behavior + +> **iOS 17+**: All APIs in this section require iOS 17 or later. + +### Paging ScrollView + +```swift +struct PagingView: View { + var body: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 0) { + ForEach(pages) { page in + PageView(page: page) + .containerRelativeFrame(.horizontal) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + } +} +``` + +### Snap to Items + +```swift +struct SnapScrollView: View { + var body: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 16) { + ForEach(items) { item in + ItemCard(item: item) + .frame(width: 280) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .contentMargins(.horizontal, 20) + } +} +``` + +## Summary Checklist + +- [ ] Use `ScrollViewReader` with stable IDs when proxy-based scrolling is needed +- [ ] Use `.visualEffect` for scroll-based visual changes +- [ ] Use `.scrollTargetBehavior(.paging)` for paging behavior +- [ ] Use `.scrollTargetBehavior(.viewAligned)` for snap-to-item behavior +- [ ] Use `onScrollGeometryChange` (iOS 18+) and extract only the value needed +- [ ] Use `scrollPosition(_:)` with `ScrollPosition` for flexible scrolling on iOS 18+ +- [ ] Use `scrollPosition(id:)` with an optional ID binding on iOS 17+ +- [ ] Add `.scrollTargetLayout()` when scrolling to identified views +- [ ] Derive threshold values instead of propagating every offset change when possible +- [ ] Use the `GeometryReader` + preference approach when supporting pre-iOS 18 versions diff --git a/.cursor/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md b/.cursor/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md new file mode 100644 index 00000000..3ade2fb2 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md @@ -0,0 +1,388 @@ +# SwiftUI Sheet, Navigation & Inspector Patterns Reference + +## Table of Contents + +- [Sheet Patterns](#sheet-patterns) +- [Item-Driven Alerts and Confirmation Dialogs (SDK 27)](#item-driven-alerts-and-confirmation-dialogs-sdk-27) +- [Navigation Patterns](#navigation-patterns) +- [Multi-Column Navigation with NavigationSplitView](#multi-column-navigation-with-navigationsplitview) +- [Inspector](#inspector) +- [Presentation Modifiers](#presentation-modifiers) +- [Summary Checklist](#summary-checklist) + +## Sheet Patterns + +### Item-Driven Sheets (Preferred) + +**Use `.sheet(item:)` instead of `.sheet(isPresented:)` when presenting model-based content.** + +```swift +// Good - item-driven +@State private var selectedItem: Item? + +var body: some View { + List(items) { item in + Button(item.name) { + selectedItem = item + } + } + .sheet(item: $selectedItem) { item in + ItemDetailSheet(item: item) + } +} + +// Avoid - boolean flag requires separate state +@State private var showSheet = false +@State private var selectedItem: Item? + +var body: some View { + List(items) { item in + Button(item.name) { + selectedItem = item + showSheet = true + } + } + .sheet(isPresented: $showSheet) { + if let selectedItem { + ItemDetailSheet(item: selectedItem) + } + } +} +``` + +**Why**: `.sheet(item:)` automatically handles presentation state and avoids optional unwrapping in the sheet body. + +### Sheets Own Their Actions + +**Sheets should handle their own dismiss and actions internally** using `@Environment(\.dismiss)`. Avoid passing `onSave`/`onCancel` closures from the parent -- it creates callback prop-drilling and reduces reusability. + +```swift +struct EditItemSheet: View { + @Environment(\.dismiss) private var dismiss + let item: Item + @State private var name: String + + init(item: Item) { + self.item = item + _name = State(initialValue: item.name) + } + + var body: some View { + NavigationStack { + Form { TextField("Name", text: $name) } + .navigationTitle("Edit Item") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Save") { /* save and dismiss */ } } + } + } + } +} +``` + +### Enum-Based Sheet Management + +When presenting multiple different sheets, use an `Identifiable` enum with `.sheet(item:)` instead of multiple boolean state properties: + +```swift +struct ArticlesView: View { + enum Sheet: Identifiable { + case add, edit(Article), categories + var id: String { + switch self { + case .add: "add" + case .edit(let a): "edit-\(a.id)" + case .categories: "categories" + } + } + } + + @State private var presentedSheet: Sheet? + + var body: some View { + List { /* ... */ } + .toolbar { + Button("Add") { presentedSheet = .add } + } + .sheet(item: $presentedSheet) { sheet in + switch sheet { + case .add: AddArticleView() + case .edit(let article): EditArticleView(article: article) + case .categories: CategoriesView() + } + } + } +} +``` + +**Why**: A single `@State` property and one `.sheet(item:)` modifier replaces N boolean properties and N sheet modifiers, improving readability and preventing only-one-sheet-at-a-time conflicts. + +## Item-Driven Alerts and Confirmation Dialogs (SDK 27) + +SDK 27 adds `alert(_:item:actions:message:)` and `confirmationDialog(_:item:titleVisibility:actions:message:)`. The optional binding alone drives presentation, the unwrapped value is passed to the action and message closures, and dismissal resets the binding to `nil`. The item does not need to conform to `Identifiable`. + +```swift +@State private var photoToDelete: Photo? + +var body: some View { + PhotoList { photoToDelete = $0 } + .confirmationDialog( + "Delete photo?", + item: $photoToDelete + ) { photo in + Button("Delete \(photo.name)", role: .destructive) { + delete(photo) + } + } message: { photo in + Text("\(photo.name) will be removed.") + } +} +``` + +Prefer the item overload for an action tied to an optional value instead of synchronizing a separate Boolean or pairing `isPresented` with `presenting:`. Do not use the older `Alert`-returning `alert(item:)`. These overloads require the SDK 27 toolchain but back-deploy to iOS 15, macOS 12, tvOS 15, watchOS 8, and visionOS 1; no runtime availability gate is needed at those deployment targets. + +## Navigation Patterns + +### Type-Safe Navigation with NavigationStack + +```swift +struct ContentView: View { + var body: some View { + NavigationStack { + List { + NavigationLink("Profile", value: Route.profile) + NavigationLink("Settings", value: Route.settings) + } + .navigationDestination(for: Route.self) { route in + switch route { + case .profile: + ProfileView() + case .settings: + SettingsView() + } + } + } + } +} + +enum Route: Hashable { + case profile + case settings +} +``` + +### Programmatic Navigation + +```swift +struct ContentView: View { + @State private var navigationPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + Button("Go to Detail") { + navigationPath.append(DetailRoute.item(id: 1)) + } + } + .navigationDestination(for: DetailRoute.self) { route in + switch route { + case .item(let id): + ItemDetailView(id: id) + } + } + } + } +} + +enum DetailRoute: Hashable { + case item(id: Int) +} +``` + +## Multi-Column Navigation with NavigationSplitView + +### Two-Column Layout + +Use `NavigationSplitView` for sidebar-driven navigation. Available on iOS 16+, macOS 13+, tvOS 16+, watchOS 9+. + +```swift +struct ContentView: View { + @State private var selectedItem: Item.ID? + + var body: some View { + NavigationSplitView { + List(items, selection: $selectedItem) { item in + Text(item.name) + } + .navigationTitle("Items") + } detail: { + if let selectedItem, let item = items.first(where: { $0.id == selectedItem }) { + ItemDetailView(item: item) + } else { + ContentUnavailableView("Select an Item", systemImage: "doc") + } + } + } +} +``` + +### Three-Column Layout + +```swift +struct ContentView: View { + @State private var departmentId: Department.ID? + @State private var employeeIds = Set() + + var body: some View { + NavigationSplitView { + List(model.departments, selection: $departmentId) { dept in + Text(dept.name) + } + } content: { + if let department = model.department(id: departmentId) { + List(department.employees, selection: $employeeIds) { emp in + Text(emp.name) + } + } else { + Text("Select a department") + } + } detail: { + EmployeeDetails(for: employeeIds) + } + } +} +``` + +### Configuration + +- **Column visibility**: `NavigationSplitView(columnVisibility: $visibility)` with `NavigationSplitViewVisibility` (`.detailOnly`, `.doubleColumn`, `.all`) +- **Column widths**: `.navigationSplitViewColumnWidth(min:ideal:max:)` on each column +- **Compact column**: `NavigationSplitView(preferredCompactColumn: $column)` to control which column shows on narrow devices +- **Style**: `.navigationSplitViewStyle(.balanced)` or `.prominentDetail` (default) + +### Platform Behavior + +| Platform | Behavior | +|----------|----------| +| **macOS** | Columns always visible side-by-side; sidebar has translucent material; variable-width column resizing by dragging | +| **iPadOS (regular)** | Sidebar can overlay or push detail; supports column visibility toggle via toolbar button | +| **iOS / iPadOS (compact)** | Collapses into a single `NavigationStack`; sidebar items show disclosure chevrons; back button navigates between columns | +| **iPhone (all sizes)** | Always collapsed into a stack; sidebar appears as the root list; selections push detail onto the stack | +| **watchOS / tvOS** | Collapses into a single stack | + +## Inspector + +> **Availability:** iOS 17.0+, macOS 14.0+ + +A trailing-edge panel for supplementary information. + +On wider size classes (macOS, iPad landscape), it appears as a **trailing column**. On compact size classes (iPhone), it **adapts to a sheet** automatically. + +### Basic Inspector + +```swift +struct ShapeEditor: View { + @State private var showInspector = false + + var body: some View { + MyEditorView() + .inspector(isPresented: $showInspector) { + InspectorContent() + } + .toolbar { + ToolbarItem { + Button { + showInspector.toggle() + } label: { + Label("Inspector", systemImage: "info.circle") + } + } + } + } +} +``` + +### Inspector with Column Width + +```swift +MyEditorView() + .inspector(isPresented: $showInspector) { + InspectorContent() + .inspectorColumnWidth(min: 200, ideal: 250, max: 400) + } +``` + +### Inspector with Fixed Width + +```swift +MyEditorView() + .inspector(isPresented: $showInspector) { + InspectorContent() + .inspectorColumnWidth(300) + } +``` + +### Platform Behavior + +| Platform | Behavior | +|----------|----------| +| **macOS** | Trailing-edge sidebar panel; resizable by dragging edge; integrates with window toolbar | +| **iPadOS (regular)** | Trailing column alongside content; toggleable via toolbar button | +| **iOS / iPadOS (compact)** | Adapts to a sheet presentation; swipe-to-dismiss supported | +| **iPhone (all sizes)** | Always presented as a sheet (no trailing column); dismiss via swipe or button | + +> **Tip:** Use `InspectorCommands` in your app's `.commands` to include the default inspector toggle keyboard shortcut. + +## Presentation Modifiers + +### Full Screen Cover + +```swift +struct ContentView: View { + @State private var showFullScreen = false + + var body: some View { + Button("Show Full Screen") { + showFullScreen = true + } + .fullScreenCover(isPresented: $showFullScreen) { + FullScreenView() + } + } +} +``` + +### Popover + +```swift +struct ContentView: View { + @State private var showPopover = false + + var body: some View { + Button("Show Popover") { + showPopover = true + } + .popover(isPresented: $showPopover) { + PopoverContentView() + .presentationCompactAdaptation(.popover) // Don't adapt to sheet on iPhone + } + } +} +``` + +For older `alert` and `confirmationDialog` API patterns, see `latest-apis.md`. Prefer the SDK 27 item overloads above when the presentation is tied to an optional value. + +## Summary Checklist + +- [ ] Use `.sheet(item:)` for model-based sheets +- [ ] Sheets own their actions and dismiss internally +- [ ] Use `NavigationStack` with `navigationDestination(for:)` for type-safe navigation +- [ ] Use `NavigationPath` for programmatic navigation +- [ ] Use `NavigationSplitView` for sidebar-driven multi-column layouts +- [ ] Use `Inspector` for trailing-edge supplementary panels +- [ ] Set column widths with `navigationSplitViewColumnWidth(min:ideal:max:)` or `inspectorColumnWidth(min:ideal:max:)` +- [ ] Use appropriate presentation modifiers (sheet, fullScreenCover, popover) +- [ ] Alerts and confirmation dialogs use modern API with actions; prefer the SDK 27 `item:` overload for an optional value +- [ ] Avoid passing dismiss/save callbacks to sheets +- [ ] Use enum-based `Identifiable` type with `.sheet(item:)` when presenting multiple sheets +- [ ] Navigation state can be saved/restored when needed diff --git a/.cursor/skills/swiftui-expert-skill/references/soft-deprecation.md b/.cursor/skills/swiftui-expert-skill/references/soft-deprecation.md new file mode 100644 index 00000000..355f1ce6 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/soft-deprecation.md @@ -0,0 +1,39 @@ +# Handling Soft-Deprecated APIs + +This file covers *how to behave* when you encounter soft-deprecated SwiftUI APIs. For the actual list of deprecated-to-modern transitions, see `references/latest-apis.md`. + +## What "soft-deprecated" means + +A soft-deprecated API is marked deprecated in the SDK headers but with a placeholder deprecation version (`100000.0`) that suppresses compiler warnings. It still compiles and works correctly — it just signals that the API shouldn't be used in new code. Examples include `NavigationView` (use `NavigationStack` / `NavigationSplitView`), `ActionSheet` / `Alert` (use the `.confirmationDialog` / `.alert` modifiers), `MagnificationGesture` (renamed `MagnifyGesture`), and `PresentationMode` (use `\.dismiss`). + +Because these still work, treat them as **informational**, not urgent. + +## Scoping rule — read this first + +All soft-deprecation guidance is scoped to the code you are **directly modifying**. If a file contains several views and the task touches only one, the other views are out of scope. + +- Only discuss the view(s) you actually edited. +- Do not mention, flag, or offer to migrate soft-deprecated APIs in code you weren't asked to change — including trailing "while I'm here, want me to migrate `OtherView`?" questions. +- This takes precedence over any prompt asking for "observations" or "other notes." + +Mentioning soft-deprecated APIs in untouched code creates noise, distracts from the task, and pressures the user into unrelated work. + +## When generating new code + +Never introduce a new usage of a soft-deprecated API. If you're unsure whether an API is soft-deprecated, check `references/latest-apis.md` before recommending it — any API that worked in a prior release could have been soft-deprecated since. + +## When asked to review, refactor, modernize, or clean up + +Point out soft-deprecated APIs in the code under review and suggest the modern replacement. Keep the tone informational — these still compile and run, so frame migration as an improvement, not a bug fix. + +## When asked to add a feature or fix a bug + +If the view you're editing already uses a soft-deprecated API, **keep it as-is** in your change. Don't silently swap `NavigationView` for `NavigationStack` while adding a search bar — that produces unexpected diffs, risks regressions (state resets, navigation behavior changes), and makes the change harder to review. After delivering the requested change, you may add a brief one-line offer to migrate as a separate step. + +If a *different* view in the same file uses a soft-deprecated API, ignore it entirely (see the scoping rule). + +## General guidance + +- Never introduce new usages of soft-deprecated APIs in code written from scratch. +- Don't proactively scan a codebase for soft-deprecated APIs — only notice them when they appear in code you're directly modifying for the user's request. +- Migrations are real edits with behavioral risk; they belong in their own focused change, not bundled into unrelated work. diff --git a/.cursor/skills/swiftui-expert-skill/references/state-management.md b/.cursor/skills/swiftui-expert-skill/references/state-management.md new file mode 100644 index 00000000..b4a7082d --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/state-management.md @@ -0,0 +1,484 @@ +# SwiftUI State Management Reference + +## Table of Contents + +- [Property Wrapper Selection Guide](#property-wrapper-selection-guide) +- [@State](#state) +- [SDK 27 `@State` Macro](#sdk-27-state-macro) +- [Property Wrappers Inside @Observable Classes](#property-wrappers-inside-observable-classes) +- [Make @Observable Property Types Equatable](#make-observable-property-types-equatable) +- [@Observable Dependency Granularity](#observable-dependency-granularity) +- [@Binding](#binding) +- [@FocusState](#focusstate) +- [@StateObject vs @ObservedObject (Legacy - Pre-iOS 17)](#stateobject-vs-observedobject-legacy---pre-ios-17) +- [Don't Store Parent-Owned Inputs as @State](#dont-store-parent-owned-inputs-as-state) +- [@Bindable (iOS 17+)](#bindable-ios-17) +- [Passed Value Inputs](#passed-value-inputs) +- [Isolate Side-Effect-Only Dependencies](#isolate-side-effect-only-dependencies) +- [Decision Flowchart](#decision-flowchart) +- [State Privacy Rules](#state-privacy-rules) +- [Avoid Nested ObservableObject](#avoid-nested-observableobject) +- [Key Principles](#key-principles) + +## Property Wrapper Selection Guide + +| Wrapper | Use When | Notes | +|---------|----------|-------| +| `@State` | Internal view state that triggers updates | Must be `private` | +| `@Binding` | Child view needs to modify parent's state | Don't use for read-only | +| `@Bindable` | iOS 17+: View receives `@Observable` object and needs bindings | For injected observables | +| `let` | Read-only value passed from parent | Simplest option | + +**Legacy (Pre-iOS 17):** +| Wrapper | Use When | Notes | +|---------|----------|-------| +| `@StateObject` | View owns an `ObservableObject` instance | Use `@State` with `@Observable` instead | +| `@ObservedObject` | View receives an `ObservableObject` from outside | Never create inline | + +## @State + +Always mark `@State` properties as `private`. Use for internal view state that triggers UI updates. + +```swift +// Correct +@State private var isAnimating = false +@State private var selectedTab = 0 +``` + +**Why Private?** Marking state as `private` makes it clear what's created by the view versus what's passed in. It also prevents accidentally passing initial values that will be ignored (see "Don't Pass Values as @State" below). + +### iOS 17+ with @Observable (Preferred) + +**Always prefer `@Observable` over `ObservableObject`.** With iOS 17's `@Observable` macro, use `@State` instead of `@StateObject`: + +```swift +@Observable +@MainActor // Always mark @Observable classes with @MainActor +final class DataModel { + var name = "Some Name" + var count = 0 +} + +struct MyView: View { + @State private var model = DataModel() // Use @State, not @StateObject + + var body: some View { + VStack { + TextField("Name", text: $model.name) + Stepper("Count: \(model.count)", value: $model.count) + } + } +} +``` + +**Critical**: When a view *owns* an `@Observable` object, always use `@State` -- not `let`. Without `@State`, SwiftUI may recreate the instance when a parent view redraws, losing accumulated state. `@State` tells SwiftUI to preserve the instance across view redraws. Using `@State` also provides bindings directly (no need for `@Bindable`). + +**Note**: You may want to mark `@Observable` classes with `@MainActor` to ensure thread safety with SwiftUI, unless your project or package uses Default Actor Isolation set to `MainActor`—in which case, the explicit attribute is redundant and can be omitted. + +## SDK 27 `@State` Macro + +SDK 27 migrates `@State` from a property wrapper to a macro. When an initializer intentionally seeds view-owned state, drop the declaration's initial value and assign it once in `init`: + +```swift +struct CounterView: View { + let name: String + @State private var count: Int + + init(name: String, count: Int) { + self.name = name + self.count = count + } +} +``` + +Do not fix “used before being initialized” by reordering assignments. Assigning in `init` to state that already has a declaration default remains incorrect: SwiftUI preserves the declaration's state storage, and later parent arguments do not replace child-owned state. + +Other source-compatibility failures: + +- “Invalid redeclaration of synthesized property”: another property wrapper composed with `@State` is colliding with macro-generated storage. Remove the redundant wrapper or restructure the composition. +- Missing private memberwise initializer: SDK 27 may not synthesize it for a view containing `@State`. Define the initializer explicitly instead of delegating to the missing memberwise initializer. + +Keep `@State` private. Use an initializer seed only for intentional one-time ownership; use a plain value or `@Binding` when later parent updates must propagate. + +## Property Wrappers Inside @Observable Classes + +**Critical**: The `@Observable` macro transforms stored properties to add observation tracking. Property wrappers (like `@AppStorage`, `@SceneStorage`, `@Query`) also transform properties with their own storage. These two transformations conflict, causing a compiler error. + +**Always annotate property-wrapper properties with `@ObservationIgnored` inside `@Observable` classes.** + +```swift +@Observable +@MainActor +final class SettingsModel { + // WRONG - compiler error: property wrappers conflict with @Observable + // @AppStorage("username") var username = "" + + // CORRECT - @ObservationIgnored prevents the conflict + @ObservationIgnored @AppStorage("username") var username = "" + @ObservationIgnored @AppStorage("isDarkMode") var isDarkMode = false + + // Regular stored properties work fine with @Observable + var isLoading = false +} +``` + +This applies to **any** property wrapper used inside an `@Observable` class, including but not limited to: +- `@AppStorage` +- `@SceneStorage` +- `@Query` (SwiftData) + +**Note**: Since `@ObservationIgnored` disables observation tracking for that property, SwiftUI won't detect changes through the Observation framework. However, property wrappers like `@AppStorage` already notify SwiftUI of changes through their own mechanisms (e.g., UserDefaults KVO), so views still update correctly. + +**Never remove `@ObservationIgnored`** from property-wrapper properties in `@Observable` classes — doing so causes a compiler error. + +## Make @Observable Property Types Equatable + +The `@Observable` macro generates a setter that **skips invalidation when the new value equals the current one** — but only when it can compare them, which means only when the property's type is `Equatable`. Without that conformance, every assignment notifies observing views, even when the value is identical. This is an easy win for properties written frequently with the same value (polling, streaming updates, timers). + +```swift +// AVOID: not Equatable — every assignment invalidates, even no-op writes +enum DeliveryStatus { case placed, preparing, shipped, delivered } + +// PREFER: Equatable lets the generated setter short-circuit redundant writes +enum DeliveryStatus: Equatable { case placed, preparing, shipped, delivered } +``` + +This applies to collection properties too: an `Array`/`Set`/`Dictionary` is only `Equatable` when its element type is, so a non-`Equatable` element defeats the short-circuit for the whole collection. (The check is emitted into the generated setter as user code, so it applies on every OS that supports `@Observable` when built with current Xcode.) + +This is distinct from `Equatable` *views* (see `references/performance-patterns.md`): that conformance lets SwiftUI skip a view's body; this one lets the model skip notifying observers in the first place. + +## @Observable Dependency Granularity + +Observation tracks reads at the **property** level, not the field level — so reading any part of a compound property establishes a dependency on the whole thing. Three common traps and their fixes: + +- **A computed property establishes dependencies transitively.** `var currentUser: User? { users.first { $0.id == currentID } }` reads `users` in its body, so any view reading `currentUser` depends on the entire `users` array. Renaming the access doesn't change what observation tracks. +- **A struct-typed stored property drags the whole struct.** A view reading `session.user.name` depends on `session.user`; editing any other field of `user` invalidates it. +- **An array/collection read drags the whole collection.** Reading one element establishes a dependency on the entire stored collection. +- **A row that receives the parent model plus an index subscribes too broadly.** The list that owns the `ForEach` legitimately depends on the collection. A row that looks up `state.users[index]` also depends on the entire collection, so editing one element invalidates every row. Pass the element (or the fields the row reads) directly. + +```swift +// PREFER: cache derived values as stored properties, kept in sync in didSet +@MainActor @Observable +final class AppState { + var users: [User] = [] { didSet { recomputeCurrentUser() } } + var currentID: User.ID? { didSet { recomputeCurrentUser() } } + + private(set) var currentUser: User? + private func recomputeCurrentUser() { currentUser = users.first { $0.id == currentID } } +} +``` + +For struct-typed properties, expose the fields the views actually read as individual properties on the model (each is then tracked separately). If the struct must remain round-trippable (re-encoded to a payload), keep both: a stored `var user: User` for the original shape and the flattened properties for view consumption, kept in sync in `didSet` on `user`. When many rows each observe several fields of their element, model each element as its own `@Observable` and have the parent **persist** the instances — see the per-item view model pattern in `references/performance-patterns.md`. Reading several already-narrow properties from one model is fine and does not need splitting. + +## @Binding + +Use only when child view needs to **modify** parent's state. If child only reads the value, use `let` instead. + +```swift +// Parent +struct ParentView: View { + @State private var isSelected = false + + var body: some View { + ChildView(isSelected: $isSelected) + } +} + +// Child - will modify the value +struct ChildView: View { + @Binding var isSelected: Bool + + var body: some View { + Button("Toggle") { + isSelected.toggle() + } + } +} +``` + +### When NOT to use @Binding + +- **Don't use `@Binding` for read-only values.** If the child only displays the value and never modifies it, use `let` instead. `@Binding` adds unnecessary overhead and implies a write contract that doesn't exist. + +### Declare a Binding with @Binding, Not a Plain Property + +A binding you react to must be `@Binding var x: T`. SwiftUI subscribes only to `DynamicProperty` properties (`@State`, `@Binding`, `@Environment`, …); a binding held in an undecorated property (`let x: Binding`) is just a value it never looks inside, so external changes to the bound value don't re-evaluate the view. + +```swift +struct SelectionBadge: View { + // let selection: Binding // WRONG - untracked; external changes missed + @Binding var selection: Item? // CORRECT - DynamicProperty, tracked + + var body: some View { Text(selection?.name ?? "None") } +} +``` + +Debug builds can mask this with extra graph passes, so it often fails only in Release. It bites hardest in `UIViewRepresentable`/`NSViewRepresentable`, where the missing re-evaluation means `updateUIView(_:context:)` never runs (e.g. a presented controller that won't dismiss when its bound item is reset). + +### Prefer KeyPath Bindings Over Closure Bindings + +When you need a binding into a model, prefer a KeyPath/subscript-based binding over a hand-written `Binding(get:set:)` closure. A closure binding allocates a new closure each time `body` runs and can't be compared, which can trigger unnecessary invalidations. + +```swift +// BAD - closure binding: heap allocation each body pass, defeats comparison +let binding = Binding( + get: { model[scoreFor: player] }, + set: { model[scoreFor: player] = $0 } +) +PlayerScoreRow(player: player, score: binding) + +// GOOD - project through a subscript with @Bindable +@Bindable var model = model +PlayerScoreRow(player: player, score: $model[scoreFor: player]) +``` + +If no suitable subscript exists, add one (a labeled subscript reads as a clean projection into the model). Reserve closure bindings for cases where no key path or subscript can express the transform. + +For an argumentless projection, use a computed property. A marker-enum subscript (`$model[playback: .isPlaying]`) is ceremony around a property that takes no arguments: + +```swift +// AVOID: marker enum dresses up an argumentless projection +fileprivate subscript(playback _: PlaybackProjection) -> Bool { + get { rate > 0 } + set { rate = newValue ? 1 : 0 } +} +Toggle("Play", isOn: $model[playback: .isPlaying]) + +// PREFER: computed property +var isPlaying: Bool { + get { rate > 0 } + set { rate = newValue ? 1 : 0 } +} +Toggle("Play", isOn: $model.isPlaying) +``` + +## @FocusState + +See `references/focus-patterns.md` for comprehensive focus management guidance including `@FocusState`, `@FocusedValue`, `.focusable()`, default focus, and common pitfalls. + +Always mark `@FocusState` as `private`. + +## @StateObject vs @ObservedObject (Legacy - Pre-iOS 17) + +**Note**: Always prefer `@Observable` with `@State` for iOS 17+. + +The key distinction is **ownership**: `@StateObject` when the view **creates and owns** the object; `@ObservedObject` when the view **receives** it from outside. + +```swift +// View creates it → @StateObject +@StateObject private var viewModel = MyViewModel() + +// View receives it → @ObservedObject +@ObservedObject var viewModel: MyViewModel +``` + +**Never** create an `ObservableObject` inline with `@ObservedObject` -- it recreates the instance on every view update. + +### @StateObject instantiation in View's initializer + +Prefer storing the `@StateObject` in the parent view and passing it down. If you must create one in a custom initializer, pass the expression directly to `StateObject(wrappedValue:)` so the `@autoclosure` prevents redundant allocations: + +```swift +// Inside a View's init(movie:): +// WRONG — assigning to a local first defeats @autoclosure +let vm = MovieDetailsViewModel(movie: movie) +_viewModel = StateObject(wrappedValue: vm) + +// CORRECT — inline expression defers creation +_viewModel = StateObject(wrappedValue: MovieDetailsViewModel(movie: movie)) +``` + +**Modern Alternative**: Use `@Observable` with `@State` instead. + +## Don't Store Parent-Owned Inputs as @State + +Do not declare a changing parent-owned input as `@State` or `@StateObject`. State accepts an initial value and then remains owned by the child, so subsequent parent updates are ignored. + +```swift +// WRONG - child ignores parent updates +struct ChildView: View { + @State var item: Item // Shows initial value forever! + var body: some View { Text(item.name) } +} + +// CORRECT - child receives updates +struct ChildView: View { + let item: Item // Or @Binding if child needs to modify + var body: some View { Text(item.name) } +} +``` + +Mark `@State` and `@StateObject` as `private` so they do not appear in a generated initializer. A custom initializer may intentionally seed private, view-owned state once; make that ownership explicit and do not expect later argument changes to replace the state. See [SDK 27 `@State` Macro](#sdk-27-state-macro) for initialization diagnostics. + +## @Bindable (iOS 17+) + +Use when receiving an `@Observable` object from outside and needing bindings: + +```swift +@Observable +final class UserModel { + var name = "" + var email = "" +} + +struct ParentView: View { + @State private var user = UserModel() + + var body: some View { + EditUserView(user: user) + } +} + +struct EditUserView: View { + @Bindable var user: UserModel // Received from parent, needs bindings + + var body: some View { + Form { + TextField("Name", text: $user.name) + TextField("Email", text: $user.email) + } + } +} +``` + +## Passed Value Inputs + +Use `let` for read-only values passed from a parent. A view can still observe replacement values with `.onChange`; the property does not need to be `var`. + +```swift +struct ProfileHeader: View { + let username: String + let avatarURL: URL + + var body: some View { + HStack { + AsyncImage(url: avatarURL) + Text(username) + } + } +} +``` + +### Pass only the fields a view reads + +SwiftUI compares value-type inputs field by field. A child that accepts an entire struct can re-evaluate when any field changes, even if its body displays only one field. Passing a large value can also make comparison walk nested fields and collections. + +```swift +// AVOID: unrelated User changes can invalidate AvatarBadge. +struct AvatarBadge: View { + let user: User + + var body: some View { + AsyncImage(url: user.avatarURL) + } +} + +// PREFER: the input matches what the view reads. +struct AvatarBadge: View { + let avatarURL: URL + + var body: some View { + AsyncImage(url: avatarURL) + } +} +``` + +This rule primarily applies to value types. Class references compare by identity; an `@Observable` class additionally tracks the individual properties read during `body`. Compound properties still have broad granularity: reading one element of an observed array or one field of an observed struct establishes a dependency on that whole stored property. + +## Isolate Side-Effect-Only Dependencies + +An `.onChange(of:)` expression reads its value in the enclosing view's body scope. If a dependency exists only to trigger a side effect, every change still re-evaluates that view's body. + +For a non-trivial parent, consider moving the dependency and `.onChange` into a focused `ViewModifier`. This gives the side effect its own invalidation boundary: + +```swift +private struct CounterSyncModifier: ViewModifier { + @Environment(\.counter) private var counter + let model: Model + + func body(content: Content) -> some View { + content.onChange(of: counter) { + model.counter = counter + } + } +} +``` + +Do not add this indirection when the dependency also affects rendering or the parent body is already trivial; it would not reduce meaningful work. + +## Environment + +For custom environment values, `@Entry`, focused values, stable defaults, and invalidation costs, consult `references/environment-patterns.md`. + +## Decision Flowchart + +``` +Is this value owned by this view? +├─ YES: Is it a simple value type? +│ ├─ YES → @State private var +│ └─ NO (class): +│ ├─ Use @Observable → @State private var (mark class @MainActor) +│ └─ Legacy ObservableObject → @StateObject private var +│ +└─ NO (passed from parent): + ├─ Does child need to MODIFY it? + │ ├─ YES → @Binding var + │ └─ NO: Does child need BINDINGS to its properties? + │ ├─ YES (@Observable) → @Bindable var + │ └─ NO: Does child react to changes? + │ ├─ YES → let + .onChange() + │ └─ NO → let + │ + └─ Is it a legacy ObservableObject from parent? + └─ YES → @ObservedObject var (consider migrating to @Observable) +``` + +## State Privacy Rules + +**All view-owned state should be `private`:** + +```swift +// Correct - clear what's created vs passed +struct MyView: View { + // Created by view - private + @State private var isExpanded = false + @State private var viewModel = ViewModel() + @AppStorage("theme") private var theme = "light" + @Environment(\.colorScheme) private var colorScheme + + // Passed from parent - not private + let title: String + @Binding var isSelected: Bool + @Bindable var user: User + + var body: some View { + // ... + } +} +``` + +**Why**: This makes dependencies explicit and improves code completion for the generated initializer. + +## Avoid Nested ObservableObject + +**Note**: This limitation only applies to `ObservableObject`. `@Observable` fully supports nested observed objects. + +SwiftUI can't track changes through nested `ObservableObject` properties. Workaround: pass the nested object directly to child views as `@ObservedObject`. With `@Observable`, nesting works automatically. + +## Key Principles + +1. **Always prefer `@Observable` over `ObservableObject`** for new code +2. **Mark `@Observable` classes with `@MainActor` for thread safety (unless using default actor isolation)`** +3. Use `@State` with `@Observable` classes (not `@StateObject`) +4. Use `@Bindable` for injected `@Observable` objects that need bindings +5. **Always mark `@State` and `@StateObject` as `private`** +6. Do not store changing parent-owned inputs as `@State` or `@StateObject`; use private state only for intentional child ownership +7. With `@Observable`, nested objects work fine; with `ObservableObject`, pass nested objects directly to child views +8. **Always add `@ObservationIgnored` to property wrappers** (e.g., `@AppStorage`, `@SceneStorage`, `@Query`) inside `@Observable` classes — they conflict with the macro's property transformation +9. **Prefer `Equatable` types for frequently-written `@Observable` properties** so the generated setter skips redundant invalidations +10. Pass value-type views only the fields they read +11. Isolate side-effect-only dependencies when they would invalidate an expensive parent +12. Follow `references/environment-patterns.md` for custom environment and focused values +13. **Prefer KeyPath/subscript bindings over closure bindings**; use a computed property, not a marker-enum subscript, for argumentless projections +14. **Declare a binding you react to as `@Binding`, not a plain `Binding`-typed property** — a plain property isn't tracked, so external changes won't re-evaluate the view (often a Release-only failure) +15. Do not pass a parent `@Observable` plus an index into a row; pass the element or the fields the row reads diff --git a/.cursor/skills/swiftui-expert-skill/references/styled-text-editing.md b/.cursor/skills/swiftui-expert-skill/references/styled-text-editing.md new file mode 100644 index 00000000..09f083d2 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/styled-text-editing.md @@ -0,0 +1,198 @@ +# Styled Text Editing + +> Attributed `TextEditor`, `AttributedTextSelection`, and `AttributedTextFormattingDefinition` require iOS 26, macOS 26, or visionOS 26. `TextEditor` itself is **unavailable on watchOS and tvOS**. For the verbatim-vs-localized decision on `Text`, see `references/text-patterns.md`. + +## Table of Contents + +- [Attributed TextEditor](#attributed-texteditor) +- [AttributedTextSelection](#attributedtextselection) +- [Reading Attributes at the Selection](#reading-attributes-at-the-selection) +- [Transforming Attributes](#transforming-attributes) +- [Resolving Fonts](#resolving-fonts) +- [Editing Text While Keeping the Selection Valid](#editing-text-while-keeping-the-selection-valid) +- [Formatting Definitions](#formatting-definitions) +- [Text and Markdown](#text-and-markdown) + +--- + +## Attributed TextEditor + +`TextEditor` has three initializers. The attributed one accepts an optional selection binding: + +```swift +TextEditor(text: Binding) // iOS 14+ +TextEditor(text: Binding, selection: Binding) // iOS 18+ +TextEditor(text: Binding, selection: Binding? = nil) // iOS 26+ +``` + +Binding an `AttributedString` gives you a rich-text editor with no extra work — the system handles bold, italic, and the standard formatting commands. + +```swift +struct RichTextEditor: View { + @State private var text = AttributedString("Editable styled text") + @State private var selection = AttributedTextSelection() + + var body: some View { + TextEditor(text: $text, selection: $selection) + } +} +``` + +Pass a selection binding whenever you need custom formatting controls; without it you cannot read or transform what the person has selected. + +## AttributedTextSelection + +`AttributedTextSelection` is an opaque `Equatable, Sendable` value. Resolve it against the text to inspect it: + +```swift +switch selection.indices(in: text) { +case .insertionPoint(let index): + // caret only, no characters selected +case .ranges(let rangeSet): + // one or more selected ranges +} +``` + +Initializers: `init()`, `init(range:)`, `init(ranges:)`, and `init(insertionPoint:typingAttributes:)`. `affinity(in:)` returns a `TextSelectionAffinity` for the caret's direction. + +Because a selection can hold a `RangeSet` (discontiguous ranges), don't assume a single `Range`. Use the provided helpers rather than reaching for the indices directly. + +## Reading Attributes at the Selection + +`typingAttributes(in:)` returns the `AttributeContainer` that would apply to newly typed text. This is what you want for driving control state, since it works for a bare insertion point as well as a range: + +```swift +private var selectionColor: Color { + selection.typingAttributes(in: text).foregroundColor ?? .primary +} +``` + +`attributes(in:)` returns a `Sequence` of `AttributeContainer` values covering the selection, with a subscript for a single key. Use it to detect mixed values across a selection: + +```swift +let underlines = selection.attributes(in: text)[\.underlineStyle] +let allUnderlined = underlines.allSatisfy { $0 != nil } +``` + +## Transforming Attributes + +`AttributedString.transformAttributes(in:body:)` is the mutating entry point for formatting commands. It takes the selection `inout` and hands you an `inout AttributeContainer` to modify: + +```swift +private func toggleUnderline() { + text.transformAttributes(in: &selection) { container in + container.underlineStyle = container.underlineStyle == nil ? .single : nil + } +} + +private func setColor(_ color: Color) { + text.transformAttributes(in: &selection) { container in + container.foregroundColor = color + } +} +``` + +When the selection is an insertion point, the change applies to the typing attributes instead of any characters, so the next typed character picks up the formatting. That is why the selection is `inout` — the transform updates it. + +## Resolving Fonts + +`Font` values are declarative and may be relative (`.body`, `.headline`), so you cannot read a weight or an italic flag off them directly. Read `\.fontResolutionContext` from the environment (a `Font.Context`) and call `resolve(in:)` to get a `Font.Resolved` with concrete `isBold`, `isItalic`, and `weight`: + +```swift +struct FormattingBar: View { + @Binding var text: AttributedString + @Binding var selection: AttributedTextSelection + @Environment(\.fontResolutionContext) private var fontResolutionContext + + var body: some View { + HStack { + Button("Bold", systemImage: "bold") { toggleBold() } + Button("Italic", systemImage: "italic") { toggleItalic() } + } + } + + private func toggleBold() { + text.transformAttributes(in: &selection) { container in + let font = container.font ?? .default + let resolved = font.resolve(in: fontResolutionContext) + container.font = font.bold(!resolved.isBold) + } + } + + private func toggleItalic() { + text.transformAttributes(in: &selection) { container in + let font = container.font ?? .default + let resolved = font.resolve(in: fontResolutionContext) + container.font = font.italic(!resolved.isItalic) + } + } +} +``` + +Note the `?? .default` fallback: an unstyled run has no `font` attribute at all. Resolving the context (rather than assuming `.body`) keeps the toggle correct under Dynamic Type and inherited font modifiers. + +## Editing Text While Keeping the Selection Valid + +Mutating an `AttributedString` invalidates indices, which can leave a stored selection pointing at the wrong place. Use the selection-aware replacement APIs so SwiftUI updates the selection with the text: + +```swift +text.replaceSelection(&selection, with: AttributedString("replacement")) +text.replaceSelection(&selection, withCharacters: "plain replacement") +``` + +Foundation's `transform(updating:)` accepts attributed-string index ranges, not `AttributedTextSelection`. Use it only after resolving and managing those ranges yourself. + +## Formatting Definitions + +An `AttributedTextFormattingDefinition` constrains which attribute values an editor accepts, so pasted or system-applied formatting is normalized instead of rejected ad hoc. The protocol has a `Scope` (an `AttributeScope`) and a `body` built from constraints: + +```swift +struct BrandFormatting: AttributedTextFormattingDefinition { + struct Scope: AttributeScope { + let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute + let font: AttributeScopes.SwiftUIAttributes.FontAttribute + } + + var body: some AttributedTextFormattingDefinition { + ValueConstraint(for: \.foregroundColor, values: [.primary, .brandRed], default: .primary) + } +} +``` + +`ValueConstraint` takes a key path (or attribute type), a `Set` of allowed values, and a default that replaces anything outside the set. Custom constraints conform to `AttributedTextValueConstraint` and implement `constrain(_:)`, which receives a mutable proxy over the attribute container. + +Apply a definition with `attributedTextFormattingDefinition(_:)`. Overloads also accept a bare `AttributeScope` type or a `KeyPath` when you only want to limit *which* attributes survive: + +```swift +TextEditor(text: $text, selection: $selection) + .attributedTextFormattingDefinition(BrandFormatting()) +``` + +The modifier is `attributedTextFormattingDefinition(_:)` — not `textFormattingDefinition(_:)`. + +Attributes outside the scope are dropped from the editor's text, which is the mechanism that keeps a document's attribute set closed. Restricting the scope also means your persistence layer only ever sees attributes you declared. + +## Text and Markdown + +`Text` accepts Markdown in a localized string literal, but only an inline subset. Interpolating a `String` variable bypasses both localization and Markdown parsing. + +```swift +Text("This is **bold** and *italic*") +Text("Visit [Apple](https://www.apple.com)") +``` + +Supported: emphasis, strong emphasis, strikethrough, inline code, and links. **Not** supported: headings, lists, block quotes, code blocks, tables, images, and hard or soft line breaks — `Text` parses with `inlineOnlyPreservingWhitespace`, so a `#` or `-` renders literally and a newline in the literal is preserved as whitespace rather than becoming a break. + +For block-level Markdown, parse it yourself into `AttributedString` with a full `AttributedString.MarkdownParsingOptions` configuration and lay the blocks out as separate views. + +For read-only styled content, build an `AttributedString` and hand it to `Text`: + +```swift +var styled = AttributedString("Red and Blue") +if let range = styled.range(of: "Red") { + styled[range].foregroundColor = .red +} +Text(styled) +``` + +Prefer `foregroundStyle(_:)` over `foregroundColor(_:)` on `Text`; the former accepts any `ShapeStyle`, including gradients and materials. diff --git a/.cursor/skills/swiftui-expert-skill/references/text-patterns.md b/.cursor/skills/swiftui-expert-skill/references/text-patterns.md new file mode 100644 index 00000000..351097f9 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/text-patterns.md @@ -0,0 +1,36 @@ +# SwiftUI Text Patterns Reference + +> For broader localization guidance — String Catalogs, `#bundle` for packages, `LocalizedStringResource`, locale-aware formatting, RTL layout, and translator comments — see `references/localization.md`. This file covers only the verbatim-vs-localized decision for a single `Text`. +> +> For rich text — attributed `TextEditor` with `AttributedTextSelection`, `transformAttributes(in:)`, font resolution, formatting definitions, and the Markdown subset `Text` supports — see `references/styled-text-editing.md`. + +## Table of Contents + +- [Text Initialization: Verbatim vs Localized](#text-initialization-verbatim-vs-localized) + +## Text Initialization: Verbatim vs Localized + +**Default: always use `Text("…")`.** Only use `Text(verbatim:)` when explicitly required for a string literal that must not be localized. + +```swift +// Localized literal - "Save" is used as the localization key and looked up in Localizable.strings (only if one exists in the project) +Text("Save") + +// String variable - bypasses localization automatically; no verbatim needed +let filename: String = model.exportFilename +Text(filename) + +// Non-localized literal - use verbatim only when the literal must not be localized +Text(verbatim: "pencil") +``` + +### Decision Flow + +``` +Is the input a String variable or dynamic value? +└─ YES → Text(variable) // bypasses localization automatically + +Is the string literal intended for localization? +├─ YES → Text("…") // default; key looked up in Localizable.strings +└─ NO → Text(verbatim: "…") // only when explicitly non-localized +``` diff --git a/.cursor/skills/swiftui-expert-skill/references/toolbar-patterns.md b/.cursor/skills/swiftui-expert-skill/references/toolbar-patterns.md new file mode 100644 index 00000000..a2e8b71f --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/toolbar-patterns.md @@ -0,0 +1,171 @@ +# SwiftUI Toolbar Patterns + +## Customizable toolbars (iOS 26+, macOS 26+, tvOS 26+, watchOS 26+, visionOS 26+) + +Use `.toolbar(id:)` when people should be able to add, remove, or rearrange toolbar content. Every customizable `ToolbarItem` needs a stable, unique string ID. Keep IDs tied to the meaning of the action rather than to a changing array position. + +```swift +.toolbar(id: "main-toolbar") { + ToolbarItem(id: "tag") { + TagButton() + } + ToolbarItem(id: "share") { + ShareButton() + } + ToolbarSpacer(.fixed) + ToolbarItem(id: "more") { + MoreButton() + } +} +``` + +`ToolbarItem(id:placement:content:)` is available on iOS 14+, macOS 11+, tvOS 14+, and watchOS 7+ (visionOS 1+). The `showsByDefault:` overload is soft-deprecated in the SDK 27 toolchain; use `defaultCustomization(_:options:)` instead. + +`ToolbarSpacer` separates groups of toolbar content. `ToolbarSpacer(.fixed)` creates a fixed-width gap; `ToolbarSpacer(.flexible)` expands to push content apart. The initializer also accepts a placement. `ToolbarSpacer` is available on iOS 26+ and macOS 26+; it is unavailable on tvOS, watchOS, and visionOS. + +## System-defined toolbar content + +`DefaultToolbarItem` places a system-defined item, such as search or the sidebar toggle, at a chosen placement. It is available on iOS 26+, macOS 26+, tvOS 26+, watchOS 26+, and visionOS 26+. + +```swift +.toolbar { + DefaultToolbarItem(kind: .search, placement: .bottomBar) + DefaultToolbarItem(kind: .sidebarToggle, placement: .navigationBarLeading) +} +``` + +The `.search` kind is available on iOS, macOS, and visionOS 26+; it is unavailable on tvOS and watchOS. The `.sidebarToggle` kind is available on every platform supported by `DefaultToolbarItem`. + +The `.largeSubtitle` placement supplies content in the large navigation-title subtitle area. It takes precedence over the value supplied by `navigationSubtitle(_:)`. It is available on iOS 26+ only. + +On iOS 26+ and macOS 26+, use `sharedBackgroundVisibility(.hidden)` on the `ToolbarItem` when one item should not participate in the shared Liquid Glass background. It is unavailable on tvOS, watchOS, and visionOS. Apply `badge(_:)` to the item's view to show an indicator: + +```swift +.toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Notifications", systemImage: "bell") { } + .badge(unreadCount) + } + ToolbarItem(placement: .topBarTrailing) { + ProfileButton() + } + .sharedBackgroundVisibility(.hidden) +} +``` + +## Search + +Use `.searchToolbarBehavior(.minimize)` to opt into a compact, button-like search control that expands when selected. The modifier is available on all aligned 26 releases, but `.minimize` is available only on iOS and visionOS; use `.automatic` elsewhere. + +## Transitioning from toolbar controls + +Attach `matchedTransitionSource(id:in:)` to toolbar content that presents another view, then use a zoom navigation transition with the same ID and namespace in the destination. + +```swift +@Namespace private var namespace + +.toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Show details", systemImage: "info") { + isPresented = true + } + } + .matchedTransitionSource(id: "details", in: namespace) +} +.sheet(isPresented: $isPresented) { + DetailsView() + .navigationTransition(.zoom(sourceID: "details", in: namespace)) +} +``` + +The view `matchedTransitionSource(id:in:)` is available on iOS 18+, macOS 15+, tvOS 18+, watchOS 11+, and visionOS 2+. Its toolbar-content form is available on iOS 26+ and is unavailable on macOS, tvOS, watchOS, and visionOS. The zoom navigation transition is available on iOS 18+, macOS 15+, tvOS 18+, watchOS 11+, and visionOS 2+. + +## SDK 27 overflow and visibility + +When toolbar content does not fit, the system can move lower-priority items into an overflow menu. `visibilityPriority(_:)` is available on iOS 27+, macOS 26.1+, watchOS 27+, tvOS 27+, and visionOS 27+. `.automatic` is available on every supported platform. `.low` and `.high` are available only on iOS and macOS. `ToolbarItemVisibilityPriority(higherThan:)` and `(lowerThan:)` are available on iOS 27+ and macOS 27+. + +```swift +.toolbar { + ToolbarItemGroup { + UndoButton() + RedoButton() + } + .visibilityPriority(.high) +} +``` + +`ToolbarOverflowMenu` is toolbar content whose children always appear in the overflow menu. The `View.toolbarOverflowMenu { ... }` modifier provides the same behavior outside a toolbar builder. Both are available on iOS 27+ and visionOS 27+ only. The type and modifier have different syntaxes: + +```swift +.toolbar { + ToolbarOverflowMenu { + ExportButton() + ClearButton() + } +} + +content + .toolbarOverflowMenu { + ExportButton() + ClearButton() + } +``` + +`.topBarPinnedTrailing` keeps a `ToolbarItem` at the trailing edge and prevents it from moving into overflow. It is available on iOS 27+ and visionOS 27+ only. + +For deployment targets below SDK 27, prefer one availability check around the toolbar content when using these APIs: + +```swift +.toolbar { + if #available(iOS 27, *) { + ToolbarItem(placement: .topBarPinnedTrailing) { + ShareButton() + } + ToolbarOverflowMenu { + ExportButton() + } + } else { + ToolbarItem { + ShareButton() + } + } +} +``` + +## Minimization, margins, and status bar + +`toolbarMinimizationBehavior(_:for:)`, `toolbarMinimizationSafeAreaAdjustment(_:for:)`, and `toolbarMinimizationRestoration(_:for:)` are available on all Apple platforms in SDK 27. The behavior cases `.automatic`, `.onScrollDown`, `.onScrollUp`, and `.never` and the safe-area cases `.automatic`, `.enabled`, and `.disabled` have platform-specific availability; use `.automatic` for cross-platform code and gate iOS-only cases as needed. + +```swift +ScrollView { + Content() +} +.toolbarMinimizationBehavior(.onScrollDown, for: .navigationBar) +.toolbarMinimizationSafeAreaAdjustment(.automatic, for: .navigationBar) +``` + +`contentMarginsRemoved(_:)` removes the system margins around toolbar content. It is available on iOS 27+, macOS 27+, tvOS 27+, watchOS 27+, and visionOS 27+. + +On iOS 27+, `ToolbarPlacement.statusBar` can be passed to `toolbarVisibility(_:for:)` to control status-bar visibility. It is iOS-only; keep `statusBarHidden(_:)` in an earlier deployment fallback and do not suggest a replacement on visionOS, where there is no status bar. + +```swift +.toolbarVisibility(.hidden, for: .statusBar) +``` + +## Dynamic toolbar content + +`ForEach` conforms to `ToolbarContent` when built with the SDK 27 toolchain and back-deploys to iOS 16+, macOS 13+, watchOS 9+, tvOS 16+, and visionOS 1+. Give the collection elements stable identity. `EmptyView` as toolbar content requires iOS 27+, macOS 27+, tvOS 27+, watchOS 27+, and visionOS 27+. + +```swift +.toolbar { + ForEach(actions) { action in + ToolbarItem { + Button(action.title) { + action.perform() + } + } + } +} +``` + +For broader Liquid Glass styling, see [liquid-glass.md](liquid-glass.md). For macOS window and toolbar concerns, keep platform-specific guidance in the macOS references. diff --git a/.cursor/skills/swiftui-expert-skill/references/trace-analysis.md b/.cursor/skills/swiftui-expert-skill/references/trace-analysis.md new file mode 100644 index 00000000..98f279f6 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/trace-analysis.md @@ -0,0 +1,295 @@ +# Instruments Trace Analysis + +Use this reference whenever the user references an Xcode Instruments `.trace` +file. A target SwiftUI source file is **optional** — if provided, you can +cite specific lines; without one, the trace still surfaces view names, +hot symbols, and high-severity events that tell the user where to look. + +The bundled parser reads five lanes for SwiftUI responsiveness (Time +Profiler, Hangs, Animation Hitches, SwiftUI updates, and the SwiftUI +cause graph) and exposes three discovery modes (`--list-logs`, +`--list-signposts`, `--fanin-for`) plus a `--window` flag so the agent +can focus analysis on a precise slice of the trace. + +## When to invoke + +Any of these signals: + +- Message contains a path ending in `.trace`. +- User mentions "hangs", "hitches", "jank", "slow view", or performance + issues alongside an Instruments recording. +- User asks to focus analysis "after / before / between / during" a log + message or signpost. + +Triggering does **not** require a SwiftUI source file. If one is present +you'll ground recommendations in specific lines; if not, base them on the +view names and symbols the trace reveals. + +## The three CLI modes + +The scripts live alongside this skill at `scripts/` and need only the +Python 3 stdlib + `xctrace` (ships with Xcode at `/usr/bin/xctrace`). + +### 1. Full analysis (default) + +```bash +python3 "${SKILL_DIR}/scripts/analyze_trace.py" \ + --trace "/path/to/file.trace" \ + --top 10 --top-hitches 5 \ + [--window START_MS:END_MS] \ + --json-only +``` + +- `--json-only` gives you structured data; omit for JSON + markdown + summary; `--markdown-only` is for pasting a digest into the chat. +- `--output ` writes `.json` and `.md` instead of stdout. +- `--window START_MS:END_MS` (optional) restricts every lane and every + correlation to that time slice. +- `--run N` selects a specific run when the trace contains more than one + recording session. Single-run traces don't need it; multi-run traces + require it and will error with the available run numbers if omitted. + Use `--list-runs` to dump per-run metadata (template, duration, + start/end dates, schemas) before analyzing. + +### 2. `--list-logs` — find os_log timestamps + +```bash +python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace --list-logs \ + [--log-subsystem com.myapp.net] \ + [--log-category "Network"] \ + [--log-type Fault] \ + [--log-message-contains "loaded feed"] \ + [--log-limit 10] \ + [--window START_MS:END_MS] +``` + +Returns JSON `{ "logs": [...], "count": N }` where each log entry includes +`time_ms`, `type`, `subsystem`, `category`, `process`, and the formatted +`message` (with args substituted) + raw `format_string`. All filters are +AND-combined; `--log-message-contains` is case-insensitive substring match. + +### 3. `--list-signposts` — find signpost intervals + +```bash +python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace --list-signposts \ + [--signpost-name-contains "ImageDecode"] \ + [--signpost-subsystem com.myapp.feed] \ + [--signpost-category "Rendering"] \ + [--window START_MS:END_MS] +``` + +Returns JSON `{ "intervals": [...], "events": [...] }`. Intervals are +paired `begin`/`end` signposts with `start_ms`, `end_ms`, `duration_ms`, +`name`, `subsystem`, `category`, `process`, `signpost_id`. Single-point +events (and any unpaired begins) go into `events`. All filters are +AND-combined; `--signpost-name-contains` is case-insensitive substring +match. + +### 4. `--fanin-for` — who keeps invalidating this view? + +```bash +python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace \ + --fanin-for "TextStyleModifier" \ + [--window START_MS:END_MS] \ + [--top 10] +``` + +Returns JSON `{ "matches": [...] }`. Each match names a destination node +whose fmt string contains the substring (case-insensitive) and lists its +top incoming source nodes ranked by edge count. Use this after the +`swiftui` lane names an expensive view and you want to know *why it keeps +being invalidated*. For the example above, the top source is +`closure #1 in UserDefaultObserver.Target.GraphAttribute.send()` — the +canonical signature of an `@AppStorage` / `UserDefaults` feedback storm. + +## Composition pattern — scoping to a slice + +When the user says something like "focus on X", "between A and B", or +"during signpost Y", compose the three modes: + +1. **Discover** — call `--list-logs` or `--list-signposts` with filters + that match the user's description. Pick the right entries. +2. **Build the window** — take `time_ms` (logs) or `start_ms`/`end_ms` + (intervals) and form `--window START:END`. +3. **Analyse** — call the default mode with `--window`. + +Examples: + +- *"Focus on the section after the log saying 'loaded feed'."* + → `--list-logs --log-message-contains "loaded feed"`, take the entry's + `time_ms`, set window = `[that_ms, end_of_trace_ms]` (or use the trace + `duration_s × 1000`). +- *"Between the 'begin-sync' log and the 'done-sync' log."* + → Two `--list-logs` calls (or one with a broader filter), pick the two + timestamps, set window = `[first, second]`. +- *"During the signpost 'ImageDecode'."* + → `--list-signposts --signpost-name-contains "ImageDecode"`, pick the + interval, set window = `[start_ms, end_ms]`. + +## JSON shape + +```json +{ + "trace": "...", + "xctrace_version": "26.4 (...)", + "template": "SwiftUI", + "duration_s": 14.83, + "schemas_available": [...], + "lanes": [ + { "lane": "time-profiler", "available": true, "schema_used": "time-profile", + "metrics": { "total_samples": N, "total_weight_ms": ms, "processes": [...] }, + "top_offenders": [ { "symbol", "weight_ms", "percent", "samples", "thread" } ] }, + { "lane": "hangs", "available": true, "schema_used": "potential-hangs", + "metrics": { "count", "total_duration_ms", "worst_duration_ms", + "severity_buckets": {"lt_250ms","250ms_1s","gt_1s"} }, + "top_offenders": [ { "start_ms", "duration_ms", "hang_type", "thread" } ] }, + { "lane": "hitches", "available": true, "schema_used": "hitches", + "metrics": { "count", "total_hitch_ms", "worst_hitch_ms", + "narrative_breakdown": {...}, "system_hitches", "app_hitches" }, + "top_offenders": [ { "start_ms", "hitch_duration_ms", "narrative", "is_system" } ] }, + { "lane": "swiftui", "available": true, "schemas_used": [...], + "metrics": { "total_events", "unique_views", "total_duration_ms", + "severity_breakdown": {"Very Low":N,"Moderate":N,"High":N}, + "update_type_breakdown": {"View Body Updates":N, ...} }, + "top_offenders": [ { "view", "total_ms", "count", "avg_ms" } ], + "high_severity_events": [ { "view", "severity", "duration_ms", "category", + "update_type", "description" } ] }, + { "lane": "swiftui-causes", "available": true, "schema_used": "swiftui-causes", + "metrics": { "total_edges", "unique_sources", "unique_destinations", + "top_labels": {...} }, + "top_sources": [ { "source", "edges", "top_destinations": [...] } ], + "top_destinations": [ { "destination", "edges", "top_sources": [...] } ] } + ], + "correlations": [ + { + "trigger": { "lane": "hangs"|"hitches", "start_ms", "end_ms", "duration_ms", + "hang_type"|"frame_duration_ms" }, + "time_profiler_main_thread": { + "samples_in_window": N, "samples_on_main": M, + "main_running_coverage_pct": 0–100, + "hot_symbols": [ { "symbol", "samples", "weight_ms", "percent_of_main" } ] + }, + "swiftui_overlapping_updates": [ { "view", "duration_ms", "start_ms" } ] + } + ] +} +``` + +## Interpretation guide + +### `main_running_coverage_pct` is the key diagnostic + +Time Profiler samples the main thread every ~1ms. For a correlation window +of `N` ms, you'd expect ~`N` main-thread running samples if main were fully +CPU-bound. Coverage is the ratio of observed main-thread samples to that +expectation. + +- **< 25% coverage** → main thread was **blocked** (I/O, lock, sync XPC, + `Task.sleep`, waiting on an actor-isolated call). The `hot_symbols` you + do see are the moments main *was* executing — look there for the code + that *initiates* the blocking work, not the work itself. Common fix: + move to a background executor / `nonisolated` / `Task.detached`. +- **≥ 75% coverage** → main was **CPU-bound** the whole time. `hot_symbols` + point directly at the expensive work. Common fixes: hoist computation + out of view bodies, cache derived values, avoid per-frame allocation, + debounce `onChange`. +- **25–75%** → mix. Usually computation plus intermittent I/O; show both + hot symbols and note that main was partially blocked. + +### High-severity SwiftUI events → reference routing + +When `swiftui.high_severity_events[].description` is one of: + +| description | Likely cause | Route to | +|------------------|---------------------------|-------------------------------------| +| `onChange` | Expensive `.onChange` body | `references/performance-patterns.md`, `references/state-management.md` | +| `Gesture` | Heavy gesture handler | `references/performance-patterns.md` | +| `Action Callback`| Button/tap handler work | `references/performance-patterns.md` | +| `Update` | View body recomputation | `references/view-structure.md`, `references/performance-patterns.md` | +| `Creation` | View init cost | `references/view-structure.md` | +| `Layout` | GeometryReader churn | `references/layout-best-practices.md` | + +### Mapping trace findings to source code + +If the user gave you a specific file, use it to confirm/cite. If they didn't, the trace itself tells you which views and symbols to look up. + +1. **From `swiftui.top_offenders` and `high_severity_events`**, use the + `view` string as your search key. If a target file is open, grep it; + if not, recommend the user grep their project for that type or the + module name. A partial match (prefix / generic stripping) means it's + probably a subview. +2. **From `correlations[].time_profiler_main_thread.hot_symbols`**, treat + symbols starting with the user's module name (or in Swift free-function + form) as candidates. System frames (`swift_`, `dyld`, `objc_`, `CA*`, + `CF*`, `NS*`, `__open`, `pthread*`) identify *what* the code was doing + but the user-code caller one frame up is typically what to fix — say + so and, if you can, suggest searching the project for callers of the + equivalent Swift API (e.g. `__open` → `FileHandle` / `Data(contentsOf:)` / + `JSONDecoder.decode(from: Data)` sites). +3. **From `hitches[].narrative`**, Apple pre-attributes each hitch. The + string `"Potentially expensive app update(s)"` means SwiftUI blamed the + app (so user code is in scope); absence of narrative usually means it + was a system hitch or below the threshold. +4. **Correlating hitches with SwiftUI updates**: the + `swiftui_overlapping_updates` list on each hitch names the views that + were actively rendering when the frame dropped. Prioritise those. + +### Cause graph: finding *why* updates keep happening + +The `swiftui` lane tells you *what* is expensive; the `swiftui-causes` +lane tells you *why* it keeps being triggered. Each edge is "source node +propagated to destination node" in SwiftUI's attribute graph. + +Signatures to watch for in `top_sources`: + +- **`closure #1 in UserDefaultObserver.Target.GraphAttribute.send()`** — + an `@AppStorage` / `UserDefaults` write is fanning out to every reader. + If the destination list contains multiple `@AppStorage .` + entries with thousands of edges each, you have a feedback storm. Fix + by reading each key once at a high level and passing values down, or + wrapping settings in a single `@Observable` so only genuine readers + invalidate. Route to `references/state-management.md` and + `references/performance-patterns.md`. +- **`EnvironmentWriter: …`** with thousands of edges — a modifier (often + `.hoverEffect`, custom environment keys) is applied too widely and + being re-installed during every layout pass. Route to + `references/view-structure.md`. +- **`View Creation / Reuse`** as the #1 source — the hierarchy is + replacing children rather than mutating in place. Look for ID + instability (missing/unstable `.id(…)` on ForEach, type-erased + `AnyView` wrappers, conditional structure swaps). Route to + `references/list-patterns.md` and `references/view-structure.md`. + +When a specific view in `swiftui.high_severity_events` keeps showing up, +run `--fanin-for ""` to see the ranked list of sources +invalidating it. + +### Picking targets from a full-trace analysis + +Prioritise from most actionable to least: + +1. **Any `hangs` with `main_running_coverage_pct < 25%`** — these are + blocking-I/O smells; nearly always fixable by moving work off-main. +2. **Any `hangs` with `main_running_coverage_pct ≥ 75%`** — CPU-bound + main-thread work; fix the top `hot_symbols`. +3. **`swiftui-causes.top_sources` with > ~1k edges** — structural + invalidation bugs (feedback storms, over-applied modifiers). These + are often cheaper to fix than per-view optimisations and collapse + many downstream high-severity updates at once. +4. **`hitches` with `narrative == "Potentially expensive app update(s)"`** + and overlapping `swiftui_overlapping_updates` — specific views to + restructure. +5. **`swiftui.high_severity_events`** — `onChange`, `Gesture`, or `Action + Callback` with `duration_ms > ~16` are frame-dropping handlers. For + any that keep firing, run `--fanin-for` to find the source. +6. **`swiftui.top_offenders`** — heaviest views by total body time, even + without triggering hitches; candidates for view extraction or + memoisation (`equatable`, `@ViewBuilder` extraction). + +## Recommended output format for the user + +After running the parser, structure your response as: + +1. **One-line summary** — "Found N hangs, worst Wms; K hitches; J high-severity SwiftUI updates." +2. **Root-cause findings** — per prioritised target (see above), one paragraph with the trace evidence (coverage %, hot symbol, overlapping view) and a citation from `references/…` for the fix pattern. +3. **Plan** — numbered, file-specific edits. Cite line numbers in the user's Swift file when you know them. Don't edit the file unless the user asked for edits. diff --git a/.cursor/skills/swiftui-expert-skill/references/trace-recording.md b/.cursor/skills/swiftui-expert-skill/references/trace-recording.md new file mode 100644 index 00000000..fcfdd1d9 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/trace-recording.md @@ -0,0 +1,157 @@ +# Recording an Instruments Trace + +Use this reference when the user asks to record a new trace — either to +attach to a running app, launch one fresh, or capture a specific session +of actions they'll perform interactively. + +The bundled `scripts/record_trace.py` wraps `xctrace record` with: + +- The **SwiftUI** template by default (override with `--template`). +- **Manual stop** via Ctrl+C, a stop-file, or `--time-limit`. +- JSON discovery for devices and templates. +- Normal Python exit codes so an agent can orchestrate. +- Redacted command logging for values passed through `--env`. +- An explicit acknowledgement gate for system-wide recordings. + +## Privacy and consent + +Prefer `--attach` or `--launch`, which limits collection to the app being +diagnosed. A system-wide recording can capture activity and metadata from +unrelated applications. Before using `--all-processes`, explain that scope to +the user and obtain their explicit approval. Then pass +`--allow-system-wide-recording` to record that acknowledgement in the command. + +Values passed through `--env KEY=VALUE` are forwarded to `xctrace`, but the +wrapper redacts each value from its displayed command. Avoid placing secrets on +command lines when a safer launch configuration is available, because other +local process-inspection tools may still expose process arguments. + +## Typical flows + +### A) Attach to a running app on a connected device + +```bash +python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --device "Pol's iPhone" \ + --attach "Helm" \ + --output ~/Desktop/helm-session.trace +``` + +Leave it running while the user exercises the app. Stop with **Ctrl+C**. + +### B) Launch an app and record from the first frame + +```bash +python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --device "" \ + --launch "/path/to/App.app" \ + --output ~/Desktop/launch.trace +``` + +Useful for diagnosing cold-start hitches and view-creation cost. + +### C) Agent-driven: start in background, stop via stop-file + +When you (the agent) are running non-interactively — e.g. via +`Bash run_in_background` — use a stop-file so you can signal the +recording to end cleanly: + +```bash +# Start recording (background) +python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --attach Helm --stop-file /tmp/stop-trace \ + --output ~/Desktop/session.trace + +# ...user does their thing... + +# Stop cleanly (from another shell or tool call) +touch /tmp/stop-trace +``` + +The script polls every 0.5s for the stop-file, sends SIGINT to xctrace +when it appears, and waits up to 60s for the trace to finalise. + +### D) Time-boxed recording + +```bash +python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --attach Helm --time-limit 30s --output ~/Desktop/30s.trace +``` + +xctrace stops itself at the limit. + +## Discovery helpers + +```bash +# List every connected device, simulator, and the host — JSON. +python3 "${SKILL_DIR}/scripts/record_trace.py" --list-devices + +# List all Instruments templates — JSON with a flat list + by-section map. +python3 "${SKILL_DIR}/scripts/record_trace.py" --list-templates +``` + +Device entries have `kind` (`devices`, `devices offline`, `simulators`), +`name`, `os`, `udid`. Offline devices are known but unplugged / unpaired — +plug them in before recording. + +## Picking a template + +> **Hard rule: the `SwiftUI` template only populates the SwiftUI lane on a +> real device — a physical iOS/iPadOS device or the host Mac. On the iOS +> Simulator it records but the SwiftUI lane comes back empty.** If the +> chosen UDID falls under the `simulators` kind from `--list-devices`, +> switch to `Time Profiler`. It still gives you Time Profiler + Hangs + +> Animation Hitches, which `analyze_trace.py` analyses and correlates +> normally; only the `swiftui` lane will report `available: false`. + +Decision flow: + +| Target | Template to pass | +|----------------------------------------------|----------------------| +| Physical iOS/iPadOS device (connected) | `SwiftUI` (default) | +| Host Mac (macOS app, `--all-processes`, etc.)| `SwiftUI` (default) | +| iOS / iPadOS / watchOS / tvOS Simulator | `Time Profiler` | + +Always confirm the target kind with `--list-devices` before starting a +recording: entries under `simulators` mean you must switch to Time +Profiler; entries under `devices` (both connected devices and the host +Mac) support the SwiftUI template. Entries under `devices offline` need +the user to connect/unlock/trust the device before recording. + +For ad-hoc hang hunting on any target, `Time Profiler` or +`Animation Hitches` alone may be enough. + +For an explicitly approved system-wide recording: + +```bash +python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --all-processes --allow-system-wide-recording \ + --time-limit 30s --output ~/Desktop/system-wide.trace +``` + +## Chaining into analysis + +The recording script prints `trace written: ` on exit. Feed that +path straight into `analyze_trace.py`: + +```bash +TRACE=$(python3 "${SKILL_DIR}/scripts/record_trace.py" \ + --attach Helm --stop-file /tmp/stop-trace --output ~/Desktop/session.trace \ + 2>&1 | awk '/trace written:/ {print $NF}') +python3 "${SKILL_DIR}/scripts/analyze_trace.py" --trace "$TRACE" --json-only +``` + +If the user wanted a specific scope, combine with `--list-logs` / +`--list-signposts` / `--window` from `references/trace-analysis.md`. + +## Failure modes to handle + +- **Device offline** — `--list-devices` shows it in `devices offline`. + Ask the user to connect/unlock the device and retry. +- **Output path exists** — the script refuses to overwrite. Either pick + a new `--output` or delete the existing bundle. +- **App not running (for `--attach`)** — xctrace exits with an error; + fall back to `--launch` or tell the user to open the app first. +- **Signing / trust on device** — iOS requires a development build + signed with the user's team. If xctrace returns a signing error, point + the user to trust the developer profile on the device. diff --git a/.cursor/skills/swiftui-expert-skill/references/view-structure.md b/.cursor/skills/swiftui-expert-skill/references/view-structure.md new file mode 100644 index 00000000..bdf5568e --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/view-structure.md @@ -0,0 +1,794 @@ +# SwiftUI View Structure Reference + +## Table of Contents + +- [View Structure Principles](#view-structure-principles) +- [View File Structure (optional readability suggestion)](#view-file-structure-optional-readability-suggestion) +- [Struct or Method / Computed Property?](#struct-or-method--computed-property) +- [Extract Subviews, Not Computed Properties](#extract-subviews-not-computed-properties) +- [@ViewBuilder](#viewbuilder) +- [SDK 27 `@ContentBuilder`](#sdk-27-contentbuilder) +- [Keep View Body Simple and Avoid High-Cost Operations](#keep-view-body-simple-and-avoid-high-cost-operations) +- [Keep View `init` Cheap](#keep-view-init-cheap) +- [Single-Child Group](#single-child-group) +- [When to Extract Subviews](#when-to-extract-subviews) +- [Container View Pattern](#container-view-pattern) +- [Utilize Lazy Containers for Large Data Sets](#utilize-lazy-containers-for-large-data-sets) +- [ZStack vs overlay/background](#zstack-vs-overlaybackground) +- [Compositing Group Before Clipping](#compositing-group-before-clipping) +- [Split State-Driven Parts into Custom View Types](#split-state-driven-parts-into-custom-view-types) +- [Reusable Styling with ViewModifier](#reusable-styling-with-viewmodifier) +- [Skeleton Loading with Redacted Views](#skeleton-loading-with-redacted-views) +- [AnyView](#anyview) +- [UIViewRepresentable Essentials](#uiviewrepresentable-essentials) +- [Troubleshooting](#troubleshooting) +- [Summary Checklist](#summary-checklist) + +## View Structure Principles + +SwiftUI's diffing algorithm compares view hierarchies to determine what needs updating. Proper view composition directly impacts performance. + +## View File Structure (optional readability suggestion) + +Property ordering has no effect on correctness or performance, so treat this as a personal/team readability preference rather than a rule. Some developers find a consistent order easier to scan — for example, environment, then state, then passed-in properties, then `init`, body, and helper subviews. Adopt it only if your team wants the consistency; never reorder existing code solely to match it. + +```swift +struct ContentView: View { + // MARK: - Environment Properties + @Environment(\.colorScheme) var colorScheme + + // MARK: - State Properties + @Binding var isToggled: Bool + @State private var viewModel: SomeViewModel + + // MARK: - Private Properties + private let title: String = "SwiftUI Guide" + + // MARK: - Initializer (if needed) + init(isToggled: Binding) { + self._isToggled = isToggled + } + + // MARK: - Body + var body: some View { + VStack { + header + content + } + } + + // MARK: - Computed Subviews + private var header: some View { + Text(title).font(.largeTitle).padding() + } + + private var content: some View { + VStack { + Text("Counter: \(counter)") + } + } +} +``` + +## Struct or Method / Computed Property? + +If a `View` is intended to be reusable across multiple screens, encapsulate it within a separate `struct`. If its usage is confined to a single context, it can be declared as a function or computed property within the containing `View`. + +However, if a view maintains state using `@State`, `@Binding`, `@ObservedObject`, `@Environment`, `@StateObject`, or similar wrappers, it should generally be a separate `struct`. + +- For simple, static views: a computed property is acceptable. +- For views requiring parameters: a method is more appropriate, but only when those parameters are stable. If parameters change per-call (e.g. inside a `ForEach` where each call receives a different item), prefer a separate `struct` so SwiftUI can diff inputs and skip body evaluation. +- For reusable, stateful, or logically independent UI sections: prefer a dedicated `struct`. + +```swift +struct ContentView: View { + var titleView: some View { + Text("Hello from Property") + .font(.largeTitle) + .foregroundColor(.blue) + } + + func messageView(text: String, color: Color) -> some View { + Text(text) + .font(.title) + .foregroundColor(color) + .padding() + } + + var body: some View { + VStack { + titleView + messageView(text: "Hello from Method", color: .red) + } + } +} +``` + +For conditional modifier composition and `AnyShapeStyle`, consult `references/modifier-patterns.md`. + +## Extract Subviews, Not Computed Properties + +A view is SwiftUI's unit of invalidation. When an input changes, SwiftUI re-runs the body of the smallest enclosing **view type** that depends on it — every conditional, modifier chain, and string interpolation in that body, even if only one leaf actually depends on what changed. A computed property or `@ViewBuilder` helper is inlined into the parent's body, so it shares the parent's invalidation boundary and does not reduce update cost; it only reorganizes the code. A separate `View` type with narrow inputs becomes its own boundary and re-runs only when its own inputs change. + +This is why "split your body for readability" is also a performance tool — but only when you split into real `View` types, not computed properties. + +### The Problem with @ViewBuilder Functions + +When you use `@ViewBuilder` functions or computed properties for complex views, the entire function re-executes on every parent state change: + +```swift +// BAD - re-executes complexSection() on every tap +struct ParentView: View { + @State private var count = 0 + + var body: some View { + VStack { + Button("Tap: \(count)") { count += 1 } + complexSection() // Re-executes every tap! + } + } + + @ViewBuilder + func complexSection() -> some View { + // Complex views that re-execute unnecessarily + ForEach(0..<100) { i in + HStack { + Image(systemName: "star") + Text("Item \(i)") + Spacer() + Text("Detail") + } + } + } +} +``` + +### The Solution: Separate Structs + +Extract to separate `struct` views. SwiftUI can skip their `body` when inputs don't change: + +```swift +// GOOD - ComplexSection body SKIPPED when its inputs don't change +struct ParentView: View { + @State private var count = 0 + + var body: some View { + VStack { + Button("Tap: \(count)") { count += 1 } + ComplexSection() // Body skipped during re-evaluation + } + } +} + +struct ComplexSection: View { + var body: some View { + ForEach(0..<100) { i in + HStack { + Image(systemName: "star") + Text("Item \(i)") + Spacer() + Text("Detail") + } + } + } +} +``` + +### Why This Works + +1. SwiftUI compares the `ComplexSection` struct (which has no properties) +2. Since nothing changed, SwiftUI skips calling `ComplexSection.body` +3. The complex view code never executes unnecessarily + +### Multi-section detail views + +The most common place this rule gets dropped is a detail screen with several distinct sections — `header + gallery + description + reviews`, `header + ingredients + steps`, `hero + specs + related`. The tempting shape is one big view with `private var header: some View`, `private var gallery: some View`, and so on. That shape shares one invalidation boundary, so a change that affects one section re-evaluates all of them. Factor each named section into its own `View` type that takes only the fields it renders, and keep the parent thin — it just composes the sections. + +```swift +// PREFER: each section is its own type with narrow inputs. +struct ProductDetailView: View { + let product: Product + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + ProductHeader(name: product.name, price: product.price) + ProductGallery(images: product.imageURLs) + ProductDescription(text: product.descriptionText) + ProductReviews(average: product.averageStars, count: product.reviewCount) + } + .padding() + } + } +} +``` + +This generalizes to every `*DetailView`: one `View` type per section, narrow inputs each, a thin parent that composes them. Small `@ViewBuilder` fragments reused two or three times within the same body are still fine — the rule targets factoring done for organization or to manage body length, where a real `View` type does the right thing. + +## @ViewBuilder + +Use `@ViewBuilder` functions for small, simple sections (a few views, no expensive computation) that don't affect performance. They work particularly well for static content that doesn't depend on any `@State` or `@Binding`, since SwiftUI won't need to diff them independently. Extract to a separate `struct` when the section is complex, depends on state, or needs to be skipped during re-evaluation. + +The `@ViewBuilder` attribute is only required when a function or computed property returns multiple different views conditionally, for example through `if` or `switch`: + +```swift +@ViewBuilder +private var conditionalView: some View { + if isExpanded { + VStack { + Text("Expanded View") + Image(systemName: "star") + } + } else { + Text("Collapsed View") + } +} +``` + +If every branch returns the same concrete type, `@ViewBuilder` is unnecessary: + +```swift +var conditionalText: some View { + if Bool.random() { + Text("Hello") + } else { + Text("World") + } +} +``` + +Prefer `@ViewBuilder` when: + +- there is conditional branching between multiple view types +- extracting a separate `struct` would not provide meaningful separation + +## SDK 27 `@ContentBuilder` + +SDK 27 unifies many SwiftUI result builders under `@ContentBuilder`. Block contents are no longer constrained to `View`, so previously compiling source can become ambiguous. Choose the narrow fix matching the diagnostic. Do not broadly rewrite working builders or rename unrelated types. + +- For ambiguous `ShapeStyle.opacity` or `blendMode` passed directly to `overlay` or `background`, select the builder overload: + ```swift + Rectangle().overlay { + Color.blue.opacity(0.3).blendMode(.overlay) + } + ``` +- Fully qualify a shadowed SwiftUI type, such as `SwiftUI.Color.clear`. +- Avoid spelling concrete `TupleView` or `TupleContent` generic structures; prefer opaque `some View`. If a concrete SDK 27 type is unavoidable, the builder now produces `TupleContent`. When the deployment target is below OS 27 and a concrete `TupleView` constraint is unavoidable, construct `TupleView((...))` explicitly inside the builder as a back-deployment fallback. +- If an empty nested builder is ambiguous, provide `EmptyContent()` or `EmptyView()`. This can occur with MapKit in the dependency graph even when the file does not import MapKit, if member-import visibility is disabled. It also occurs with a conditional-compilation branch that becomes empty. +- For deeply branching Charts content that times out only when back-deployed (typically around 10+ `if`/`else if` or `switch` branches), extract the branches into an `@ChartContentBuilder` helper. + +## Keep View Body Simple and Avoid High-Cost Operations + +Refrain from performing complex operations within the `body` of your view. Instead of passing a ready-to-use sequence with filtering, mapping, or sorting directly into `ForEach`, prepare the sequence outside the body. + +```swift +// Avoid such things ... +var body: some View { + List { + ForEach(model.values.filter { $0 > 0 }, id: \.self) { + Text(String($0)) + .padding() + } + } +} +``` + +Prefer already-prepared values. `init` is not a one-time filter — it reruns whenever the parent re-evaluates, so treat it as a constant-time copy of inputs. Cache derived collections on the model (or in `@State` updated from `.onChange`) and pass the prepared sequence in: + +```swift +struct FilteredListView: View { + let filteredValues: [Int] + + var body: some View { + List { + ForEach(filteredValues, id: \.self) { value in + Text(String(value)) + .padding() + } + } + } +} +``` + +The reason this matters is that the system can call `body` multiple times during a single layout phase. Complex body computation makes those calls more expensive than necessary. + +General guidance: + +- avoid filtering, sorting, and mapping inline in `body` +- avoid constructing expensive formatters in `body` +- avoid heavy branching in large view trees +- move data preparation into the model layer or dedicated helpers; do not filter in `init` + +## Keep View `init` Cheap + +A view's `init` runs every time the parent re-evaluates its body, which can be many times per second for views inside `List`, `LazyVStack`, scroll containers, or animated parents. Treat `init` as a constant-time copy of inputs into stored properties. Don't decode JSON, build a `DateFormatter`, touch the file system, or allocate large structures there — that work repeats on every parent body pass even when the inputs are identical. + +```swift +// AVOID: decoding and formatting on every init +init(rawJSON: Data, date: Date) { + self.summary = try! JSONDecoder().decode(WeatherSummary.self, from: rawJSON) + let formatter = DateFormatter() + formatter.dateStyle = .medium + self.formattedDate = formatter.string(from: date) +} + +// PREFER: take already-prepared values; format lazily in body +let summary: WeatherSummary +let date: Date + +var body: some View { + VStack { + Text(summary.headline) + Text(date, format: .dateTime.day().month().year()) // cached, locale-aware + } +} +``` + +If a derived value genuinely needs to be computed once and kept, store it on an `@State`-owned `@Observable` model or compute it asynchronously in `.task` — not in `init`. `init` is not a one-time setup hook; it runs as often as the parent's body does. + +## Single-Child Group + +`Group { SomeView() }` — a `Group` with exactly one concrete child — wraps the view in an extra `Group` type for no visual benefit. Every modifier chained after it must be type-checked against that wrapper, adding avoidable type-checking overhead in long chains. Drop the `Group` and chain modifiers directly on the child. + +```swift +// AVOID: single concrete child wrapped in Group +Group { Text(status) } + .padding(.horizontal, 8) + .background(.thinMaterial, in: Capsule()) + +// PREFER: chain directly on the child +Text(status) + .padding(.horizontal, 8) + .background(.thinMaterial, in: Capsule()) +``` + +The rule is specifically about one concrete view. A `Group` whose content is a `ForEach`, multiple sibling views, or an `if`/`else` (which produces `_ConditionalContent`) is doing real work — applying a shared modifier across siblings or both branches — and is fine. + +## When to Extract Subviews + +Extract complex views into separate subviews when: +- The view has multiple logical sections or responsibilities +- The view contains reusable components +- The view body becomes difficult to read or understand +- You need to isolate state changes for performance +- The view is becoming large (keep views small for better performance) +- The section may evolve independently over time + +## Container View Pattern + +### Avoid Closure-Based Content + +Closures can't be compared, causing unnecessary re-renders: + +```swift +// BAD - closure prevents SwiftUI from skipping updates +struct MyContainer: View { + let content: () -> Content + + var body: some View { + VStack { + Text("Header") + content() // Always called, can't compare closures + } + } +} + +// Usage forces re-render on every parent update +MyContainer { + ExpensiveView() +} +``` + +### Use @ViewBuilder Property Instead + +```swift +// GOOD - view can be compared +struct MyContainer: View { + @ViewBuilder let content: Content + + var body: some View { + VStack { + Text("Header") + content // SwiftUI can compare and skip if unchanged + } + } +} + +// Usage - SwiftUI can diff ExpensiveView +MyContainer { + ExpensiveView() +} +``` + +## Utilize Lazy Containers for Large Data Sets + +When displaying extensive lists or grids, prefer `LazyVStack`, `LazyHStack`, `LazyVGrid`, or `LazyHGrid`. These containers load views only when they appear on the screen, reducing memory usage and improving performance. + +```swift +struct ContentView: View { + let items = Array(0..<1000) + + var body: some View { + ScrollView { + LazyVStack { + ForEach(items, id: \.self) { item in + Text("Item \(item)") + } + } + } + } +} +``` + +Prefer lazy containers when: + +- rendering large collections +- row views are non-trivial +- memory usage matters +- the content is inside `ScrollView` + +## ZStack vs overlay/background + +Use `ZStack` to **compose multiple peer views** that should be layered together and jointly define layout. + +Prefer `overlay` / `background` when you’re **decorating a primary view**. +Not primarily because they don’t affect layout size, but because they **express intent and improve readability**: the view being modified remains the clear layout anchor. + +A key difference is **size proposal behavior**: +- In `overlay` / `background`, the child view implicitly adopts the size proposed to the parent when it doesn’t define its own size, making decorative attachments feel natural and predictable. +- In `ZStack`, each child participates independently in layout, and no implicit size inheritance exists. This makes it better suited for peer composition, but less intuitive for simple decoration. + +Use `ZStack` (or another container) when the “decoration” **must explicitly participate in layout sizing**—for example, when reserving space, extending tappable/visible bounds, or preventing overlap with neighboring views. + +### Examples + +```swift +// GOOD - decoration via overlay (layout anchored to button) +Button("Continue") { } + .overlay(alignment: .trailing) { + Image(systemName: "lock.fill").padding(.trailing, 8) + } + +// BAD - ZStack when overlay suffices (layout no longer anchored to button) +ZStack(alignment: .trailing) { + Button("Continue") { } + Image(systemName: "lock.fill").padding(.trailing, 8) +} + +// GOOD - background shape takes parent size +HStack(spacing: 12) { Text("Inbox"); Text("Next") } + .background { Capsule().strokeBorder(.blue, lineWidth: 2) } +``` + +## Compositing Group Before Clipping + +**Always add `.compositingGroup()` before `.clipShape()` when clipping layered views (`.overlay` or `.background`).** Without it, each layer is antialiased separately and then composited. Where antialiased edges overlap — typically at rounded corners — you get visible color fringes (semi-transparent pixels of different colors blending together). + +```swift +let shape = RoundedRectangle(cornerRadius: 16) + +// BAD - each layer antialiased separately, producing color fringes at corners +Color.red + .overlay(.white, in: shape) + .clipShape(shape) + .frame(width: 200, height: 150) + +// GOOD - layers composited first, antialiasing applied once during clipping +Color.red + .overlay(.white, in: .rect) + .compositingGroup() + .clipShape(shape) + .frame(width: 200, height: 150) +``` + +`.compositingGroup()` forces all child layers to be rendered into a single offscreen buffer before the clip is applied. This means antialiasing only happens once — on the final composited result — eliminating the fringe artifacts. + +## Split State-Driven Parts into Custom View Types + +Large views often depend on multiple independent state sources. If a single view body depends on all of them, then any state change can cause the entire body to re-evaluate. + +```swift +struct BigAndComplicatedView: View { + @State private var counter = 0 + @State private var isToggled = false + @StateObject private var viewModel = SomeViewModel() + + let title = "Big and Complicated View" + + var body: some View { + VStack { + Text(title) + .font(.largeTitle) + + Text("Counter: \(counter)") + .font(.title) + + Toggle("Enable Feature", isOn: $isToggled) + .padding() + + Button("Increment Counter") { + counter += 1 + } + + Text("ViewModel Data: \(viewModel.data)") + .padding() + + Button("Fetch Data") { + viewModel.fetchData() + } + } + } +} +``` + +### Better: Split Into Smaller Components + +```swift +struct BigAndComplicatedView: View { + @State private var counter = 0 + @State private var isToggled = false + @StateObject private var viewModel = SomeViewModel() + + var body: some View { + VStack { + titleView + CounterView(counter: $counter) + ToggleView(isToggled: $isToggled) + ViewModelDataView(data: viewModel.data) { + viewModel.updateData() + } + .equatable() + } + } + + private var titleView: some View { + Text("Big and Complicated View") + .font(.largeTitle) + } +} +``` + +Why this is better: + +- changing `counter` only affects `CounterView` +- toggling only affects `ToggleView` +- updating the model data only affects `ViewModelDataView` + +### Notes on Equatable + +Using `Equatable` for a view is not a universal best practice, but it can be useful in targeted cases where: + +- the input is small and well-defined +- the comparison logic is meaningful +- you want to reduce unnecessary body evaluation for a specific subtree + +Do not use `Equatable` as a blanket optimization technique. + +## Reusable Styling with ViewModifier + +Extract repeated modifier combinations into a `ViewModifier` struct. Expose via a `View` extension for autocompletion: + +```swift +private struct CardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(.rect(cornerRadius: 12)) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardStyle()) + } +} +``` + +### Custom ButtonStyle + +Use the `ButtonStyle` protocol for reusable button designs. Use `PrimitiveButtonStyle` only when you need custom interaction handling (e.g., simultaneous gestures): + +```swift +struct PrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .bold() + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.accentColor) + .clipShape(Capsule()) + .scaleEffect(configuration.isPressed ? 0.95 : 1) + .animation(.smooth, value: configuration.isPressed) + } +} +``` + +### Discoverability with Static Member Lookup + +Make custom styles and modifiers discoverable via leading-dot syntax: + +```swift +extension ButtonStyle where Self == PrimaryButtonStyle { + static var primary: PrimaryButtonStyle { .init() } +} + +// Usage: .buttonStyle(.primary) +``` + +This pattern works for any SwiftUI style protocol (`ButtonStyle`, `ListStyle`, `ToggleStyle`, etc.). + +## Skeleton Loading with Redacted Views + +Use `.redacted(reason: .placeholder)` to show skeleton views while data loads. Use `.unredacted()` to opt out specific views: + +```swift +VStack(alignment: .leading) { + Text(article?.title ?? String(repeating: "X", count: 20)) + .font(.headline) + Text(article?.author ?? String(repeating: "X", count: 12)) + .font(.subheadline) + Text("SwiftLee") + .font(.caption) + .unredacted() +} +.redacted(reason: article == nil ? .placeholder : []) +``` + +Apply `.redacted` on a container to redact all children at once. + +## AnyView + +`AnyView` is type erasure. SwiftUI uses structural identity based on type information to determine when views should be updated. + +```swift +private var nameView: some View { + if isEditable { + TextField("Your name", text: $name) + } else { + Text(name) + } +} +``` + +Avoid patterns like: + +```swift +private var nameView: some View { + if isEditable { + return AnyView(TextField("Your name", text: $name)) + } else { + return AnyView(Text(name)) + } +} +``` + +Because `AnyView` erases type information, SwiftUI loses some optimization opportunities. Prefer `@ViewBuilder` or conditional branches with concrete view types. + +Use `AnyView` only when type erasure is truly necessary for API design. + +## UIViewRepresentable Essentials + +When bridging UIKit views into SwiftUI: + +- `makeUIView(context:)` is called **once** to create the UIKit view +- `updateUIView(_:context:)` is called on **every SwiftUI redraw** to sync state +- The representable struct itself is **recreated on every redraw** -- avoid heavy work in its init +- Use a `Coordinator` for delegate callbacks and two-way communication + +```swift +struct MapView: UIViewRepresentable { + let coordinate: CLLocationCoordinate2D + + func makeUIView(context: Context) -> MKMapView { + let map = MKMapView() + map.delegate = context.coordinator + return map + } + + func updateUIView(_ map: MKMapView, context: Context) { + map.setCenter(coordinate, animated: true) + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + class Coordinator: NSObject, MKMapViewDelegate { } +} +``` + +## Troubleshooting + +### Debug SwiftUI Renderings + +> See `references/performance-patterns.md` (item #8) for the `_printChanges()` vs `_logChanges()` comparison and the `@self`/`@identity` output meaning. The snippets below show the call sites. + +If it is needed to debug render cycles and read console output you can leverage the `_printChanges()` or `_logChanges()` methods on `View`. These methods print information about when the view is being evaluated and what changes are triggering updates. This can be very helpful when your view body is called multiple times and you want to know why. + +```swift +struct ContentView: View { + @State private var counter: Int = 99 + + init() { + print(Self.self, #function) + } + + var body: some View { + let _ = Self._printChanges() + + VStack { + Text("Counter: \(counter)") + Button { + counter += 1 + } label: { + Text("Counter +1") + } + .buttonStyle(.borderedProminent) + } + .padding() + } +} +``` + +As an alternative to `Self._printChanges()`, you can use `_logChanges()` + +```swift +struct ContentView: View { + @State private var counter: Int = 99 + + var body: some View { + let _ = Self._logChanges() + + VStack { + Text("Counter: \(counter)") + Button { + counter += 1 + } label: { + Text("Counter +1") + } + .buttonStyle(.borderedProminent) + } + .padding() + } +} +``` + +Use these tools only for debugging and remove them from production code. + +### Handling "The Compiler Is Unable to Type-Check This Expression in Reasonable Time" + +If you encounter: + +> The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions + +it is often caused by overly complex view structures or expressions. + +Ways to fix it: + +- break large expressions into smaller computed values +- extract subviews +- split long modifier chains +- simplify nested generics and builders +- avoid huge inline closures + +## Summary Checklist + +- [ ] Extract complex views into separate subviews, not computed properties +- [ ] Keep views small for readability and performance +- [ ] Use `@ViewBuilder` only where it actually adds value +- [ ] Avoid heavy filtering, mapping, sorting, or formatter creation inside `body` or `init` +- [ ] Keep view `init` cheap (constant-time input copy; it runs on every parent body pass) +- [ ] Resolve SDK 27 `@ContentBuilder` ambiguity with the matching narrow fix +- [ ] Avoid single-child `Group { OneView() }` (chain modifiers directly on the child) +- [ ] Use lazy containers for large data sets +- [ ] Container views use `@ViewBuilder let content: Content` +- [ ] Prefer `overlay` / `background` for decoration and `ZStack` for peer composition +- [ ] `.compositingGroup()` before `.clipShape()` on layered views to avoid antialiasing fringes +- [ ] Split state-heavy areas into smaller view types +- [ ] Extract repeated styling into `ViewModifier` or `ButtonStyle` +- [ ] Expose reusable styles via static member lookup when it improves discoverability +- [ ] Use `.redacted(reason: .placeholder)` for loading skeletons +- [ ] Avoid `AnyView` unless type erasure is truly needed +- [ ] In `UIViewRepresentable`, keep heavy work out of struct init +- [ ] Use `_printChanges()` / `_logChanges()` to debug rendering behavior +- [ ] Break up overly complex expressions when the compiler struggles diff --git a/.cursor/skills/swiftui-expert-skill/references/webkit-integration.md b/.cursor/skills/swiftui-expert-skill/references/webkit-integration.md new file mode 100644 index 00000000..c2a79841 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/references/webkit-integration.md @@ -0,0 +1,242 @@ +# WebKit Integration in SwiftUI + +> `WebView` and `WebPage` require iOS 26, macOS 26, or visionOS 26 and are **unavailable on watchOS and tvOS**. A few members require the aligned 27 releases; those are called out inline. Both types are `@MainActor`. + +## Table of Contents + +- [WebView](#webview) +- [WebPage](#webpage) +- [Configuration and Data Stores](#configuration-and-data-stores) +- [Loading Content](#loading-content) +- [Observing Navigation](#observing-navigation) +- [Deciding Navigation Policy](#deciding-navigation-policy) +- [Calling JavaScript](#calling-javascript) +- [Find Navigator](#find-navigator) +- [View Modifiers](#view-modifiers) +- [Exporting PDF and Images](#exporting-pdf-and-images) +- [Custom URL Schemes](#custom-url-schemes) + +--- + +## WebView + +`WebView` has two initializers: one for a bare URL, one backed by a `WebPage`. + +```swift +import SwiftUI +import WebKit + +WebView(url: URL(string: "https://www.swift.org")) +``` + +`WebView(url:)` is enough for read-only display; the view reloads when the URL changes. Use the `WebPage` initializer when you need the page's title, loading state, back-forward list, JavaScript, or navigation policy. + +## WebPage + +`WebPage` is an observable `@MainActor` class that owns the web content. Because it is observable, reading its properties in a `body` re-renders automatically. + +```swift +struct BrowserView: View { + @State private var page = WebPage() + + var body: some View { + NavigationStack { + WebView(page) + .navigationTitle(page.title) + } + .onAppear { + _ = page.load(URL(string: "https://www.apple.com")) + } + } +} +``` + +Observable properties include `url`, `title` (a non-optional `String`), `isLoading`, `estimatedProgress`, `backForwardList`, `serverTrust`, and `customUserAgent`. Note that `title` is not optional — don't write `if let title = page.title`. + +```swift +ProgressView(value: page.estimatedProgress) + .opacity(page.isLoading ? 1 : 0) +``` + +## Configuration and Data Stores + +`WebPage.Configuration` is a value type applied at initialization; changing it afterwards has no effect on an existing page. + +```swift +var configuration = WebPage.Configuration() +configuration.loadsSubresources = true +configuration.defaultNavigationPreferences.allowsContentJavaScript = true +configuration.websiteDataStore = .nonPersistent() + +let page = WebPage(configuration: configuration) +``` + +`websiteDataStore` is a `WKWebsiteDataStore`. Use `.default()` to share cookies and caches with other pages in the app, or `.nonPersistent()` for private browsing where nothing survives the session. `defaultNavigationPreferences` supplies the baseline `NavigationPreferences` (JavaScript, `ContentMode`) for every navigation; a `NavigationDeciding` can override it per navigation. + +Set `page.customUserAgent` on the page itself, not the configuration. + +## Loading Content + +Every `load` overload returns an `AsyncSequence` of `WebPage.NavigationEvent` values, so you can iterate it to follow that specific navigation — or bind it to `_` when you don't care. + +```swift +_ = page.load(URLRequest(url: url)) +_ = page.load(html: "

Hello

", baseURL: URL(string: "https://example.com")!) +_ = page.load(data, mimeType: "text/html", characterEncoding: .utf8, baseURL: baseURL) +_ = page.load(simulatedRequest: request, responseHTML: html) +_ = page.reload(fromOrigin: false) +page.stopLoading() +``` + +`baseURL` resolves relative links and determines the origin for HTML and `Data` loads. Back-forward navigation loads an item from the list: + +```swift +if let backItem = page.backForwardList.backItem { + _ = page.load(backItem) +} +``` + +## Observing Navigation + +`page.navigations` is an `AsyncSequence` of `NavigationEvent` covering all navigations; the per-call sequence returned by `load` covers just that one. Cases are `.startedProvisionalNavigation`, `.receivedServerRedirect`, `.committed`, and `.finished`. Failures surface as thrown `WebPage.NavigationError` values (`.failedProvisionalNavigation`, `.pageClosed`, `.webContentProcessTerminated`, `.invalidURL`). + +```swift +.task { + do { + for try await event in page.navigations { + if event == .finished { await indexPage() } + } + } catch { + // handle NavigationError + } +} +``` + +For simple loading indicators, prefer the observable `page.isLoading` and `page.estimatedProgress` over consuming the event stream. + +## Deciding Navigation Policy + +Conform a type to `WebPage.NavigationDeciding` and pass it to the initializer. Every requirement has a default implementation, so implement only what you need. + +```swift +struct LinkPolicy: WebPage.NavigationDeciding { + func decidePolicy( + for action: WebPage.NavigationAction, + preferences: inout WebPage.NavigationPreferences + ) async -> WKNavigationActionPolicy { + guard action.request.url?.host() != "blocked.example.com" else { return .cancel } + preferences.allowsContentJavaScript = true + return .allow + } + + func decidePolicy( + for response: WebPage.NavigationResponse + ) async -> WKNavigationResponsePolicy { + (response.response as? HTTPURLResponse)?.statusCode == 200 ? .allow : .cancel + } +} + +let page = WebPage(navigationDecider: LinkPolicy()) +``` + +`decideAuthenticationChallengeDisposition(for:)` handles `URLAuthenticationChallenge`, and `willSubmit(formInfo:)` (iOS/macOS/visionOS 27+) observes form submissions. A separate `dialogPresenter:` parameter takes a `WebPage.DialogPresenting` type for JavaScript alerts, confirms, and prompts. + +## Calling JavaScript + +```swift +let result = try await page.callJavaScript( + """ + const meta = document.querySelector('meta[name="description"]'); + return meta ? meta.getAttribute('content') : ''; + """ +) +let description = result as? String +``` + +`callJavaScript(_:arguments:in:contentWorld:)` takes a **function body**, so use `return` to produce a value. `arguments` is a `[String: Any]` dictionary whose keys become in-scope variables — pass values that way instead of interpolating strings. `in:` targets a `WebPage.FrameInfo`; `contentWorld:` takes a `WKContentWorld` (`.page`, `.defaultClient`, or a custom world) to isolate your script's globals from the page's own. + +## Find Navigator + +`WebView` participates in the standard SwiftUI find navigator: + +```swift +WebView(page) + .findNavigator(isPresented: $isSearching) +``` + +## View Modifiers + +Applied to the `WebView`: + +| Modifier | Purpose | +|---|---| +| `webViewBackForwardNavigationGestures(_:)` | `.automatic` / `.enabled` / `.disabled` swipe navigation | +| `webViewMagnificationGestures(_:)` | Pinch-to-zoom behavior | +| `webViewLinkPreviews(_:)` | Long-press / force-touch link previews | +| `webViewTextSelection(_:)` | Takes a `TextSelectability`, e.g. `.enabled` | +| `webViewElementFullscreenBehavior(_:)` | Allows HTML element fullscreen | +| `webViewContentBackground(_:)` | Takes a `Visibility` — hide it to show your own background behind the page | +| `webViewContextMenu(menu:)` | macOS only. Builds a menu from a `WebView.ActivatedElementInfo` (its `linkURL`). | +| `webViewScrollPosition(_:)` | Binds a `ScrollPosition` | +| `webViewOnScrollGeometryChange(for:of:action:)` | Observes `ScrollGeometry` changes | +| `webViewScrollInputBehavior(_:for:)` | Enables or disables a `ScrollInputKind` | + +On macOS: + +```swift +WebView(page) + .webViewContentBackground(.hidden) + .background(.regularMaterial) + .webViewContextMenu { element in + if let url = element.linkURL { + ShareLink(item: url) + } + } +``` + +## Exporting PDF and Images + +`WebPage` conforms to `Transferable`, so it can be dragged or shared directly. For explicit exports, call `exported(as:)` with a `WebPage.ExportedContentConfiguration`: + +```swift +let pdf = try await page.exported(as: .pdf(region: .contents)) +let png = try await page.exported(as: .image(region: .rect(bounds), snapshotWidth: 1024)) +``` + +`Region` is either `.contents` or `.rect(_:)`, and both factories accept `allowTransparentBackground`. Both calls return `Data`. + +`WebPage` has no web-archive API. Web archives remain a `WKWebView` API (`createWebArchiveData(completionHandler:)`), so reach for `WKWebView` in a representable only when you specifically need `.webarchive` output. + +## Custom URL Schemes + +`URLSchemeHandler` replies with an `AsyncSequence` of `URLSchemeTaskResult` values — first a `.response`, then one or more `.data` elements. Register handlers in the configuration's `urlSchemeHandlers` dictionary keyed by `URLScheme`. + +```swift +struct AssetSchemeHandler: URLSchemeHandler { + func reply(for request: URLRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + guard let url = request.url else { + continuation.finish(throwing: URLError(.badURL)) + return + } + let html = "

\(url.path())

" + continuation.yield(.response(URLResponse( + url: url, + mimeType: "text/html", + expectedContentLength: -1, + textEncodingName: "utf-8" + ))) + continuation.yield(.data(Data(html.utf8))) + continuation.finish() + } + } +} + +var configuration = WebPage.Configuration() +if let scheme = URLScheme("myapp") { + configuration.urlSchemeHandlers[scheme] = AssetSchemeHandler() +} +let page = WebPage(configuration: configuration) +``` + +`URLScheme(_:)` is failable — the system rejects reserved schemes such as `http` and `https`. Cancellation is expressed by terminating the returned sequence, so honor `Task` cancellation inside it rather than implementing a separate stop callback. diff --git a/.cursor/skills/swiftui-expert-skill/scripts/analyze_trace.py b/.cursor/skills/swiftui-expert-skill/scripts/analyze_trace.py new file mode 100644 index 00000000..250dbfdf --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/analyze_trace.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Analyze an Xcode Instruments .trace file and emit JSON + markdown. + +Primary modes: + (default) Full four-lane analysis + cross-lane correlations. + --list-logs Dump os_log entries (optionally filtered) as JSON so an + agent can locate a focus window by log content. + --list-signposts Dump os_signpost intervals + point events as JSON. + +Windowing: + --window START_MS:END_MS restricts every lane to that slice of the trace. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from instruments_parser import ( + causes, + correlate, + events, + hangs, + hitches, + summary, + swiftui, + time_profiler, + xctrace, +) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Analyze an Instruments .trace file.", + ) + parser.add_argument("--trace", required=True, type=Path) + parser.add_argument( + "--output", + type=Path, + help="Base path; writes .json and .md", + ) + parser.add_argument("--top", type=int, default=10, help="Top-N per lane") + parser.add_argument( + "--top-hitches", + type=int, + default=5, + help="Correlate only the N worst hitches (avoid flooding output).", + ) + parser.add_argument( + "--window", + type=str, + default=None, + help="Restrict analysis to a time slice, e.g. --window 10400:11700 (ms).", + ) + parser.add_argument( + "--run", + type=int, + default=None, + help="Which run to analyze (1-based). Required for traces with >1 run.", + ) + parser.add_argument( + "--list-runs", action="store_true", + help="Emit per-run metadata as JSON (use this to discover available runs).", + ) + + # Mode flags (mutually exclusive with full analysis) + mode_group = parser.add_argument_group("Discovery modes") + mode_group.add_argument( + "--list-logs", action="store_true", + help="Emit os_log entries as JSON (use filter flags below).", + ) + mode_group.add_argument( + "--list-signposts", action="store_true", + help="Emit os_signpost intervals + events as JSON.", + ) + mode_group.add_argument("--log-subsystem", type=str, default=None) + mode_group.add_argument("--log-category", type=str, default=None) + mode_group.add_argument( + "--log-type", type=str, default=None, + help="e.g. Fault, Error, Default, Info, Debug", + ) + mode_group.add_argument( + "--log-message-contains", type=str, default=None, + help="Case-insensitive substring match on the message / format string.", + ) + mode_group.add_argument( + "--log-limit", type=int, default=None, + help="Cap number of log entries returned (applied after all filters).", + ) + mode_group.add_argument( + "--signpost-name-contains", type=str, default=None, + help="Case-insensitive substring match on signpost name.", + ) + mode_group.add_argument("--signpost-subsystem", type=str, default=None) + mode_group.add_argument("--signpost-category", type=str, default=None) + mode_group.add_argument( + "--fanin-for", type=str, default=None, + help="Emit incoming cause-graph sources for destinations whose fmt " + "contains this substring. Case-insensitive.", + ) + + fmt_group = parser.add_mutually_exclusive_group() + fmt_group.add_argument("--json-only", action="store_true") + fmt_group.add_argument("--markdown-only", action="store_true") + + args = parser.parse_args(argv) + + # The discovery modes aren't in a mutually_exclusive_group because they + # live alongside their sub-filters in the same argparse group; enforce the + # constraint by hand so an agent gets a clear error instead of silent + # precedence. + active_modes = sum([ + args.list_runs, + args.list_logs, + args.list_signposts, + bool(args.fanin_for), + ]) + if active_modes > 1: + parser.error( + "--list-runs, --list-logs, --list-signposts, and --fanin-for are " + "mutually exclusive; pick one per invocation." + ) + + trace = args.trace + if not trace.exists(): + print(f"error: trace not found: {trace}", file=sys.stderr) + return 2 + + info = xctrace.toc(trace) + window_ns = _parse_window(args.window) + + if args.list_runs: + sys.stdout.write(json.dumps({ + "xctrace_version": info.xctrace_version, + "runs": [ + { + "number": r.number, + "template": r.template_name, + "duration_s": r.duration_s, + "start_date": r.start_date, + "end_date": r.end_date, + "schemas": sorted(r.schemas), + } + for r in info.runs + ], + }, indent=2)) + sys.stdout.write("\n") + return 0 + + run_info = _resolve_run(info, args.run) + if run_info is None: + return 2 + run_number = run_info.number + + if args.list_logs: + out = events.list_logs( + trace, run_info.schemas, + subsystem=args.log_subsystem, + category=args.log_category, + message_contains=args.log_message_contains, + message_type=args.log_type, + limit=args.log_limit, + window_ns=window_ns, + run=run_number, + ) + sys.stdout.write(json.dumps({"logs": out, "count": len(out)}, indent=2)) + sys.stdout.write("\n") + return 0 + + if args.list_signposts: + sp = events.list_signposts( + trace, run_info.schemas, + name_contains=args.signpost_name_contains, + subsystem=args.signpost_subsystem, + category=args.signpost_category, + window_ns=window_ns, + run=run_number, + ) + sys.stdout.write(json.dumps(sp, indent=2)) + sys.stdout.write("\n") + return 0 + + if args.fanin_for: + fanin = causes.fanin_for( + trace, run_info.schemas, + destination_contains=args.fanin_for, + top_k=args.top, + window=window_ns, + run=run_number, + ) + sys.stdout.write(json.dumps(fanin, indent=2)) + sys.stdout.write("\n") + return 0 + + # Full five-lane analysis + schemas = run_info.schemas + lanes_out = { + "time-profiler": time_profiler.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number), + "hangs": hangs.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number), + "hitches": hitches.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number), + "swiftui": swiftui.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number), + "swiftui-causes": causes.analyze(trace, schemas, top_n=args.top, window=window_ns, run=run_number), + } + correlations = correlate.build( + lanes_out, top_hitches=args.top_hitches, top_symbols=5 + ) + public_lanes = [_strip_internal(l) for l in lanes_out.values()] + + result: dict = { + "trace": str(trace), + "xctrace_version": info.xctrace_version, + "run": run_number, + "runs_available": [r.number for r in info.runs], + "template": run_info.template_name, + "duration_s": run_info.duration_s, + "start_date": run_info.start_date, + "end_date": run_info.end_date, + "schemas_available": sorted(run_info.schemas), + "lanes": public_lanes, + "correlations": correlations, + } + if window_ns is not None: + result["window_ms"] = { + "start": window_ns[0] / 1_000_000, + "end": window_ns[1] / 1_000_000, + } + + md = summary.render(result) + + if args.output: + json_path = args.output.with_suffix(".json") + md_path = args.output.with_suffix(".md") + json_path.write_text(json.dumps(result, indent=2)) + md_path.write_text(md) + print(f"wrote {json_path}") + print(f"wrote {md_path}") + return 0 + + if args.markdown_only: + sys.stdout.write(md) + elif args.json_only: + sys.stdout.write(json.dumps(result, indent=2)) + sys.stdout.write("\n") + else: + sys.stdout.write(json.dumps(result, indent=2)) + sys.stdout.write("\n---\n") + sys.stdout.write(md) + return 0 + + +def _resolve_run(info, requested: int | None): + """Pick a run from the trace. + + If `requested` is given, return that run or None on miss (with a friendly + error). If unset and the trace has exactly one run, default to it. If + unset and there are multiple runs, error out so the agent picks + explicitly — silently picking run 1 lost data for the user. + """ + if not info.runs: + print("error: trace has no runs", file=sys.stderr) + return None + if requested is not None: + try: + return info.get_run(requested) + except KeyError as e: + print(f"error: {e}", file=sys.stderr) + return None + if len(info.runs) == 1: + return info.runs[0] + available = ", ".join(str(r.number) for r in info.runs) + print( + f"error: trace has {len(info.runs)} runs ({available}); pass --run N. " + f"Use --list-runs to see per-run metadata.", + file=sys.stderr, + ) + return None + + +def _parse_window(spec: str | None) -> tuple[int, int] | None: + if not spec: + return None + if ":" not in spec: + raise SystemExit(f"--window expects START_MS:END_MS, got {spec!r}") + start_s, end_s = spec.split(":", 1) + try: + start_ms = float(start_s) + end_ms = float(end_s) + except ValueError as e: + raise SystemExit(f"--window: {e}") + if end_ms < start_ms: + raise SystemExit("--window: end_ms must be >= start_ms") + return (int(start_ms * 1_000_000), int(end_ms * 1_000_000)) + + +def _strip_internal(lane: dict) -> dict: + return {k: v for k, v in lane.items() if not k.startswith("_")} + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/__init__.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/__init__.py new file mode 100644 index 00000000..e15975bd --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/__init__.py @@ -0,0 +1 @@ +"""Parsers for Xcode Instruments .trace files via xctrace export.""" diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/causes.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/causes.py new file mode 100644 index 00000000..1148b42e --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/causes.py @@ -0,0 +1,187 @@ +"""SwiftUI cause-graph lane (`swiftui-causes` schema). + +Instruments emits one row per edge in SwiftUI's dependency graph: every time +a source node (a state change, user defaults observer, system event, etc.) +propagates to a destination node (a body evaluation, layout, creation), a +row is written with both endpoints as metadata values. + +This lane aggregates those edges two ways: + +- **By source node** — which attribute graph nodes are driving the most + updates overall. The canonical "why is my app thrashing?" view; a + `UserDefaultObserver.send()` showing up with 11k outgoing edges is a + feedback storm. +- **By destination node** — which views/modifiers receive the most + invalidations, and from whom. Use this to trace a hot view back to the + source that keeps poking it. + +The analyzer's main lane (`swiftui`) tells you *what* updates are +expensive; this lane tells you *why* they keep happening. +""" +from __future__ import annotations + +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +from . import xctrace, xml_utils + +SCHEMA = "swiftui-causes" + +# Metadata nodes render as space-separated field dumps ("A gray icon n/a n/a"). +# We aggregate on the full fmt string so callers can spot specific edges like +# "@AppStorage TextStyleModifier.fontOption", but also expose the short head +# ("@AppStorage", "Creation of App", ...) for coarser grouping. + + +def analyze( + trace_path: Path, + toc_schemas: frozenset[str], + top_n: int = 10, + top_k_per_node: int = 5, + window: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, Any]: + if SCHEMA not in toc_schemas: + return { + "lane": "swiftui-causes", + "available": False, + "notes": [ + "SwiftUI causes data not present (requires SwiftUI template on a real device).", + ], + } + + xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run) + stream = xml_utils.RowStream(xml_bytes) + + source_edges: Counter[str] = Counter() + destination_edges: Counter[str] = Counter() + fanout: dict[str, Counter[str]] = defaultdict(Counter) + fanin: dict[str, Counter[str]] = defaultdict(Counter) + label_counts: Counter[str] = Counter() + total_edges = 0 + + for row in stream: + time_el = xml_utils.first_present(row, "timestamp", "time") + if time_el is not None: + t_ns = xml_utils.int_text(stream.resolve(time_el)) + if t_ns is not None and not xml_utils.in_window(t_ns, window): + continue + + src = _fmt(row, stream, "source-node") + dst = _fmt(row, stream, "destination-node") + if not src or not dst: + continue + + source_edges[src] += 1 + destination_edges[dst] += 1 + fanout[src][dst] += 1 + fanin[dst][src] += 1 + + label = _fmt(row, stream, "label") + if label: + label_counts[label] += 1 + + total_edges += 1 + + top_sources = [ + { + "source": src, + "edges": count, + "top_destinations": [ + {"destination": d, "edges": c} + for d, c in fanout[src].most_common(top_k_per_node) + ], + } + for src, count in source_edges.most_common(top_n) + ] + + top_destinations = [ + { + "destination": dst, + "edges": count, + "top_sources": [ + {"source": s, "edges": c} + for s, c in fanin[dst].most_common(top_k_per_node) + ], + } + for dst, count in destination_edges.most_common(top_n) + ] + + return { + "lane": "swiftui-causes", + "available": True, + "schema_used": SCHEMA, + "metrics": { + "total_edges": total_edges, + "unique_sources": len(source_edges), + "unique_destinations": len(destination_edges), + "top_labels": dict(label_counts.most_common(top_n)), + }, + "top_sources": top_sources, + "top_destinations": top_destinations, + "notes": [], + } + + +def fanin_for( + trace_path: Path, + toc_schemas: frozenset[str], + destination_contains: str, + top_k: int = 10, + window: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, Any]: + """Return the top source nodes feeding any destination whose fmt string + contains `destination_contains` (case-insensitive substring). + + Used when the agent has a suspect view from the `swiftui` lane and wants + to know *who keeps invalidating it*. Does a full pass over the causes + schema each time — cheap enough at typical trace sizes. + """ + if SCHEMA not in toc_schemas: + return {"available": False, "matches": []} + + needle = destination_contains.lower() + xml_bytes = xctrace.export_schema(trace_path, SCHEMA, run=run) + stream = xml_utils.RowStream(xml_bytes) + + matches: dict[str, Counter[str]] = defaultdict(Counter) + totals: Counter[str] = Counter() + + for row in stream: + time_el = xml_utils.first_present(row, "timestamp", "time") + if time_el is not None: + t_ns = xml_utils.int_text(stream.resolve(time_el)) + if t_ns is not None and not xml_utils.in_window(t_ns, window): + continue + + dst = _fmt(row, stream, "destination-node") + if not dst or needle not in dst.lower(): + continue + src = _fmt(row, stream, "source-node") + if not src: + continue + + matches[dst][src] += 1 + totals[dst] += 1 + + out = [] + for dst, count in totals.most_common(top_k): + out.append({ + "destination": dst, + "total_incoming_edges": count, + "top_sources": [ + {"source": s, "edges": c} + for s, c in matches[dst].most_common(top_k) + ], + }) + return {"available": True, "matches": out} + + +def _fmt(row, stream, key: str) -> str | None: + el = row.get(key) + if el is None: + return None + resolved = stream.resolve(el) + return resolved.get("fmt") or xml_utils.str_text(resolved) diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/correlate.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/correlate.py new file mode 100644 index 00000000..e29b8dea --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/correlate.py @@ -0,0 +1,179 @@ +"""Cross-lane correlation: for each hang and top-N worst hitches, aggregate +Time Profiler samples and SwiftUI updates whose timestamps fall inside the +event window [start, start+duration]. Uses bisect so lookups stay O(log N) +per event. +""" +from __future__ import annotations + +from bisect import bisect_left, bisect_right +from collections import defaultdict +from typing import Any + + +def build(lanes: dict[str, dict], top_hitches: int = 5, top_symbols: int = 5) -> list[dict]: + """Produce a list of correlation entries. + + `lanes` is a dict keyed by lane name (time-profiler, hangs, hitches, + swiftui) of their analyzer outputs. + """ + tp = lanes.get("time-profiler") + hangs = lanes.get("hangs") + hitches = lanes.get("hitches") + swiftui = lanes.get("swiftui") + + tp_index = _build_time_profile_index(tp) + sui_events = (swiftui or {}).get("_events") if swiftui and swiftui.get("available") else None + + correlations: list[dict] = [] + + if hangs and hangs.get("available"): + for h in hangs.get("_events", []): + correlations.append( + _correlate_event( + trigger_lane="hangs", + start_ns=h["start_ns"], + end_ns=h["end_ns"], + extra={"hang_type": h["hang_type"]}, + tp_index=tp_index, + sui_events=sui_events, + top_symbols=top_symbols, + ) + ) + + if hitches and hitches.get("available"): + worst_hitches = hitches.get("_events", [])[:top_hitches] + for hi in worst_hitches: + correlations.append( + _correlate_event( + trigger_lane="hitches", + start_ns=hi["start_ns"], + end_ns=hi["end_ns"], + extra={ + "frame_duration_ms": hi["frame_duration_ms"], + "hitch_duration_ms": hi["hitch_duration_ms"], + }, + tp_index=tp_index, + sui_events=sui_events, + top_symbols=top_symbols, + ) + ) + + return correlations + + +# --- Internal ------------------------------------------------------------- + +def _build_time_profile_index(tp: dict | None): + if not tp or not tp.get("available"): + return None + samples = tp.get("_samples") or [] + if not samples: + return None + # Samples are already sorted by time in time_profiler.analyze. + times = [s["time_ns"] for s in samples] + return {"times": times, "samples": samples} + + +def _correlate_event( + trigger_lane: str, + start_ns: int, + end_ns: int, + extra: dict, + tp_index: dict | None, + sui_events: list[dict] | None, + top_symbols: int, +) -> dict[str, Any]: + entry: dict[str, Any] = { + "trigger": { + "lane": trigger_lane, + "start_ms": round(start_ns / 1_000_000, 2), + "end_ms": round(end_ns / 1_000_000, 2), + "duration_ms": round((end_ns - start_ns) / 1_000_000, 2), + **extra, + }, + } + + if tp_index is not None: + tp = _time_profile_hot_symbols( + tp_index, start_ns, end_ns, top_symbols + ) + duration_ns = end_ns - start_ns + # Sample rate is 1ms/sample on standard Time Profiler. If the window + # is N ms long we'd expect ~N main-thread samples if main was fully + # running; fewer means main was blocked (I/O, lock, etc.). + expected_if_running = max(1, duration_ns // 1_000_000) + coverage_pct = min(100.0, 100.0 * tp["samples_main"] / expected_if_running) + entry["time_profiler_main_thread"] = { + "samples_in_window": tp["samples_total"], + "samples_on_main": tp["samples_main"], + "main_running_coverage_pct": round(coverage_pct, 1), + "hot_symbols": tp["hot_symbols"], + } + + if sui_events is not None: + sui_overlap = _swiftui_overlaps(sui_events, start_ns, end_ns) + entry["swiftui_overlapping_updates"] = sui_overlap + + return entry + + +def _time_profile_hot_symbols( + tp_index: dict, start_ns: int, end_ns: int, top_n: int +) -> dict: + """Return main-thread hot symbols in the given window. + + Hang/hitch/SwiftUI correlations are all main-thread responsiveness + problems, so worker-thread symbols are noise. We also return a coverage + metric — when main was blocked on I/O or a lock, the window will have + far fewer samples than its duration would predict, and that signal is + what tells the agent "this was blocked, not CPU-bound". + """ + times = tp_index["times"] + samples = tp_index["samples"] + lo = bisect_left(times, start_ns) + hi = bisect_right(times, end_ns) + window = samples[lo:hi] + if not window: + return {"samples_total": 0, "samples_main": 0, "hot_symbols": []} + + main_samples = [s for s in window if s["is_main"]] + weight_by_symbol: dict[str, int] = defaultdict(int) + count_by_symbol: dict[str, int] = defaultdict(int) + for s in main_samples: + weight_by_symbol[s["leaf_symbol"]] += s["weight_ns"] + count_by_symbol[s["leaf_symbol"]] += 1 + total_weight = sum(weight_by_symbol.values()) or 1 + + ranked = sorted(weight_by_symbol.items(), key=lambda kv: kv[1], reverse=True) + hot = [] + for symbol, weight in ranked[:top_n]: + hot.append({ + "symbol": symbol, + "samples": count_by_symbol[symbol], + "weight_ms": round(weight / 1_000_000, 2), + "percent_of_main": round(100.0 * weight / total_weight, 2), + }) + return { + "samples_total": len(window), + "samples_main": len(main_samples), + "hot_symbols": hot, + } + + +def _swiftui_overlaps( + events: list[dict], start_ns: int, end_ns: int +) -> list[dict]: + # Events aren't guaranteed sorted by start_ns here (we sort by duration in + # swiftui.analyze). Linear scan; SwiftUI event counts are typically small. + out: list[dict] = [] + for e in events: + if e["end_ns"] < start_ns or e["start_ns"] > end_ns: + continue + out.append({ + "view": e["view"], + "duration_ms": e["duration_ms"], + "start_ms": e["start_ms"], + }) + # Worst first. + out.sort(key=lambda x: x["duration_ms"], reverse=True) + return out[:10] diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/events.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/events.py new file mode 100644 index 00000000..6ea8028e --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/events.py @@ -0,0 +1,291 @@ +"""Discovery helpers for os_log messages and os_signpost intervals. + +These let an agent locate a focus window (e.g. "after the log saying X", +"during signpost Y") before running the main lane analysis. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from . import xctrace, xml_utils + +OS_LOG_SCHEMA = "os-log" +OS_SIGNPOST_SCHEMA = "os-signpost" +OS_SIGNPOST_INTERVAL_SCHEMA = "os-signpost-interval" + + +def list_logs( + trace_path: Path, + toc_schemas: frozenset[str], + subsystem: str | None = None, + category: str | None = None, + message_contains: str | None = None, + message_type: str | None = None, + limit: int | None = None, + window_ns: tuple[int, int] | None = None, + run: int = 1, +) -> list[dict[str, Any]]: + """Return os_log entries, optionally filtered. Case-insensitive contains. + + `limit` counts *post-filter* matches — including the window filter — so + the caller gets N matching logs inside the window rather than the first + N matching logs that might all fall outside it. + """ + if OS_LOG_SCHEMA not in toc_schemas: + return [] + xml_bytes = xctrace.export_schema(trace_path, OS_LOG_SCHEMA, run=run) + stream = xml_utils.RowStream(xml_bytes) + needle = message_contains.lower() if message_contains else None + + out: list[dict[str, Any]] = [] + for row in stream: + time_el = row.get("time") + if time_el is None: + continue + time_ns = xml_utils.int_text(stream.resolve(time_el)) + if time_ns is None: + continue + if not xml_utils.in_window(time_ns, window_ns): + continue + + sub = _str_of(row, stream, "subsystem") + cat = _str_of(row, stream, "category") + typ = _str_of(row, stream, "message-type") + fmt = _str_of(row, stream, "format-string") + msg = _str_of(row, stream, "message") or fmt + + if subsystem and (sub or "") != subsystem: + continue + if category and (cat or "") != category: + continue + if message_type and (typ or "") != message_type: + continue + if needle and needle not in (msg or "").lower() and needle not in (fmt or "").lower(): + continue + + process_el = row.get("process") + process = ( + xml_utils.extract_process(process_el, stream).get("name") + if process_el is not None else None + ) + + out.append({ + "time_ns": time_ns, + "time_ms": round(time_ns / 1_000_000, 3), + "type": typ, + "subsystem": sub, + "category": cat, + "process": process, + "message": msg, + "format_string": fmt, + }) + if limit is not None and len(out) >= limit: + break + + out.sort(key=lambda e: e["time_ns"]) + return out + + +def list_signposts( + trace_path: Path, + toc_schemas: frozenset[str], + name_contains: str | None = None, + subsystem: str | None = None, + category: str | None = None, + window_ns: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, list[dict[str, Any]]]: + """Return signpost intervals (paired begin/end) plus single-point events. + + Shape: { "intervals": [...], "events": [...] }. Intervals have + start_ms/end_ms/duration_ms; events have a single time_ms. + + Reads two complementary schemas: + * `os-signpost-interval`: already-paired intervals (this is where + user-emitted signposts like com.example.MyApp typically land). + * `os-signpost`: raw begin/end/event rows; we pair begins with ends + ourselves and fall back to point events for unpaired rows. Most + Apple-framework signposts (CloudKit, AppKit, …) live here. + + Filters are AND-combined. `name_contains` is a case-insensitive substring + match. `window_ns` keeps intervals that overlap the window (not strict + containment) and point events whose timestamp falls inside it. + """ + # The two signpost schemas overlap: every paired begin/end in `os-signpost` + # also shows up as a row in `os-signpost-interval`. To avoid duplicates we + # prefer the pre-paired schema for intervals and only mine `os-signpost` + # for point events (and for begin/end pairing as a fallback when the + # interval schema is missing — older traces). + intervals: list[dict[str, Any]] = [] + events: list[dict[str, Any]] = [] + + has_intervals = OS_SIGNPOST_INTERVAL_SCHEMA in toc_schemas + if has_intervals: + intervals.extend(_read_interval_schema(trace_path, run=run)) + + if OS_SIGNPOST_SCHEMA in toc_schemas: + more_intervals, more_events = _read_event_schema(trace_path, run=run) + if not has_intervals: + intervals.extend(more_intervals) + events.extend(more_events) + + intervals.sort(key=lambda i: i["start_ns"]) + events.sort(key=lambda e: e["time_ns"]) + + needle = name_contains.lower() if name_contains else None + + def _matches(entry: dict) -> bool: + if subsystem and (entry.get("subsystem") or "") != subsystem: + return False + if category and (entry.get("category") or "") != category: + return False + if needle and needle not in (entry.get("name") or "").lower(): + return False + return True + + if subsystem or category or needle: + intervals = [i for i in intervals if _matches(i)] + events = [e for e in events if _matches(e)] + + if window_ns is not None: + s, e = window_ns + intervals = [ + i for i in intervals + if not (i["end_ns"] < s or i["start_ns"] > e) + ] + events = [ev for ev in events if s <= ev["time_ns"] <= e] + + return {"intervals": intervals, "events": events} + + +def _read_interval_schema(trace_path: Path, run: int = 1) -> list[dict[str, Any]]: + """Read the os-signpost-interval schema (pre-paired intervals).""" + xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_INTERVAL_SCHEMA, run=run) + stream = xml_utils.RowStream(xml_bytes) + + out: list[dict[str, Any]] = [] + for row in stream: + start_el = xml_utils.first_present(row, "start", "time") + dur_el = row.get("duration") + if start_el is None or dur_el is None: + continue + start_ns = xml_utils.int_text(stream.resolve(start_el)) + dur_ns = xml_utils.int_text(stream.resolve(dur_el)) + if start_ns is None or dur_ns is None: + continue + end_ns = start_ns + dur_ns + + name = _str_of(row, stream, "name") + sub = _str_of(row, stream, "subsystem") + cat = _str_of(row, stream, "category") + signpost_id = _str_of(row, stream, "identifier") or _str_of(row, stream, "signpost-id") + process_el = row.get("process") + process = ( + xml_utils.extract_process(process_el, stream).get("name") + if process_el is not None else None + ) + + out.append({ + "start_ns": start_ns, + "end_ns": end_ns, + "duration_ns": dur_ns, + "start_ms": round(start_ns / 1_000_000, 3), + "end_ms": round(end_ns / 1_000_000, 3), + "duration_ms": round(dur_ns / 1_000_000, 3), + "name": name, + "subsystem": sub, + "category": cat, + "process": process, + "signpost_id": signpost_id, + }) + return out + + +def _read_event_schema( + trace_path: Path, + run: int = 1, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Read the os-signpost schema and pair begin/end rows into intervals.""" + xml_bytes = xctrace.export_schema(trace_path, OS_SIGNPOST_SCHEMA, run=run) + stream = xml_utils.RowStream(xml_bytes) + + pending: dict[tuple, dict] = {} + intervals: list[dict[str, Any]] = [] + events: list[dict[str, Any]] = [] + + for row in stream: + time_el = xml_utils.first_present(row, "time", "start") + if time_el is None: + continue + time_ns = xml_utils.int_text(stream.resolve(time_el)) + if time_ns is None: + continue + + name = _str_of(row, stream, "name") + sub = _str_of(row, stream, "subsystem") + cat = _str_of(row, stream, "category") + event_type = _str_of(row, stream, "event-type") or _str_of(row, stream, "message-type") + signpost_id = _str_of(row, stream, "signpost-id") or _str_of(row, stream, "identifier") + process_el = row.get("process") + process = ( + xml_utils.extract_process(process_el, stream).get("name") + if process_el is not None else None + ) + + key = (process, sub, cat, name, signpost_id) + etype = (event_type or "").lower() + + if etype in ("begin", "interval begin", "start"): + pending[key] = {"start_ns": time_ns, "name": name, + "subsystem": sub, "category": cat, + "process": process, "signpost_id": signpost_id} + elif etype in ("end", "interval end", "stop"): + start = pending.pop(key, None) + if start is not None: + dur_ns = time_ns - start["start_ns"] + intervals.append({ + **start, + "end_ns": time_ns, + "duration_ns": dur_ns, + "start_ms": round(start["start_ns"] / 1_000_000, 3), + "end_ms": round(time_ns / 1_000_000, 3), + "duration_ms": round(dur_ns / 1_000_000, 3), + }) + else: + events.append(_point_event(time_ns, name, sub, cat, + process, signpost_id, event_type)) + else: + events.append(_point_event(time_ns, name, sub, cat, + process, signpost_id, event_type)) + + # Unclosed begins are surfaced as point events so nothing is silently dropped. + for info in pending.values(): + events.append(_point_event(info["start_ns"], info["name"], + info["subsystem"], info["category"], + info["process"], info["signpost_id"], + "Begin (unclosed)")) + + return intervals, events + + +def _point_event(time_ns, name, subsystem, category, process, signpost_id, event_type): + return { + "time_ns": time_ns, + "time_ms": round(time_ns / 1_000_000, 3), + "name": name, + "subsystem": subsystem, + "category": category, + "process": process, + "signpost_id": signpost_id, + "event_type": event_type, + } + + +def _str_of(row, stream, key): + el = row.get(key) + if el is None: + return None + resolved = stream.resolve(el) + txt = xml_utils.str_text(resolved) or resolved.get("fmt") + return txt diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hangs.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hangs.py new file mode 100644 index 00000000..8cfc75c3 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hangs.py @@ -0,0 +1,108 @@ +"""Hangs lane parser (schema `potential-hangs`). + +The schema lacks inline backtraces — stacks come from Time Profiler samples +that overlap each hang's window. Correlation is done later in correlate.py. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from . import xctrace, xml_utils + +PREFERRED_SCHEMAS = ("potential-hangs",) +FALLBACK_SCHEMAS = ("main-thread-hang", "hang", "hangs") + + +def analyze( + trace_path: Path, + toc_schemas: frozenset[str], + top_n: int = 10, + window: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, Any]: + schema = _pick_schema(toc_schemas) + if schema is None: + return { + "lane": "hangs", + "available": False, + "notes": ["Hangs data not present in trace."], + } + + xml_bytes = xctrace.export_schema(trace_path, schema, run=run) + stream = xml_utils.RowStream(xml_bytes) + + hangs: list[dict] = [] + for row in stream: + start_el = row.get("start") + dur_el = row.get("duration") + type_el = row.get("hang-type") + thread_el = row.get("thread") + if start_el is None or dur_el is None: + continue + start_ns = xml_utils.int_text(stream.resolve(start_el)) + duration_ns = xml_utils.int_text(stream.resolve(dur_el)) + if start_ns is None or duration_ns is None: + continue + if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window): + continue + hang_type = xml_utils.str_text(stream.resolve(type_el)) if type_el is not None else None + thread = xml_utils.extract_thread(thread_el, stream) if thread_el is not None else None + + hangs.append({ + "start_ns": start_ns, + "duration_ns": duration_ns, + "end_ns": start_ns + duration_ns, + "duration_ms": round(duration_ns / 1_000_000, 2), + "start_ms": round(start_ns / 1_000_000, 2), + "hang_type": hang_type or "Hang", + "thread": thread, + }) + + hangs.sort(key=lambda h: h["duration_ns"], reverse=True) + + total_ms = sum(h["duration_ms"] for h in hangs) + worst = hangs[0] if hangs else None + + # Severity buckets per Apple docs (Microhang: 250ms–500ms, Hang: ≥500ms). + # We bucket by raw duration so the agent can reason about it. + buckets = {"lt_250ms": 0, "250ms_1s": 0, "gt_1s": 0} + for h in hangs: + if h["duration_ms"] < 250: + buckets["lt_250ms"] += 1 + elif h["duration_ms"] < 1000: + buckets["250ms_1s"] += 1 + else: + buckets["gt_1s"] += 1 + + top_offenders = [ + { + "start_ms": h["start_ms"], + "duration_ms": h["duration_ms"], + "hang_type": h["hang_type"], + "thread": (h["thread"] or {}).get("name", ""), + } + for h in hangs[:top_n] + ] + + return { + "lane": "hangs", + "available": True, + "schema_used": schema, + "metrics": { + "count": len(hangs), + "total_duration_ms": round(total_ms, 2), + "worst_duration_ms": worst["duration_ms"] if worst else 0, + "severity_buckets": buckets, + }, + "top_offenders": top_offenders, + "notes": [], + "_events": hangs, # retained for correlation + } + + +def _pick_schema(available: frozenset[str]) -> str | None: + for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS: + if s in available: + return s + return None diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hitches.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hitches.py new file mode 100644 index 00000000..a1e769d2 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/hitches.py @@ -0,0 +1,145 @@ +"""Animation hitches lane parser. + +Xcode 26 schema `hitches` columns: start, duration (hitch time), process, +is-system, swap-id, label, display, narrative-description. The +narrative-description field carries Apple's own attribution (e.g. +"Potentially expensive app update(s)") which is the highest-signal column. +""" +from __future__ import annotations + +from collections import Counter +from pathlib import Path +from typing import Any + +from . import xctrace, xml_utils + +CANDIDATE_SCHEMAS = ("hitches", "animation-hitch", "hitch") + +START_KEYS = ("start", "time", "sample-time") +DURATION_KEYS = ("duration", "hitch-duration", "frame-duration") + + +def analyze( + trace_path: Path, + toc_schemas: frozenset[str], + top_n: int = 10, + window: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, Any]: + schema = _pick_schema(toc_schemas) + if schema is None: + return { + "lane": "hitches", + "available": False, + "notes": ["Animation hitches not present in trace."], + } + + xml_bytes = xctrace.export_schema(trace_path, schema, run=run) + stream = xml_utils.RowStream(xml_bytes) + + events: list[dict] = [] + narrative_counts: Counter[str] = Counter() + system_count = 0 + + for row in stream: + start_ns = _first_int(row, stream, START_KEYS) + duration_ns = _first_int(row, stream, DURATION_KEYS) + if start_ns is None or duration_ns is None: + continue + if not xml_utils.event_overlaps_window(start_ns, start_ns + duration_ns, window): + continue + + process_el = row.get("process") + process = ( + xml_utils.extract_process(process_el, stream) + if process_el is not None else None + ) + + narrative_el = row.get("narrative-description") + narrative = xml_utils.str_text(stream.resolve(narrative_el)) if narrative_el is not None else None + if narrative: + narrative_counts[narrative] += 1 + + is_system_el = row.get("is-system") + is_system = _bool_text(stream.resolve(is_system_el)) if is_system_el is not None else None + if is_system: + system_count += 1 + + events.append({ + "start_ns": start_ns, + "end_ns": start_ns + duration_ns, + "duration_ns": duration_ns, + "hitch_duration_ns": duration_ns, # Xcode 26 `duration` == hitch time + "frame_duration_ns": None, + "hitch_duration_ms": round(duration_ns / 1_000_000, 2), + "frame_duration_ms": None, + "start_ms": round(start_ns / 1_000_000, 2), + "process": (process or {}).get("name"), + "narrative": narrative, + "is_system": bool(is_system) if is_system is not None else None, + }) + + events.sort(key=lambda e: e["duration_ns"], reverse=True) + + total_hitch_ms = sum(e["hitch_duration_ms"] for e in events) + worst = events[0] if events else None + + per_process: dict[str, int] = {} + for e in events: + key = e["process"] or "unknown" + per_process[key] = per_process.get(key, 0) + 1 + + top_offenders = [ + { + "start_ms": e["start_ms"], + "hitch_duration_ms": e["hitch_duration_ms"], + "frame_duration_ms": e["frame_duration_ms"], + "process": e["process"], + "narrative": e["narrative"], + "is_system": e["is_system"], + } + for e in events[:top_n] + ] + + return { + "lane": "hitches", + "available": True, + "schema_used": schema, + "metrics": { + "count": len(events), + "total_hitch_ms": round(total_hitch_ms, 2), + "worst_hitch_ms": worst["hitch_duration_ms"] if worst else 0, + "per_process": per_process, + "system_hitches": system_count, + "app_hitches": len(events) - system_count, + "narrative_breakdown": dict(narrative_counts.most_common()), + }, + "top_offenders": top_offenders, + "notes": [], + "_events": events, + } + + +def _pick_schema(available: frozenset[str]) -> str | None: + for s in CANDIDATE_SCHEMAS: + if s in available: + return s + return None + + +def _first_int(row, stream, keys): + for key in keys: + el = row.get(key) + if el is None: + continue + val = xml_utils.int_text(stream.resolve(el)) + if val is not None: + return val + return None + + +def _bool_text(elem) -> bool | None: + txt = xml_utils.str_text(elem) + if txt is None: + return None + return txt.strip() in ("1", "true", "True", "YES", "Yes") diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/summary.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/summary.py new file mode 100644 index 00000000..5927db72 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/summary.py @@ -0,0 +1,243 @@ +"""Markdown summary renderer for the combined trace analysis.""" +from __future__ import annotations + + +def render(result: dict) -> str: + lines: list[str] = [] + trace = result.get("trace", "?") + header = result.get("xctrace_version") or "" + template = result.get("template") or "" + duration_s = result.get("duration_s") + lines.append(f"# Instruments Trace Analysis") + meta = [p for p in [f"Trace: `{trace}`", header, template] if p] + lines.append(" • ".join(meta)) + if duration_s is not None: + lines.append(f"Recording duration: {duration_s:.2f}s") + lines.append("") + + lanes_by_name = {lane["lane"]: lane for lane in result.get("lanes", [])} + + _render_time_profiler(lines, lanes_by_name.get("time-profiler")) + _render_hangs(lines, lanes_by_name.get("hangs")) + _render_hitches(lines, lanes_by_name.get("hitches")) + _render_swiftui(lines, lanes_by_name.get("swiftui")) + _render_causes(lines, lanes_by_name.get("swiftui-causes")) + _render_correlations(lines, result.get("correlations", [])) + + return "\n".join(lines).rstrip() + "\n" + + +def _skipped_block(title: str, lane: dict | None) -> list[str]: + if lane is None: + return [f"## {title} — skipped (lane module not run)", ""] + notes = lane.get("notes") or [] + note_text = f" — {notes[0]}" if notes else "" + return [f"## {title} — skipped{note_text}", ""] + + +def _render_time_profiler(lines: list[str], lane: dict | None) -> None: + if not lane or not lane.get("available"): + lines.extend(_skipped_block("Time Profiler", lane)) + return + m = lane["metrics"] + lines.append( + f"## Time Profiler — {m['total_samples']:,} samples, " + f"{m['total_weight_ms']:.0f}ms CPU time" + ) + if m.get("processes"): + lines.append(f"Processes: {', '.join(m['processes'])}") + lines.append("") + if lane["top_offenders"]: + lines.append("Top offenders:") + for i, o in enumerate(lane["top_offenders"], 1): + lines.append( + f"{i}. `{_truncate(o['symbol'], 90)}` — " + f"{o['percent']:.1f}% ({o['weight_ms']:.0f}ms, " + f"{o['samples']} samples, {_short_thread(o['thread'])})" + ) + for note in lane.get("notes") or []: + lines.append(f"> {note}") + lines.append("") + + +def _render_hangs(lines: list[str], lane: dict | None) -> None: + if not lane or not lane.get("available"): + lines.extend(_skipped_block("Hangs", lane)) + return + m = lane["metrics"] + buckets = m["severity_buckets"] + lines.append( + f"## Hangs — {m['count']} hangs, {m['total_duration_ms']:.0f}ms total, " + f"worst {m['worst_duration_ms']:.0f}ms" + ) + lines.append( + f"Severity: <250ms={buckets['lt_250ms']}, " + f"250ms–1s={buckets['250ms_1s']}, >1s={buckets['gt_1s']}" + ) + lines.append("") + for i, h in enumerate(lane["top_offenders"], 1): + lines.append( + f"{i}. {h['duration_ms']:.0f}ms {h['hang_type']} at " + f"{h['start_ms']:.2f}ms on {_short_thread(h['thread'])}" + ) + lines.append("") + + +def _render_hitches(lines: list[str], lane: dict | None) -> None: + if not lane or not lane.get("available"): + lines.extend(_skipped_block("Animation Hitches", lane)) + return + m = lane["metrics"] + lines.append( + f"## Animation Hitches — {m['count']} hitches, " + f"{m['total_hitch_ms']:.0f}ms total, worst {m['worst_hitch_ms']:.0f}ms" + ) + if m.get("per_process"): + pp = ", ".join(f"{k}={v}" for k, v in m["per_process"].items()) + lines.append(f"By process: {pp}") + lines.append("") + if m.get("narrative_breakdown"): + nb = ", ".join(f"{k}={v}" for k, v in m["narrative_breakdown"].items() if k) + if nb: + lines.append(f"Apple attribution: {nb}") + if m.get("system_hitches") is not None: + lines.append( + f"System vs app: system={m['system_hitches']}, app={m['app_hitches']}" + ) + lines.append("") + for i, h in enumerate(lane["top_offenders"], 1): + narrative = f" — {h['narrative']}" if h.get("narrative") else "" + src = " [system]" if h.get("is_system") else "" + proc = f" ({h['process']})" if h.get("process") else "" + lines.append( + f"{i}. {h['hitch_duration_ms']:.0f}ms at {h['start_ms']:.2f}ms" + f"{proc}{src}{narrative}" + ) + lines.append("") + + +def _render_swiftui(lines: list[str], lane: dict | None) -> None: + if not lane or not lane.get("available"): + lines.extend(_skipped_block("SwiftUI", lane)) + return + m = lane["metrics"] + lines.append( + f"## SwiftUI — {m['total_events']:,} updates across " + f"{m['unique_views']} views, {m['total_duration_ms']:.0f}ms total" + ) + if m.get("severity_breakdown"): + sb = ", ".join(f"{k}={v}" for k, v in m["severity_breakdown"].items()) + lines.append(f"Severity: {sb}") + if m.get("update_type_breakdown"): + ub = ", ".join(f"{k}={v}" for k, v in m["update_type_breakdown"].items()) + lines.append(f"Update types: {ub}") + lines.append("") + if lane["top_offenders"]: + lines.append("Heaviest views (by total body time):") + for i, v in enumerate(lane["top_offenders"], 1): + lines.append( + f"{i}. `{_truncate(v['view'], 80)}` — {v['total_ms']:.0f}ms total, " + f"{v['count']} updates (avg {v['avg_ms']:.2f}ms)" + ) + if lane.get("high_severity_events"): + lines.append("") + lines.append("High-severity updates:") + for i, e in enumerate(lane["high_severity_events"][:5], 1): + cat = f" [{e['category']}]" if e.get("category") else "" + lines.append( + f"{i}. `{_truncate(e['view'], 60)}` — " + f"{e['severity']} ({e['duration_ms']:.2f}ms at {e['start_ms']:.2f}ms){cat}" + ) + lines.append("") + + +def _render_causes(lines: list[str], lane: dict | None) -> None: + if not lane or not lane.get("available"): + lines.extend(_skipped_block("SwiftUI Cause Graph", lane)) + return + m = lane["metrics"] + lines.append( + f"## SwiftUI Cause Graph — {m['total_edges']:,} edges, " + f"{m['unique_sources']} sources → {m['unique_destinations']} destinations" + ) + lines.append("") + if lane.get("top_sources"): + lines.append("Top sources (who's driving the most updates):") + for i, s in enumerate(lane["top_sources"][:5], 1): + lines.append(f"{i}. `{_truncate(s['source'], 80)}` — {s['edges']:,} edges") + for d in s["top_destinations"][:3]: + lines.append( + f" → `{_truncate(d['destination'], 70)}` {d['edges']:,}" + ) + if lane.get("top_destinations"): + lines.append("") + lines.append("Top destinations (who's being invalidated most):") + for i, d in enumerate(lane["top_destinations"][:5], 1): + lines.append(f"{i}. `{_truncate(d['destination'], 80)}` — {d['edges']:,} edges") + for s in d["top_sources"][:3]: + lines.append( + f" ← `{_truncate(s['source'], 70)}` {s['edges']:,}" + ) + lines.append("") + + +def _render_correlations(lines: list[str], correlations: list[dict]) -> None: + if not correlations: + return + lines.append("## Correlations") + lines.append("") + for c in correlations: + t = c["trigger"] + head = ( + f"- **{t['lane']}** at {t['start_ms']:.2f}ms " + f"({t['duration_ms']:.0f}ms)" + ) + if t.get("hang_type"): + head += f" — {t['hang_type']}" + lines.append(head) + + tp = c.get("time_profiler_main_thread") + if tp is not None: + cov = tp["main_running_coverage_pct"] + lines.append( + f" - Main thread: {tp['samples_on_main']} running samples " + f"({cov:.0f}% coverage — " + f"{'blocked' if cov < 25 else 'mostly running'})" + ) + for s in tp["hot_symbols"][:3]: + lines.append( + f" · `{_truncate(s['symbol'], 80)}` " + f"{s['percent_of_main']:.0f}% ({s['samples']} samples)" + ) + if not tp["hot_symbols"]: + lines.append(" · no main-thread samples in window") + + sui = c.get("swiftui_overlapping_updates") + if sui: + for s in sui[:3]: + lines.append( + f" - SwiftUI: `{s['view']}` {s['duration_ms']:.2f}ms " + f"(at {s['start_ms']:.2f}ms)" + ) + lines.append("") + + +def _short_thread(name: str) -> str: + if not name: + return "" + if name.startswith("Main Thread") or name == "main": + return "main" + # "NowPlaying Gigs (0x251990d) (NowPlaying Gigs, pid: 28401)" -> "tid 0x251990d" + tid_start = name.find("(0x") + if tid_start != -1: + start = tid_start + 1 + end = name.find(")", start) + if end != -1: + return f"tid {name[start:end]}" + return name[:40] + + +def _truncate(s: str, n: int) -> str: + if len(s) <= n: + return s + return s[: n - 1] + "…" diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/swiftui.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/swiftui.py new file mode 100644 index 00000000..9c359dde --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/swiftui.py @@ -0,0 +1,195 @@ +"""SwiftUI lane parser (Xcode 26+). + +Primary schema is `swiftui-updates` with columns: start, duration, id, +update-type, allocations, description, category, view-hierarchy, module, +view-name, process, thread, root-causes, severity, cause-graph-node, +full-cause-graph-node. + +We aggregate by view-name across all SwiftUI schemas (future-proofing against +schema renames) and break severity out separately so the agent can focus on +the high-severity rows. +""" +from __future__ import annotations + +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +from . import xctrace, xml_utils + +START_KEYS = ("start", "time", "sample-time", "timestamp") +DURATION_KEYS = ("duration", "body-duration", "update-duration") +VIEW_KEYS = ("view-name", "view", "view-type", "name", "type") +MODULE_KEYS = ("module",) +CATEGORY_KEYS = ("category",) +UPDATE_TYPE_KEYS = ("update-type",) +SEVERITY_KEYS = ("severity",) +DESCRIPTION_KEYS = ("description",) + +HIGH_SEVERITIES = {"High", "Very High", "Severe", "Critical"} + +# Ongoing / unterminated updates carry a sentinel duration (≈ UINT64_MAX-ish). +# Any duration longer than an hour is almost certainly that sentinel and would +# break aggregates + the correlation overlap check. +_SENTINEL_DURATION_NS = 60 * 60 * 1_000_000_000 # 1 hour + + +def analyze( + trace_path: Path, + toc_schemas: frozenset[str], + top_n: int = 10, + window: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, Any]: + schemas = sorted( + s for s in toc_schemas + if s.startswith("swiftui") and not s.endswith("-causes") + ) + if not schemas: + return { + "lane": "swiftui", + "available": False, + "notes": ["SwiftUI lane not in trace (Xcode 26+ SwiftUI template required)."], + } + + events: list[dict] = [] + per_view_total_ns: dict[str, int] = defaultdict(int) + per_view_count: dict[str, int] = defaultdict(int) + severity_counts: Counter[str] = Counter() + update_type_counts: Counter[str] = Counter() + category_counts: Counter[str] = Counter() + + for schema in schemas: + xml_bytes = xctrace.export_schema(trace_path, schema, run=run) + stream = xml_utils.RowStream(xml_bytes) + for row in stream: + start_ns = _first_int(row, stream, START_KEYS) + dur_ns = _first_int(row, stream, DURATION_KEYS) + if start_ns is None or dur_ns is None: + continue + if dur_ns < 0 or dur_ns > _SENTINEL_DURATION_NS: + # Unterminated / ongoing update; skip so it doesn't poison + # totals and the correlation overlap check. + continue + if not xml_utils.event_overlaps_window(start_ns, start_ns + dur_ns, window): + continue + + view = _first_str(row, stream, VIEW_KEYS) + module = _first_str(row, stream, MODULE_KEYS) + category = _first_str(row, stream, CATEGORY_KEYS) + update_type = _first_str(row, stream, UPDATE_TYPE_KEYS) + severity = _first_str(row, stream, SEVERITY_KEYS) + description = _first_str(row, stream, DESCRIPTION_KEYS) + # Fall back through description → category → update-type so the + # agent sees "EnvironmentWriter: RootEnvironment" instead of + # "" when SwiftUI doesn't record a view type. + if not view: + view = description or category or update_type or "" + + per_view_total_ns[view] += dur_ns + per_view_count[view] += 1 + if severity: + severity_counts[severity] += 1 + if update_type: + update_type_counts[update_type] += 1 + if category: + category_counts[category] += 1 + + events.append({ + "schema": schema, + "start_ns": start_ns, + "end_ns": start_ns + dur_ns, + "duration_ns": dur_ns, + "duration_ms": round(dur_ns / 1_000_000, 2), + "start_ms": round(start_ns / 1_000_000, 2), + "view": view, + "module": module, + "category": category, + "update_type": update_type, + "severity": severity, + "description": description, + }) + + events.sort(key=lambda e: e["duration_ns"], reverse=True) + + top_by_total = sorted( + per_view_total_ns.items(), key=lambda kv: kv[1], reverse=True + )[:top_n] + top_offenders = [ + { + "view": view, + "total_ms": round(total_ns / 1_000_000, 2), + "count": per_view_count[view], + "avg_ms": round(total_ns / per_view_count[view] / 1_000_000, 2), + } + for view, total_ns in top_by_total + ] + + high_severity = [ + { + "view": e["view"], + "severity": e["severity"], + "duration_ms": e["duration_ms"], + "start_ms": e["start_ms"], + "category": e["category"], + "update_type": e["update_type"], + "description": e["description"], + } + for e in events if e["severity"] in HIGH_SEVERITIES + ][:top_n] + + longest = [ + { + "view": e["view"], + "duration_ms": e["duration_ms"], + "start_ms": e["start_ms"], + "category": e["category"], + "update_type": e["update_type"], + "severity": e["severity"], + } + for e in events[:top_n] + ] + + return { + "lane": "swiftui", + "available": True, + "schemas_used": schemas, + "metrics": { + "total_events": len(events), + "unique_views": len(per_view_total_ns), + "total_duration_ms": round( + sum(per_view_total_ns.values()) / 1_000_000, 2 + ), + "severity_breakdown": dict(severity_counts.most_common()), + "update_type_breakdown": dict(update_type_counts.most_common()), + "category_breakdown": dict(category_counts.most_common()), + }, + "top_offenders": top_offenders, + "longest_single_events": longest, + "high_severity_events": high_severity, + "notes": [], + "_events": events, + } + + +def _first_int(row, stream, keys): + for key in keys: + el = row.get(key) + if el is None: + continue + val = xml_utils.int_text(stream.resolve(el)) + if val is not None: + return val + return None + + +def _first_str(row, stream, keys): + for key in keys: + el = row.get(key) + if el is None: + continue + resolved = stream.resolve(el) + txt = xml_utils.str_text(resolved) or resolved.get("fmt") + if txt: + return txt + return None diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/time_profiler.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/time_profiler.py new file mode 100644 index 00000000..72059067 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/time_profiler.py @@ -0,0 +1,135 @@ +"""Time Profiler lane parser (schema `time-profile`). + +Aggregates CPU samples by leaf symbol, keeps per-sample rows so that other +lanes can correlate by timestamp window. +""" +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import Any + +from . import xctrace, xml_utils + +PREFERRED_SCHEMAS = ("time-profile",) +FALLBACK_SCHEMAS = ("time-sample",) # no symbolication; used only if nothing else + + +def analyze( + trace_path: Path, + toc_schemas: frozenset[str], + top_n: int = 10, + window: tuple[int, int] | None = None, + run: int = 1, +) -> dict[str, Any]: + schema = _pick_schema(toc_schemas) + if schema is None: + return { + "lane": "time-profiler", + "available": False, + "notes": ["Time Profiler data not present in trace."], + } + + xml_bytes = xctrace.export_schema(trace_path, schema, run=run) + stream = xml_utils.RowStream(xml_bytes) + + samples: list[dict] = [] + symbol_weight: dict[str, int] = defaultdict(int) + symbol_samples: dict[str, int] = defaultdict(int) + symbol_thread: dict[str, str] = {} + processes: set[str] = set() + total_weight = 0 + min_time: int | None = None + max_time: int | None = None + + for row in stream: + time_el = row.get("time") + weight_el = row.get("weight") + thread_el = row.get("thread") + stack_el = row.get("stack") + if stack_el is None or time_el is None or thread_el is None: + continue + + sample_time_ns = xml_utils.int_text(stream.resolve(time_el)) + if not xml_utils.in_window(sample_time_ns, window): + continue + weight_ns = xml_utils.int_text(stream.resolve(weight_el)) or 0 + frames = xml_utils.extract_backtrace(stack_el, stream, max_frames=20) + if not frames: + continue + + thread = xml_utils.extract_thread(thread_el, stream) + process_name = (thread.get("process") or {}).get("name") + if process_name: + processes.add(process_name) + + leaf = xml_utils.top_symbol(frames) + symbol_weight[leaf] += weight_ns + symbol_samples[leaf] += 1 + symbol_thread.setdefault( + leaf, "main" if thread["is_main"] else thread.get("name", "") + ) + total_weight += weight_ns + + if sample_time_ns is not None: + min_time = sample_time_ns if min_time is None else min(min_time, sample_time_ns) + max_time = sample_time_ns if max_time is None else max(max_time, sample_time_ns) + + samples.append({ + "time_ns": sample_time_ns, + "weight_ns": weight_ns, + "thread_name": thread["name"], + "is_main": thread["is_main"], + "process": process_name, + "leaf_symbol": leaf, + "frames": frames[:5], + }) + + samples.sort(key=lambda s: s["time_ns"]) + + top = sorted( + symbol_weight.items(), key=lambda kv: kv[1], reverse=True + )[:top_n] + top_offenders = [ + { + "symbol": sym, + "weight_ns": w, + "weight_ms": round(w / 1_000_000, 2), + "samples": symbol_samples[sym], + "percent": round(100.0 * w / total_weight, 2) if total_weight else 0.0, + "thread": symbol_thread.get(sym, ""), + } + for sym, w in top + ] + + notes: list[str] = [] + if schema in FALLBACK_SCHEMAS: + notes.append( + f"Using fallback schema `{schema}`; backtraces may be unsymbolicated." + ) + + return { + "lane": "time-profiler", + "available": True, + "schema_used": schema, + "metrics": { + "total_samples": len(samples), + "total_weight_ns": total_weight, + "total_weight_ms": round(total_weight / 1_000_000, 2), + "window_start_ns": min_time, + "window_end_ns": max_time, + "processes": sorted(processes), + }, + "top_offenders": top_offenders, + "notes": notes, + # Internal: retained for correlation. Stripped before JSON emission + # if --slim is requested by the orchestrator. + "_samples": samples, + } + + +def _pick_schema(available: frozenset[str]) -> str | None: + for s in PREFERRED_SCHEMAS + FALLBACK_SCHEMAS: + if s in available: + return s + return None diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xctrace.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xctrace.py new file mode 100644 index 00000000..768839b4 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xctrace.py @@ -0,0 +1,117 @@ +"""Thin wrapper around the `xctrace` CLI.""" +from __future__ import annotations + +import subprocess +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class RunInfo: + """Per-run metadata and schemas. Instruments traces can hold multiple runs.""" + number: int + template_name: str | None + duration_s: float | None + start_date: str | None + end_date: str | None + schemas: frozenset[str] + + +@dataclass(frozen=True) +class TraceInfo: + xctrace_version: str + runs: tuple[RunInfo, ...] + + def get_run(self, number: int) -> RunInfo: + for r in self.runs: + if r.number == number: + return r + available = ", ".join(str(r.number) for r in self.runs) + raise KeyError(f"run {number} not in trace (available: {available})") + + +def version() -> str: + out = subprocess.run( + ["xctrace", "version"], capture_output=True, text=True, check=True + ) + return out.stdout.strip() + + +def toc(trace_path: Path) -> TraceInfo: + """Export the trace's table of contents and return per-run metadata. + + The TOC is small (a few KB) so we load it fully rather than streaming. + """ + xml_bytes = _run_export(trace_path, ["--toc"]) + root = ET.fromstring(xml_bytes) + + instruments = _find_text(root, ".//instruments-version") or "" + + runs: list[RunInfo] = [] + for run_el in root.iterfind("./run"): + number_attr = run_el.get("number") + if not number_attr: + continue + try: + number = int(number_attr) + except ValueError: + continue + if number <= 0: + continue + + schemas: set[str] = set() + for table in run_el.iterfind("./data/table"): + schema = table.get("schema") + if schema: + schemas.add(schema) + + summary = run_el.find("./info/summary") + if summary is not None: + template = _find_text(summary, "./template-name") + duration = _find_text(summary, "./duration") + start = _find_text(summary, "./start-date") + end = _find_text(summary, "./end-date") + else: + template = duration = start = end = None + + runs.append(RunInfo( + number=number, + template_name=template, + duration_s=float(duration) if duration else None, + start_date=start, + end_date=end, + schemas=frozenset(schemas), + )) + + runs.sort(key=lambda r: r.number) + return TraceInfo( + xctrace_version=instruments, + runs=tuple(runs), + ) + + +def export_schema(trace_path: Path, schema: str, run: int = 1) -> bytes: + """Export one schema's data as XML bytes from the given run. + + Callers are expected to iterparse the result rather than build a full tree + for large schemas (time-profile can be tens of MB). + """ + xpath = f'/trace-toc/run[@number="{run}"]/data/table[@schema="{schema}"]' + return _run_export(trace_path, ["--xpath", xpath]) + + +def _run_export(trace_path: Path, extra_args: list[str]) -> bytes: + cmd = ["xctrace", "export", "--input", str(trace_path), *extra_args] + proc = subprocess.run(cmd, capture_output=True, check=False) + if proc.returncode != 0: + raise RuntimeError( + f"xctrace export failed ({proc.returncode}): " + f"{proc.stderr.decode(errors='replace').strip()}" + ) + return proc.stdout + + +def _find_text(root: ET.Element, path: str) -> str | None: + el = root.find(path) + return el.text if el is not None and el.text else None diff --git a/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xml_utils.py b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xml_utils.py new file mode 100644 index 00000000..e18acf0b --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/instruments_parser/xml_utils.py @@ -0,0 +1,224 @@ +"""Streaming XML helpers for xctrace export output. + +Instruments XML deduplicates repeated values with `id`/`ref` attributes that +can span the whole document, so we stream rows with iterparse while keeping +a global id cache for later ref lookups. +""" +from __future__ import annotations + +import xml.etree.ElementTree as ET +from collections.abc import Iterator +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Column: + mnemonic: str # e.g. "time", "weight", "stack" + engineering_type: str # e.g. "sample-time", "weight", "tagged-backtrace" + + +class RowStream: + """Iterate elements of a single schema export. + + Yields `dict[str, Element]` keyed by column mnemonic. Elements inside a + yielded row are live ET elements (rooted in the id cache where applicable + so ref resolution via `resolve()` remains valid after the row is yielded). + """ + + def __init__(self, xml_bytes: bytes): + self._xml = xml_bytes + self.columns: list[Column] = [] + self._id_cache: dict[str, ET.Element] = {} + + def resolve(self, element: ET.Element) -> ET.Element: + """If the element is a ref, return the referenced element; else self.""" + ref = element.get("ref") + if ref is None: + return element + target = self._id_cache.get(ref) + if target is None: + return element # unresolved; return the ref element itself + return target + + def __iter__(self) -> Iterator[dict[str, ET.Element]]: + # iterparse fires `end` events once an element is fully parsed, so ids + # are visible to descendants via the cache. We only need `end` events; + # row bodies are reconstructed from the end element itself in _row_dict. + # + # NOTE: we intentionally don't call `elem.clear()` after yielding a row. + # Instruments' XML is a single shared doc where any row can `ref` an + # `id` defined earlier (threads, processes, stacks, metadata), and + # clearing would break those later lookups. The tradeoff is peak RAM + # ≈ document size. That's fine for typical traces up to a few hundred + # MB; very large exports may need a smarter pass that first indexes + # referenced ids and only retains those. + schema_seen = False + + context = ET.iterparse(_bytes_to_file(self._xml), events=("end",)) + for _event, elem in context: + eid = elem.get("id") + if eid is not None: + self._id_cache[eid] = elem + + if elem.tag == "schema" and not schema_seen: + self.columns = _parse_columns(elem) + schema_seen = True + continue + + if elem.tag == "row": + yield _row_dict(elem, self.columns) + # Do not clear elem — children referenced via id may still be needed. + + +def _parse_columns(schema_el: ET.Element) -> list[Column]: + cols: list[Column] = [] + for col in schema_el.findall("col"): + mnemonic = (col.findtext("mnemonic") or "").strip() + etype = (col.findtext("engineering-type") or "").strip() + if mnemonic: + cols.append(Column(mnemonic=mnemonic, engineering_type=etype)) + return cols + + +def _row_dict(row_el: ET.Element, cols: list[Column]) -> dict[str, ET.Element]: + # Row children map positionally to columns. marks a missing + # optional value for that column. + result: dict[str, ET.Element] = {} + children = list(row_el) + for idx, child in enumerate(children): + if idx >= len(cols): + break + if child.tag == "sentinel": + continue + result[cols[idx].mnemonic] = child + return result + + +def _bytes_to_file(data: bytes): + import io + return io.BytesIO(data) + + +# --- Extraction helpers --------------------------------------------------- + +def int_text(elem: ET.Element | None) -> int | None: + if elem is None or elem.text is None: + return None + try: + return int(elem.text) + except ValueError: + return None + + +def str_text(elem: ET.Element | None) -> str | None: + if elem is None or elem.text is None: + return None + return elem.text + + +def fmt_attr(elem: ET.Element | None) -> str | None: + """Return the human-readable `fmt` attribute if present.""" + if elem is None: + return None + return elem.get("fmt") + + +def extract_thread(thread_el: ET.Element, stream: RowStream) -> dict: + """Parse a element into name, tid, process dict. + + Handles ref-style threads by resolving through the stream's id cache. + """ + resolved = stream.resolve(thread_el) + name = resolved.get("fmt", "") + tid_el = resolved.find("tid") + process_el = resolved.find("process") + process = extract_process(process_el, stream) if process_el is not None else None + return { + "name": name, + "tid": int_text(tid_el), + "process": process, + "is_main": name.startswith("Main Thread") if name else False, + } + + +def extract_process(process_el: ET.Element, stream: RowStream) -> dict: + resolved = stream.resolve(process_el) + name = resolved.get("fmt", "") + pid_el = resolved.find("pid") + return { + "name": _clean_process_name(name), + "pid": int_text(pid_el), + } + + +def _clean_process_name(fmt: str) -> str: + # "NowPlaying Gigs (28401)" -> "NowPlaying Gigs" + if " (" in fmt and fmt.endswith(")"): + return fmt.rsplit(" (", 1)[0] + return fmt + + +def extract_backtrace( + bt_el: ET.Element, stream: RowStream, max_frames: int = 20 +) -> list[dict]: + """Return a list of frame dicts from a or . + + Frames are ordered leaf-first (top of stack first), matching Instruments' + display order. + """ + resolved = stream.resolve(bt_el) + inner = resolved.find("backtrace") + if inner is None: + inner = resolved + frames: list[dict] = [] + for frame_el in inner.findall("frame"): + f = stream.resolve(frame_el) + frames.append({ + "name": f.get("name") or "", + "addr": f.get("addr") or "", + }) + if len(frames) >= max_frames: + break + return frames + + +def top_symbol(frames: list[dict]) -> str: + """Pick the leaf symbol, falling back to addr if unsymbolicated.""" + if not frames: + return "" + first = frames[0] + return first.get("name") or first.get("addr") or "" + + +def first_present(row: dict, *keys: str) -> ET.Element | None: + """Return the first row column whose key exists. + + `row[key] or row[other_key]` is unsafe here: Element is falsy when it has + no children (a common case for leaf , , etc.), so + `or` short-circuits past valid leaf elements. This walks keys explicitly. + """ + for key in keys: + el = row.get(key) + if el is not None: + return el + return None + + +def in_window(time_ns: int | None, window: tuple[int, int] | None) -> bool: + """Return True if time_ns is inside [start, end] (inclusive), or window is None.""" + if window is None: + return True + if time_ns is None: + return False + start, end = window + return start <= time_ns <= end + + +def event_overlaps_window( + start_ns: int, end_ns: int, window: tuple[int, int] | None +) -> bool: + """Return True if [start, end] overlaps [window.start, window.end].""" + if window is None: + return True + w_start, w_end = window + return not (end_ns < w_start or start_ns > w_end) diff --git a/.cursor/skills/swiftui-expert-skill/scripts/record_trace.py b/.cursor/skills/swiftui-expert-skill/scripts/record_trace.py new file mode 100644 index 00000000..16cd69d7 --- /dev/null +++ b/.cursor/skills/swiftui-expert-skill/scripts/record_trace.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Record an Xcode Instruments .trace file via `xctrace record`. + +Three modes: + (default) Start a recording. Stops on Ctrl+C, stop-file, or time limit. + --list-devices Enumerate connected devices + simulators as JSON. + --list-templates Enumerate available Instruments templates as JSON. + +Attach vs launch vs all-processes is mutually exclusive and passed straight +through to xctrace. The default template is "SwiftUI" (matches the +SwiftUI template in Xcode 26+ — change with --template). + +Manual stop options, most to least automated: + * Send SIGINT (Ctrl+C) to this script — forwarded to xctrace, which + finalises the trace before exiting. + * Pass --stop-file PATH; when that file appears on disk, this script + sends SIGINT to xctrace. Useful for `Bash run_in_background` + workflows where there's no interactive terminal. + * Pass --time-limit 30s / 5m / etc. — xctrace stops itself. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import signal +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Record an Instruments .trace file.") + list_mode = parser.add_mutually_exclusive_group() + list_mode.add_argument("--list-devices", action="store_true", + help="List devices and simulators as JSON, then exit.") + list_mode.add_argument("--list-templates", action="store_true", + help="List template names as JSON, then exit.") + + parser.add_argument("--template", default="SwiftUI", + help="Template name (default: SwiftUI).") + parser.add_argument("--device", default=None, + help="Device name or UDID. Defaults to the host.") + parser.add_argument("--output", type=Path, default=None, + help="Output .trace path. Defaults to ./