From 906dd3ee9ada1232ecb85738d8adf701ff63f9fa Mon Sep 17 00:00:00 2001 From: David Choi Date: Sat, 26 Sep 2026 11:00:41 -0400 Subject: [PATCH] refactor how skills are saved in db --- .cursor/rules/alpha-migrations-in-0001.mdc | 10 + .../worktrees-inside-derrick-project.mdc | 12 + .../DBRepositoryPluginFactory.swift | 242 +++++++++++++++--- .../DBRepositoryPluginPurge.swift | 14 + .../Sources/DBRepository/DatabaseSchema.swift | 26 +- .../Migrations/0001_initial_schema.down.sql | 6 + .../Migrations/0001_initial_schema.up.sql | 70 ++++- .../Migrations/0002_schema_refresh.down.sql | 2 - .../Migrations/0002_schema_refresh.up.sql | 3 - .../0003_messaging_listening_since.down.sql | 2 - .../0003_messaging_listening_since.up.sql | 1 - .../0004_messaging_reply_threads.down.sql | 2 - .../0004_messaging_reply_threads.up.sql | 5 - .../Migrations/0005_news_readers.down.sql | 2 - .../Migrations/0005_news_readers.up.sql | 30 --- .../Migrations/0006_agent_profiles.down.sql | 3 - .../Migrations/0006_agent_profiles.up.sql | 17 -- .../0007_messaging_agent_handled.down.sql | 1 - .../0007_messaging_agent_handled.up.sql | 6 - ..._messaging_thread_default_profile.down.sql | 1 - ...08_messaging_thread_default_profile.up.sql | 1 - .../0009_drop_news_readers.down.sql | 30 --- .../Migrations/0009_drop_news_readers.up.sql | 2 - .../Migrations/0010_plugin_host_ui.down.sql | 1 - .../Migrations/0010_plugin_host_ui.up.sql | 5 - .../0011_messaging_agent_work.down.sql | 1 - .../0011_messaging_agent_work.up.sql | 9 - .../0012_agent_profile_capabilities.down.sql | 1 - .../0012_agent_profile_capabilities.up.sql | 1 - .../0013_agent_profile_alias.down.sql | 2 - .../0013_agent_profile_alias.up.sql | 2 - .../DBRepositoryTests/DBRepositoryTests.swift | 25 +- .../SchemaColumnRoundTripTests.swift | 234 +++++++++++++++++ .../MCPServer/PluginRuntimeToolModule.swift | 25 +- .../Tests/MCPServerTests/MCPServerTests.swift | 19 +- .../Factory/PluginFactoryImplementation.swift | 3 + .../MCPService/ModelProviderLimit.swift | 19 ++ .../PluginFactoryCreateFailureMessage.swift | 15 +- .../Plugin/PluginAccessAskPolicy.swift | 35 ++- .../Plugin/UI/PluginSkillDisclosure.swift | 9 + .../AppLayerServicesWireTests.swift | 11 + .../PluginSpecProcessionTests.swift | 24 ++ ui/MCPService/MCPServiceToolHost.swift | 47 ++-- .../Conversation/ConversationModel.swift | 24 +- .../Support/LLM/ConfigureScriptReviewer.swift | 9 +- .../Support/LLM/SummarizerConfig.swift | 9 +- ui/ui/Events/ModelProviderLimitCenter.swift | 21 ++ ui/ui/Plugins/PluginCreationController.swift | 16 ++ ui/ui/Session/ChatSessionStore.swift | 6 +- ui/ui/Views/ContentView.swift | 1 + 50 files changed, 796 insertions(+), 266 deletions(-) create mode 100644 .cursor/rules/alpha-migrations-in-0001.mdc create mode 100644 .cursor/rules/worktrees-inside-derrick-project.mdc delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql delete mode 100644 packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql create mode 100644 packages/DBRepository/Tests/DBRepositoryTests/SchemaColumnRoundTripTests.swift create mode 100644 packages/Structure/Sources/AppLayerServices/MCPService/ModelProviderLimit.swift create mode 100644 ui/ui/Events/ModelProviderLimitCenter.swift diff --git a/.cursor/rules/alpha-migrations-in-0001.mdc b/.cursor/rules/alpha-migrations-in-0001.mdc new file mode 100644 index 00000000..d23886dd --- /dev/null +++ b/.cursor/rules/alpha-migrations-in-0001.mdc @@ -0,0 +1,10 @@ +--- +description: During alpha, put every database schema change in migration 0001 +alwaysApply: true +--- + +# Alpha database migrations + +Derrick is in alpha and is not deployed. Until it leaves alpha, put every database schema change in `packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.up.sql` and the matching `0001_initial_schema.down.sql`. + +Do not add `0002` or any later migration. Keep `DatabaseSchema.latestVersion` at 1. A local database is wiped and recreated instead of upgraded in place. diff --git a/.cursor/rules/worktrees-inside-derrick-project.mdc b/.cursor/rules/worktrees-inside-derrick-project.mdc new file mode 100644 index 00000000..cbb6d092 --- /dev/null +++ b/.cursor/rules/worktrees-inside-derrick-project.mdc @@ -0,0 +1,12 @@ +--- +description: Create Derrick git worktrees inside derrick_project +alwaysApply: true +--- + +# Worktree location + +Create every Derrick git worktree inside `/Users/davidchoi/Work/derrick_project`, next to the main checkout. + +Example: `/Users/davidchoi/Work/derrick_project/derrick-plugin-skills`. + +Do not create worktrees in `/Users/davidchoi/Work`. diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift index 945db70f..61573ba8 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginFactory.swift @@ -21,27 +21,27 @@ public extension DBRepository { } return } - let skillsData = try JSONEncoder().encode(release.skillFiles) - let skillsJSON = String(decoding: skillsData, as: UTF8.self) let artifact = release.compiledArtifact.base64EncodedString() try withDatabaseHandle { handle in - try Self.execute(""" - INSERT INTO plugin_factory_releases ( - plugin_id, version, content_hash, manifest_json, runtime_json, - guest_source, artifact_base64, skill_files_json, review_summary, created_at - ) VALUES ( - \(quoted(release.pluginID)), - \(quoted(release.version)), - \(quoted(release.contentHash.rawValue)), - \(quoted(release.manifestJSON)), - \(quoted(release.runtimeJSON)), - \(quoted(release.guestSource)), - \(quoted(artifact)), - \(quoted(skillsJSON)), - \(quoted(release.reviewSummary)), - \(quoted(Self.iso8601Formatter().string(from: .now))) - ); - """, on: handle) + try Self.withImmediateTransaction(on: handle) { + try Self.execute(""" + INSERT INTO plugin_factory_releases ( + plugin_id, version, content_hash, manifest_json, runtime_json, + guest_source, artifact_base64, review_summary, created_at + ) VALUES ( + \(quoted(release.pluginID)), + \(quoted(release.version)), + \(quoted(release.contentHash.rawValue)), + \(quoted(release.manifestJSON)), + \(quoted(release.runtimeJSON)), + \(quoted(release.guestSource)), + \(quoted(artifact)), + \(quoted(release.reviewSummary)), + \(quoted(Self.iso8601Formatter().string(from: .now))) + ); + """, on: handle) + try self.insertSkillFiles(release, on: handle) + } } } @@ -49,7 +49,7 @@ public extension DBRepository { try withDatabaseHandle { handle in let sql = """ SELECT plugin_id, version, content_hash, manifest_json, runtime_json, - guest_source, artifact_base64, skill_files_json, review_summary + guest_source, artifact_base64, review_summary FROM plugin_factory_releases WHERE plugin_id = \(quoted(pluginID)) AND version = \(quoted(version)) @@ -69,11 +69,8 @@ public extension DBRepository { let runtimeC = sqlite3_column_text(statement, 4), let sourceC = sqlite3_column_text(statement, 5), let artifactC = sqlite3_column_text(statement, 6), - let skillsC = sqlite3_column_text(statement, 7), - let summaryC = sqlite3_column_text(statement, 8), + let summaryC = sqlite3_column_text(statement, 7), let artifact = Data(base64Encoded: String(cString: artifactC)), - let skillsData = String(cString: skillsC).data(using: .utf8), - let skillFiles = try? JSONDecoder().decode([String: String].self, from: skillsData), let contentHash = try? PluginContentHash(hex: String(cString: hashC)) else { throw DBRepositoryError.sqliteOperationFailed("Stored plugin factory release is corrupt.") @@ -85,7 +82,7 @@ public extension DBRepository { runtimeJSON: String(cString: runtimeC), guestSource: String(cString: sourceC), compiledArtifact: artifact, - skillFiles: skillFiles, + skillFiles: try skillFiles(pluginID: pluginID, version: version, on: handle), contentHash: contentHash, reviewSummary: String(cString: summaryC) ) @@ -183,17 +180,202 @@ public extension DBRepository { } // Version-only row replace — do not cascade associated plugin data. try withDatabaseHandle { handle in + let pluginID = quoted(release.pluginID) + let version = quoted(release.version) + try Self.execute( + "DELETE FROM plugin_skill_references WHERE plugin_id = \(pluginID) AND version = \(version);", + on: handle + ) try Self.execute( - """ - DELETE FROM plugin_factory_releases - WHERE plugin_id = \(quoted(release.pluginID)) - AND version = \(quoted(release.version)); - """, + "DELETE FROM plugin_skills WHERE plugin_id = \(pluginID) AND version = \(version);", + on: handle + ) + try Self.execute( + "DELETE FROM plugin_factory_releases WHERE plugin_id = \(pluginID) AND version = \(version);", on: handle ) } try savePluginFactoryRelease(release) } + + func listPluginSkillIndex() throws -> [PluginSkillDisclosure.IndexEntry] { + try withDatabaseHandle { handle in + let sql = """ + SELECT s.plugin_id, s.path, s.skill_name, s.skill_description + FROM plugin_skills s + INNER JOIN plugin_factory_releases r + ON r.plugin_id = s.plugin_id AND r.version = s.version + INNER JOIN ( + SELECT plugin_id, MAX(created_at) AS created_at + FROM plugin_factory_releases + GROUP BY plugin_id + ) latest + ON latest.plugin_id = r.plugin_id AND latest.created_at = r.created_at + ORDER BY s.plugin_id ASC, s.skill_name ASC; + """ + 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 plugin skills.") + } + defer { sqlite3_finalize(statement) } + var entries: [PluginSkillDisclosure.IndexEntry] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard + let pluginIDC = sqlite3_column_text(statement, 0), + let pathC = sqlite3_column_text(statement, 1), + let nameC = sqlite3_column_text(statement, 2), + let descriptionC = sqlite3_column_text(statement, 3) + else { continue } + entries.append( + PluginSkillDisclosure.IndexEntry( + pluginID: String(cString: pluginIDC), + skillName: String(cString: nameC), + description: String(cString: descriptionC), + skillMarkdownPath: String(cString: pathC) + ) + ) + } + return entries + } + } + + func pluginSkillBody(pluginID: String, version: String, skill: String) throws -> String? { + let needle = skill.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return nil } + return try withDatabaseHandle { handle in + let sql = """ + SELECT body FROM plugin_skills + WHERE plugin_id = \(quoted(pluginID)) + AND version = \(quoted(version)) + AND (skill_name = \(quoted(needle)) OR path = \(quoted(needle))) + LIMIT 1; + """ + 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 load a plugin skill.") + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, let bodyC = sqlite3_column_text(statement, 0) else { + return nil + } + return String(cString: bodyC) + } + } + + func pluginSkillReference( + pluginID: String, + version: String, + requested: String + ) throws -> (path: String, body: String)? { + let needle = requested.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return nil } + let filename = (needle as NSString).lastPathComponent + return try withDatabaseHandle { handle in + let sql = """ + SELECT path, body FROM plugin_skill_references + WHERE plugin_id = \(quoted(pluginID)) + AND version = \(quoted(version)) + AND ( + path = \(quoted(needle)) + OR path LIKE \(quoted("%/references/\(filename)")) + ) + ORDER BY CASE WHEN path = \(quoted(needle)) THEN 0 ELSE 1 END + LIMIT 1; + """ + 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 load a plugin skill reference.") + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW, + let pathC = sqlite3_column_text(statement, 0), + let bodyC = sqlite3_column_text(statement, 1) + else { return nil } + return (String(cString: pathC), String(cString: bodyC)) + } + } + + private func skillFiles( + pluginID: String, + version: String, + on handle: OpaquePointer + ) throws -> [String: String] { + var files: [String: String] = [:] + try appendSkillFiles( + """ + SELECT path, body FROM plugin_skills + WHERE plugin_id = \(quoted(pluginID)) AND version = \(quoted(version)); + """, + into: &files, + on: handle + ) + try appendSkillFiles( + """ + SELECT path, body FROM plugin_skill_references + WHERE plugin_id = \(quoted(pluginID)) AND version = \(quoted(version)); + """, + into: &files, + on: handle + ) + return files + } + + private func appendSkillFiles( + _ sql: String, + into files: inout [String: String], + on handle: OpaquePointer + ) throws { + 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 load plugin skill files.") + } + defer { sqlite3_finalize(statement) } + while sqlite3_step(statement) == SQLITE_ROW { + guard + let pathC = sqlite3_column_text(statement, 0), + let bodyC = sqlite3_column_text(statement, 1) + else { continue } + files[String(cString: pathC)] = String(cString: bodyC) + } + } + + private func insertSkillFiles(_ release: PluginFactoryRelease, on handle: OpaquePointer) throws { + for (path, body) in release.skillFiles.sorted(by: { $0.key < $1.key }) { + let catalog = PluginSkillDisclosure.catalogFields(path: path, body: body) + if let name = catalog.name { + let description = catalog.description ?? release.reviewSummary + try Self.execute( + """ + INSERT INTO plugin_skills ( + plugin_id, version, path, skill_name, skill_description, body + ) VALUES ( + \(quoted(release.pluginID)), + \(quoted(release.version)), + \(quoted(path)), + \(quoted(name)), + \(quoted(description)), + \(quoted(body)) + ); + """, + on: handle + ) + } else if PluginFactorySkillFile.isSkillReferencePath(path) { + try Self.execute( + """ + INSERT INTO plugin_skill_references ( + plugin_id, version, path, body + ) VALUES ( + \(quoted(release.pluginID)), + \(quoted(release.version)), + \(quoted(path)), + \(quoted(body)) + ); + """, + on: handle + ) + } + } + } } extension DBRepository: PluginFactoryManifestCatalog {} diff --git a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift index 89bba3c8..14c9ba5e 100644 --- a/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift +++ b/packages/DBRepository/Sources/DBRepository/DBRepositoryPluginPurge.swift @@ -171,6 +171,20 @@ private extension DBRepository { let versionClause = version.map { " AND version = \(quoted($0))" } ?? "" if beforeCount > 0 { + try execute( + """ + DELETE FROM plugin_skill_references + WHERE plugin_id = \(quoted(pluginID))\(versionClause); + """, + on: handle + ) + try execute( + """ + DELETE FROM plugin_skills + WHERE plugin_id = \(quoted(pluginID))\(versionClause); + """, + on: handle + ) try execute( """ DELETE FROM plugin_factory_releases diff --git a/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift b/packages/DBRepository/Sources/DBRepository/DatabaseSchema.swift index 670966c7..53866b5a 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 = 13 + public static let latestVersion = 1 public static func migrationSQL(version: Int, isUp: Bool) throws -> String { let migrationName = String(format: "%04d_%@", version, migrationFileBaseName(for: version)) @@ -25,30 +25,6 @@ public enum DatabaseSchema { switch version { case 1: return "initial_schema" - case 2: - return "schema_refresh" - case 3: - return "messaging_listening_since" - case 4: - return "messaging_reply_threads" - case 5: - return "news_readers" - case 6: - return "agent_profiles" - case 7: - return "messaging_agent_handled" - case 8: - return "messaging_thread_default_profile" - case 9: - return "drop_news_readers" - case 10: - return "plugin_host_ui" - case 11: - return "messaging_agent_work" - case 12: - return "agent_profile_capabilities" - case 13: - return "agent_profile_alias" default: return "unknown" } diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.down.sql index 420c9743..b6bb61b7 100644 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.down.sql +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.down.sql @@ -6,7 +6,13 @@ DROP TABLE IF EXISTS tool_runs; DROP TABLE IF EXISTS messaging_messages; DROP TABLE IF EXISTS messaging_threads; DROP TABLE IF EXISTS messaging_connectors; +DROP TABLE IF EXISTS messaging_agent_work; +DROP TABLE IF EXISTS messaging_agent_handled; +DROP TABLE IF EXISTS plugin_host_ui; +DROP TABLE IF EXISTS plugin_skill_references; +DROP TABLE IF EXISTS plugin_skills; DROP TABLE IF EXISTS plugin_factory_releases; +DROP TABLE IF EXISTS agent_profiles; DROP TABLE IF EXISTS egress_blacklist_exceptions; DROP TABLE IF EXISTS egress_blacklist; DROP TABLE IF EXISTS agent_turns; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.up.sql index 05c0f735..f7e857b1 100644 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.up.sql +++ b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0001_initial_schema.up.sql @@ -332,17 +332,79 @@ CREATE TABLE IF NOT EXISTS plugin_factory_releases ( runtime_json TEXT NOT NULL, guest_source TEXT NOT NULL, artifact_base64 TEXT NOT NULL, - skill_files_json TEXT NOT NULL DEFAULT '{}', review_summary TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (plugin_id, version), UNIQUE (content_hash) ); +CREATE TABLE IF NOT EXISTS plugin_skills ( + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + path TEXT NOT NULL, + skill_name TEXT NOT NULL, + skill_description TEXT NOT NULL, + body TEXT NOT NULL, + PRIMARY KEY (plugin_id, version, path) +); + +CREATE TABLE IF NOT EXISTS plugin_skill_references ( + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + path TEXT NOT NULL, + body TEXT NOT NULL, + PRIMARY KEY (plugin_id, version, path) +); + +CREATE TABLE IF NOT EXISTS agent_profiles ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + handle TEXT NOT NULL UNIQUE, + instructions TEXT NOT NULL DEFAULT '', + model_json TEXT NOT NULL, + thinking_json TEXT, + rag_json TEXT NOT NULL, + is_enabled INTEGER NOT NULL DEFAULT 1, + is_builtin INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + capabilities_json TEXT NOT NULL DEFAULT '{}', + alias TEXT +); + +CREATE INDEX IF NOT EXISTS idx_agent_profiles_handle ON agent_profiles(handle); +CREATE INDEX IF NOT EXISTS idx_agent_profiles_sort ON agent_profiles(sort_order, display_name); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_profiles_alias ON agent_profiles(alias) WHERE alias IS NOT NULL; + +CREATE TABLE IF NOT EXISTS messaging_agent_handled ( + plugin_id TEXT NOT NULL, + vendor_message_id TEXT NOT NULL, + handled_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, vendor_message_id) +); + +CREATE TABLE IF NOT EXISTS plugin_host_ui ( + plugin_id TEXT PRIMARY KEY NOT NULL, + present_json TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS messaging_agent_work ( + plugin_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + parent_vendor_message_id TEXT NOT NULL, + profile_handle TEXT NOT NULL, + display_name TEXT NOT NULL, + started_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, thread_id, parent_vendor_message_id) +); + CREATE TABLE IF NOT EXISTS messaging_connectors ( plugin_id TEXT PRIMARY KEY NOT NULL, display_name TEXT NOT NULL, listening INTEGER NOT NULL DEFAULT 0, + listening_since TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -355,6 +417,7 @@ CREATE TABLE IF NOT EXISTS messaging_threads ( last_activity_at TEXT NOT NULL, muted INTEGER NOT NULL DEFAULT 0, unread_count INTEGER NOT NULL DEFAULT 0, + default_agent_profile_handle TEXT, created_at TEXT NOT NULL, FOREIGN KEY(plugin_id) REFERENCES messaging_connectors(plugin_id) ON DELETE CASCADE, UNIQUE (plugin_id, vendor_thread_id) @@ -370,10 +433,15 @@ CREATE TABLE IF NOT EXISTS messaging_messages ( direction TEXT NOT NULL, sender TEXT NOT NULL, body TEXT NOT NULL, + parent_vendor_message_id TEXT, + reply_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY(thread_id) REFERENCES messaging_threads(id) ON DELETE CASCADE ); +CREATE INDEX IF NOT EXISTS idx_messaging_messages_parent + ON messaging_messages(thread_id, parent_vendor_message_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_messaging_messages_vendor ON messaging_messages(thread_id, vendor_message_id) WHERE vendor_message_id IS NOT NULL; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.down.sql deleted file mode 100644 index 71465efe..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- No-op down for the v2 version marker. -SELECT 1; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.up.sql deleted file mode 100644 index 2e924f57..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0002_schema_refresh.up.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Dev schema refresh marker (v2). Canonical tables live in 0001_initial_schema. --- Existing v1 databases are wiped and recreated when this version is introduced. -SELECT 1; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.down.sql deleted file mode 100644 index ef46a41d..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- SQLite cannot drop columns in older versions; dev refresh recreates schema from 0001. -SELECT 1; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.up.sql deleted file mode 100644 index 98eb2d0b..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0003_messaging_listening_since.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE messaging_connectors ADD COLUMN listening_since TEXT; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.down.sql deleted file mode 100644 index ef46a41d..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.down.sql +++ /dev/null @@ -1,2 +0,0 @@ --- SQLite cannot drop columns in older versions; dev refresh recreates schema from 0001. -SELECT 1; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.up.sql deleted file mode 100644 index 96b38b7f..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0004_messaging_reply_threads.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE messaging_messages ADD COLUMN parent_vendor_message_id TEXT; -ALTER TABLE messaging_messages ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS idx_messaging_messages_parent - ON messaging_messages(thread_id, parent_vendor_message_id); diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.down.sql deleted file mode 100644 index cbb5bff7..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS news_items; -DROP TABLE IF EXISTS news_readers; 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 deleted file mode 100644 index 054ea0f3..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0005_news_readers.up.sql +++ /dev/null @@ -1,30 +0,0 @@ -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/0006_agent_profiles.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.down.sql deleted file mode 100644 index bb8d3607..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP INDEX IF EXISTS idx_agent_profiles_sort; -DROP INDEX IF EXISTS idx_agent_profiles_handle; -DROP TABLE IF EXISTS agent_profiles; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.up.sql deleted file mode 100644 index d2db5ea4..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0006_agent_profiles.up.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE IF NOT EXISTS agent_profiles ( - id TEXT PRIMARY KEY NOT NULL, - display_name TEXT NOT NULL, - handle TEXT NOT NULL UNIQUE, - instructions TEXT NOT NULL DEFAULT '', - model_json TEXT NOT NULL, - thinking_json TEXT, - rag_json TEXT NOT NULL, - is_enabled INTEGER NOT NULL DEFAULT 1, - is_builtin INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_agent_profiles_handle ON agent_profiles(handle); -CREATE INDEX IF NOT EXISTS idx_agent_profiles_sort ON agent_profiles(sort_order, display_name); diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.down.sql deleted file mode 100644 index fb21bbe5..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS messaging_agent_handled; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.up.sql deleted file mode 100644 index 50f6bc0e..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0007_messaging_agent_handled.up.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS messaging_agent_handled ( - plugin_id TEXT NOT NULL, - vendor_message_id TEXT NOT NULL, - handled_at TEXT NOT NULL, - PRIMARY KEY (plugin_id, vendor_message_id) -); diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql deleted file mode 100644 index 4c3b9cd8..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE messaging_threads DROP COLUMN default_agent_profile_handle; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql deleted file mode 100644 index 35f4b6cd..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0008_messaging_thread_default_profile.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE messaging_threads ADD COLUMN default_agent_profile_handle TEXT; 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 deleted file mode 100644 index 054ea0f3..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.down.sql +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index cbb5bff7..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0009_drop_news_readers.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS news_items; -DROP TABLE IF EXISTS news_readers; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.down.sql deleted file mode 100644 index 45341184..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS plugin_host_ui; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.up.sql deleted file mode 100644 index 261b6072..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0010_plugin_host_ui.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS plugin_host_ui ( - plugin_id TEXT PRIMARY KEY NOT NULL, - present_json TEXT NOT NULL, - updated_at TEXT NOT NULL -); diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.down.sql deleted file mode 100644 index a909f4fc..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS messaging_agent_work; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.up.sql deleted file mode 100644 index a157b98a..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0011_messaging_agent_work.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS messaging_agent_work ( - plugin_id TEXT NOT NULL, - thread_id TEXT NOT NULL, - parent_vendor_message_id TEXT NOT NULL, - profile_handle TEXT NOT NULL, - display_name TEXT NOT NULL, - started_at TEXT NOT NULL, - PRIMARY KEY (plugin_id, thread_id, parent_vendor_message_id) -); diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql deleted file mode 100644 index 1008dffa..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE agent_profiles DROP COLUMN capabilities_json; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql deleted file mode 100644 index 4e513c5c..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0012_agent_profile_capabilities.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE agent_profiles ADD COLUMN capabilities_json TEXT NOT NULL DEFAULT '{}'; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql deleted file mode 100644 index 52448126..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS idx_agent_profiles_alias; -ALTER TABLE agent_profiles DROP COLUMN alias; diff --git a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql b/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql deleted file mode 100644 index 874c0dfe..00000000 --- a/packages/DBRepository/Sources/DBRepository/Resources/Migrations/0013_agent_profile_alias.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE agent_profiles ADD COLUMN alias TEXT; -CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_profiles_alias ON agent_profiles(alias) WHERE alias IS NOT NULL; diff --git a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift index 19444071..5d08ffbd 100644 --- a/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift +++ b/packages/DBRepository/Tests/DBRepositoryTests/DBRepositoryTests.swift @@ -89,8 +89,8 @@ final class DBRepositoryTests: XCTestCase { try await repository.savePluginFactoryRelease(release) let url = await repository.databaseURL - _ = try await repository.migrateSessionMemory(username: "app-user", password: "app-secret", to: 4) - XCTAssertEqual(try schemaVersion(at: url), 4) + _ = try await repository.migrateSessionMemory(username: "app-user", password: "app-secret") + XCTAssertEqual(try schemaVersion(at: url), DatabaseSchema.latestVersion) XCTAssertFalse(try tableExists(named: "news_readers", at: url)) _ = try await repository.migrateSessionMemory(username: "app-user", password: "app-secret") @@ -177,7 +177,10 @@ final class DBRepositoryTests: XCTestCase { func testApprovedPluginFactoryReleasePersistsAndVerifies() async throws { let repository = try makeRepository() _ = try await repository.createEmptyDatabaseIfNeeded(username: "app-user", password: "app-secret") - let skillFiles = ["skills/weather/SKILL.md": "# Weather"] + let skillFiles = [ + "skills/weather/SKILL.md": "---\nname: weather\ndescription: Forecasts\n---\n# Weather", + "skills/weather/references/api.md": "# API", + ] let release = makeGoFactoryRelease( pluginID: "weather-tool", manifestName: "weather-tool", @@ -193,6 +196,22 @@ final class DBRepositoryTests: XCTestCase { XCTAssertEqual(loaded?.contentHash, release.contentHash) XCTAssertEqual(loaded?.skillFiles, skillFiles) XCTAssertTrue(loaded?.verifyIntegrity() == true) + let body = try await repository.pluginSkillBody( + pluginID: "weather-tool", + version: "1.0.0", + skill: "weather" + ) + XCTAssertEqual(body, skillFiles["skills/weather/SKILL.md"]) + let reference = try await repository.pluginSkillReference( + pluginID: "weather-tool", + version: "1.0.0", + requested: "references/api.md" + ) + XCTAssertEqual(reference?.path, "skills/weather/references/api.md") + XCTAssertEqual(reference?.body, "# API") + let index = try await repository.listPluginSkillIndex() + XCTAssertEqual(index.map(\.skillName), ["weather"]) + XCTAssertEqual(index.first?.description, "Forecasts") } func testReplacePluginFactoryReleaseUpdatesSameVersion() async throws { diff --git a/packages/DBRepository/Tests/DBRepositoryTests/SchemaColumnRoundTripTests.swift b/packages/DBRepository/Tests/DBRepositoryTests/SchemaColumnRoundTripTests.swift new file mode 100644 index 00000000..fe14ca7a --- /dev/null +++ b/packages/DBRepository/Tests/DBRepositoryTests/SchemaColumnRoundTripTests.swift @@ -0,0 +1,234 @@ +import SQLite3 +import Structure +import XCTest +@testable import DBRepository + +/// Inserts one row into every table from the migrated schema and reads every column back. +final class SchemaColumnRoundTripTests: XCTestCase { + func testEveryMigratedColumnRoundTrips() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let repository = DBRepository( + configuration: DBRepositoryConfiguration( + applicationName: "ui", + databaseName: "derrick", + databaseDirectoryURL: directory, + username: "app-user", + password: "app-secret" + ) + ) + let url = try await repository.createEmptyDatabaseIfNeeded( + username: "app-user", + password: "app-secret" + ) + + var handle: OpaquePointer? + guard sqlite3_open_v2(url.path, &handle, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK, let handle else { + XCTFail("Unable to open \(url.path)") + return + } + defer { sqlite3_close(handle) } + try exec("PRAGMA foreign_keys = ON;", on: handle) + + let tables = try tableNames(on: handle) + XCTAssertFalse(tables.isEmpty) + var pending = tables + var guardCount = 0 + while !pending.isEmpty { + guardCount += 1 + XCTAssertLessThan(guardCount, tables.count + 2, "Could not insert \(pending)") + var next: [String] = [] + var insertedAny = false + for table in pending { + do { + try insertAndReadBack(table: table, on: handle) + insertedAny = true + } catch { + let message = String(describing: error) + if message.contains("FOREIGN KEY") { + next.append(table) + } else { + XCTFail("\(table): \(message)") + return + } + } + } + if !insertedAny { + XCTFail("Foreign key cycle or missing parent for \(next)") + return + } + pending = next + } + } + + private func insertAndReadBack(table: String, on handle: OpaquePointer) throws { + let columns = try columnInfo(table: table, on: handle) + XCTAssertFalse(columns.isEmpty, table) + let names = columns.map(\.name) + let values = columns.map { literal(for: $0, table: table) } + let quotedNames = names.map(quoteIdentifier).joined(separator: ", ") + let sql = "INSERT INTO \(quoteIdentifier(table)) (\(quotedNames)) VALUES (\(values.joined(separator: ", ")));" + try exec(sql, on: handle) + + var statement: OpaquePointer? + let select = "SELECT \(quotedNames) FROM \(quoteIdentifier(table)) LIMIT 1;" + guard sqlite3_prepare_v2(handle, select, -1, &statement, nil) == SQLITE_OK, let statement else { + throw SchemaRoundTripError.message("prepare select \(table)") + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { + throw SchemaRoundTripError.message("no row \(table)") + } + for (index, column) in columns.enumerated() { + let stored = read(statement, index: Int32(index), type: column.type) + let expected = expectedReadback(for: column, table: table) + XCTAssertEqual(stored, expected, "\(table).\(column.name)") + } + } + + private func literal(for column: Column, table: String) -> String { + switch affinity(column.type) { + case .integer: + return "\(integerValue(for: column, table: table))" + case .real: + return "1.25" + case .text: + return "'\(textValue(for: column, table: table).replacingOccurrences(of: "'", with: "''"))'" + } + } + + private func expectedReadback(for column: Column, table: String) -> String { + switch affinity(column.type) { + case .integer: + return "\(integerValue(for: column, table: table))" + case .real: + return "1.25" + case .text: + return textValue(for: column, table: table) + } + } + + private func integerValue(for column: Column, table: String) -> Int { + if column.primaryKey { return 1 } + return 7 + } + + private func textValue(for column: Column, table: String) -> String { + switch column.name { + case "application_name": + return "ui" + case "session_id": + return "s1" + case "agent_id": + return "a1" + case "plugin_id": + return "plugin-1" + case "version": + return "1.0.0" + case "thread_id": + return "id-messaging_threads" + case "job_id": + return "id-jobs" + case "workflow_id": + return "id-workflow_runs" + case "run_id": + return "id-tool_runs" + case "id": + return "id-\(table)" + default: + return "\(table).\(column.name)" + } + } + + private func tableNames(on handle: OpaquePointer) throws -> [String] { + var statement: OpaquePointer? + let sql = """ + SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name ASC; + """ + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw SchemaRoundTripError.message("list tables") + } + defer { sqlite3_finalize(statement) } + var names: [String] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let c = sqlite3_column_text(statement, 0) { + names.append(String(cString: c)) + } + } + return names + } + + private func columnInfo(table: String, on handle: OpaquePointer) throws -> [Column] { + var statement: OpaquePointer? + let sql = "PRAGMA table_info(\(quoteIdentifier(table)));" + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw SchemaRoundTripError.message("table_info \(table)") + } + defer { sqlite3_finalize(statement) } + var columns: [Column] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let nameC = sqlite3_column_text(statement, 1), + let typeC = sqlite3_column_text(statement, 2) else { continue } + columns.append( + Column( + name: String(cString: nameC), + type: String(cString: typeC), + primaryKey: sqlite3_column_int(statement, 5) != 0 + ) + ) + } + return columns + } + + private func read(_ statement: OpaquePointer, index: Int32, type: String) -> String { + switch affinity(type) { + case .integer: + return "\(sqlite3_column_int(statement, index))" + case .real: + return "\(sqlite3_column_double(statement, index))" + case .text: + guard let c = sqlite3_column_text(statement, index) else { return "" } + return String(cString: c) + } + } + + private func affinity(_ declared: String) -> Affinity { + let upper = declared.uppercased() + if upper.contains("INT") { return .integer } + if upper.contains("REAL") || upper.contains("FLOA") || upper.contains("DOUB") { return .real } + return .text + } + + private func quoteIdentifier(_ name: String) -> String { + "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + private func exec(_ sql: String, on handle: OpaquePointer) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(handle, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? "sqlite exec failed" + sqlite3_free(error) + throw SchemaRoundTripError.message(message) + } + } + + private struct Column { + let name: String + let type: String + let primaryKey: Bool + } + + private enum Affinity { + case integer + case real + case text + } + + private enum SchemaRoundTripError: Error, CustomStringConvertible { + case message(String) + var description: String { if case .message(let message) = self { return message } else { return "" } } + } +} diff --git a/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift b/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift index cba5a29e..54b44145 100644 --- a/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift +++ b/packages/MCPServer/Sources/MCPServer/PluginRuntimeToolModule.swift @@ -42,7 +42,8 @@ public enum PluginRuntimeToolModule { } public static func makeSkillRegistration( - loadRelease: @escaping @Sendable (String) async throws -> PluginFactoryRelease? + activate: @escaping @Sendable (_ pluginID: String, _ skill: String) async throws -> String?, + reference: @escaping @Sendable (_ pluginID: String, _ path: String) async throws -> (path: String, body: String)? ) -> MCPToolRegistration { MCPToolRegistration( tool: .pluginSkill, @@ -82,13 +83,6 @@ public enum PluginRuntimeToolModule { message: "plugin_id is required." ).encodedJSON() } - guard let release = try await loadRelease(pluginID) else { - return try failure( - stage: .validation, - code: "plugin_not_found", - message: "No approved plugin named \(pluginID)." - ).encodedJSON() - } switch action { case "activate": let skill = arguments["skill"]?.stringValue? @@ -100,10 +94,7 @@ public enum PluginRuntimeToolModule { message: "skill is required for action=activate." ).encodedJSON() } - guard let body = PluginSkillDisclosure.activate( - skillFiles: release.skillFiles, - skillNameOrPath: skill - ) else { + guard let body = try await activate(pluginID, skill) else { return try failure( stage: .validation, code: "skill_not_found", @@ -123,14 +114,8 @@ public enum PluginRuntimeToolModule { message: "path is required for action=reference." ).encodedJSON() } - guard let hit = PluginSkillDisclosure.reference( - skillFiles: release.skillFiles, - requested: path - ) else { - let available = PluginSkillDisclosure.referencePaths(skillFiles: release.skillFiles) - let hint = available.isEmpty - ? "No references shipped for /\(pluginID)." - : "Available: \(available.joined(separator: ", "))" + guard let hit = try await reference(pluginID, path) else { + let hint = "No reference matching that path on /\(pluginID)." return try failure( stage: .validation, code: "reference_not_found", diff --git a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift index 1ae03cbe..be4971a2 100644 --- a/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift +++ b/packages/MCPServer/Tests/MCPServerTests/MCPServerTests.swift @@ -1091,9 +1091,22 @@ import WebCrawler ) let bridge = try await MCPLocalBridge.make { server in await server.register( - PluginRuntimeToolModule.makeSkillRegistration { pluginID in - pluginID == release.pluginID ? release : nil - } + PluginRuntimeToolModule.makeSkillRegistration( + activate: { pluginID, skill in + guard pluginID == release.pluginID else { return nil } + return PluginSkillDisclosure.activate( + skillFiles: release.skillFiles, + skillNameOrPath: skill + ) + }, + reference: { pluginID, path in + guard pluginID == release.pluginID else { return nil } + return PluginSkillDisclosure.reference( + skillFiles: release.skillFiles, + requested: path + ) + } + ) ) } diff --git a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift index 138bbe66..88b5342f 100644 --- a/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift +++ b/packages/Plugin/Sources/Plugin/Factory/PluginFactoryImplementation.swift @@ -58,6 +58,9 @@ public struct PluginFactorySession: Sendable { hostManifest: hostManifest ) } catch { + if ModelProviderLimit.matches(error.localizedDescription) { + throw error + } let wrapped = PluginFactoryError.invalidSource(error.localizedDescription) lastError = wrapped await logger( diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/ModelProviderLimit.swift b/packages/Structure/Sources/AppLayerServices/MCPService/ModelProviderLimit.swift new file mode 100644 index 00000000..a510407f --- /dev/null +++ b/packages/Structure/Sources/AppLayerServices/MCPService/ModelProviderLimit.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A model-provider spend or rate limit. Any screen can hit this. +public enum ModelProviderLimit: Sendable { + public static let summary = """ + The model provider refused the request because this project has reached its \ + spend or rate limit. Raise the limit, then try again. + """ + + public static func matches(_ text: String) -> Bool { + let lower = text.lowercased() + return lower.contains("http 429") + || lower.contains("insufficient_quota") + || lower.contains("spend limit") + || lower.contains("rate limit") + || lower.contains("rate_limit") + || lower.contains("spend or rate limit") + } +} diff --git a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift index 8c820765..9445f9ab 100644 --- a/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift +++ b/packages/Structure/Sources/AppLayerServices/MCPService/PluginFactoryCreateFailureMessage.swift @@ -28,6 +28,13 @@ public enum PluginFactoryCreateFailureMessage: Sendable { ) } + if ModelProviderLimit.matches(trimmed) { + return PluginFactoryCreateFailurePresentation( + summary: ModelProviderLimit.summary, + technicalDetail: trimmed + ) + } + if WorkerImageFailureDisplay.isWorkerImageIssue(trimmed) { return PluginFactoryCreateFailurePresentation( summary: """ @@ -83,11 +90,17 @@ public enum PluginFactoryCreateFailureMessage: Sendable { } return PluginFactoryCreateFailurePresentation( - summary: "The connector was not saved. \(trimmed)", + summary: alreadyExplained(trimmed) + ? trimmed + : "The connector was not saved. \(trimmed)", technicalDetail: nil ) } + private static func alreadyExplained(_ message: String) -> Bool { + message.hasPrefix("The connector was not saved.") + } + private static func isModelTimeout(_ message: String) -> Bool { LLMHTTPTimeouts.isTimeoutDescription(message) } diff --git a/packages/Structure/Sources/AppLayerServices/Plugin/PluginAccessAskPolicy.swift b/packages/Structure/Sources/AppLayerServices/Plugin/PluginAccessAskPolicy.swift index 4cb1df7f..280a8982 100644 --- a/packages/Structure/Sources/AppLayerServices/Plugin/PluginAccessAskPolicy.swift +++ b/packages/Structure/Sources/AppLayerServices/Plugin/PluginAccessAskPolicy.swift @@ -132,10 +132,22 @@ public enum PluginAccessAskPolicy: Sendable { if let documentationURL, !documentationURL.isEmpty { lines.append("[Read the setup docs](\(documentationURL))") } - lines.append("If you already have that, say yes. If you still need to create it, say so.") + lines.append(credentialReplyPrompt(discovery)) return lines.joined(separator: "\n\n") } + /// Yes/no close. Names the credential when the docs identified one. + private static func credentialReplyPrompt(_ discovery: ConnectorAuthDiscovery) -> String { + let labels = discovery.secrets.map(\.label).filter { !$0.isEmpty } + if labels.count == 1 { + return "If you already have that \(labels[0].lowercased()), say yes. If you still need to create it, say so." + } + if labels.count > 1 { + return "If you already have those, say yes. If you still need to create them, say so." + } + return "If you already have the credential those docs describe, say yes. If you still need to create it, say so." + } + private static func neededSecretSentence(_ discovery: ConnectorAuthDiscovery) -> String? { let labels = discovery.secrets.map(\.label).filter { !$0.isEmpty } if labels.count == 1 { @@ -150,10 +162,10 @@ public enum PluginAccessAskPolicy: Sendable { } private static func docsDetail(_ discovery: ConnectorAuthDiscovery) -> String? { - if let hint = discovery.setupHint, !hint.isEmpty { + if let hint = discovery.setupHint, isReadableProse(hint) { return hint } - if let crawl = discovery.crawlSummary, !crawl.isEmpty { + if let crawl = discovery.crawlSummary, isReadableProse(crawl) { return clip(crawl, limit: summaryCharacterLimit) } let labels = discovery.secrets.map(\.label).filter { !$0.isEmpty } @@ -241,6 +253,23 @@ public enum PluginAccessAskPolicy: Sendable { return "aeiou".contains(first) ? "an" : "a" } + /// Page extracts often glue navigation labels together. Those are not a question. + static func isReadableProse(_ text: String) -> Bool { + let collapsed = text + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard collapsed.count >= 12 else { return false } + let spaces = collapsed.filter { $0 == " " }.count + if spaces == 0 || spaces * 12 < collapsed.count { return false } + let chars = Array(collapsed) + var jammed = 0 + for index in 1.. String { let collapsed = text .replacingOccurrences(of: "\n", with: " ") diff --git a/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift b/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift index 7d37db86..e9a85296 100644 --- a/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift +++ b/packages/Structure/Sources/Plugin/UI/PluginSkillDisclosure.swift @@ -23,6 +23,15 @@ public enum PluginSkillDisclosure: Sendable { } } + /// Name and description for a `SKILL.md` path. Reference paths return nil. + public static func catalogFields(path: String, body: String) -> (name: String?, description: String?) { + guard PluginFactorySkillFile.isSkillMarkdownPath(path) else { return (nil, nil) } + let front = SkillFrontmatter.parse(body) + let directory = path.split(separator: "/").dropFirst().first.map(String.init) ?? "" + let name = front.name ?? directory + return (name.isEmpty ? nil : name, front.description) + } + /// Cheap routing index: skill name + description only. public static func index(from release: PluginFactoryRelease) -> [IndexEntry] { release.skillFiles.keys diff --git a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift index 3be53e5b..345e2e61 100644 --- a/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift +++ b/packages/Structure/Tests/StructureTests/AppLayerServicesWireTests.swift @@ -1527,6 +1527,17 @@ import Testing ) } + @Test func pluginFactoryCreateFailureMessageExplainsProviderLimitOnce() { + let raw = """ + Invalid Go guest source: HTTP 429: {"error":{"code":"project_spend_limit_exceeded","message":"Your project has reached its configured enforced spend limit."}} + """ + let presentation = PluginFactoryCreateFailureMessage.presentation(raw) + #expect(presentation.summary.contains("spend or rate limit")) + #expect(presentation.summary.contains("Invalid Go guest source") == false) + let again = PluginFactoryCreateFailureMessage.presentation(presentation.summary) + #expect(again.summary == presentation.summary) + } + @Test func pluginFactoryCreateFailureMessageExplainsWebTools() { let raw = "The worker image derrick-worker:go-v1 does not match the version shipped with Derrick. Rebuild or reinstall product images." let presentation = PluginFactoryCreateFailureMessage.presentation(raw) diff --git a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift index 6d28331f..00d04ce7 100644 --- a/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift +++ b/packages/Structure/Tests/StructureTests/PluginSpecProcessionTests.swift @@ -248,6 +248,30 @@ import Testing #expect(question.contains(PluginAccessAskPolicy.genericQuestion) == false) } + @Test func accessAskSkipsANavDumpFromAnyDocsPage() { + let nav = "Docs Home Changelog TutorialsSuccess!My AppsDiscard ChangesSave ChangesUsing ProductEnterprisePricingSupportGuidesMarket" + let dumped = ConnectorAuthDiscovery( + authScheme: .botToken, + secrets: [], + crawlSummary: nav + ) + let hidden = PluginAccessAskPolicy.question( + fromDocs: dumped, + documentationURL: "https://docs.example.com/auth" + ) + #expect(hidden.contains("TutorialsSuccess") == false) + #expect(hidden.contains("credential those docs describe")) + #expect(hidden.contains("[Read the setup docs]")) + + let prose = ConnectorAuthDiscovery( + authScheme: .apiKey, + secrets: [], + crawlSummary: "Create a token in the developer dashboard and send it on each request." + ) + let shown = PluginAccessAskPolicy.question(fromDocs: prose, documentationURL: nil) + #expect(shown.contains("developer dashboard")) + } + @Test func accessAskPrefersACallTokenOverOAuthClientCredentials() throws { let oauthInstall = ConnectorAuthDiscovery( authScheme: .oauth, diff --git a/ui/MCPService/MCPServiceToolHost.swift b/ui/MCPService/MCPServiceToolHost.swift index af759cfc..3063ce97 100644 --- a/ui/MCPService/MCPServiceToolHost.swift +++ b/ui/MCPService/MCPServiceToolHost.swift @@ -151,16 +151,30 @@ actor MCPServiceToolHost { ) ) await server.register( - PluginRuntimeToolModule.makeSkillRegistration { pluginID in - guard let summary = try await repo.listPluginFactoryReleaseSummaries() - .first(where: { $0.pluginID == pluginID }) else { - return nil + PluginRuntimeToolModule.makeSkillRegistration( + activate: { pluginID, skill in + guard let summary = try await repo.listPluginFactoryReleaseSummaries() + .first(where: { $0.pluginID == pluginID }) else { + return nil + } + return try await repo.pluginSkillBody( + pluginID: summary.pluginID, + version: summary.version, + skill: skill + ) + }, + reference: { pluginID, path in + guard let summary = try await repo.listPluginFactoryReleaseSummaries() + .first(where: { $0.pluginID == pluginID }) else { + return nil + } + return try await repo.pluginSkillReference( + pluginID: summary.pluginID, + version: summary.version, + requested: path + ) } - return try await repo.pluginFactoryRelease( - pluginID: summary.pluginID, - version: summary.version - ) - } + ) ) await server.register( PluginRuntimeToolModule.makeInvokeRegistration { pluginID, input in @@ -362,20 +376,7 @@ actor MCPServiceToolHost { } private static func skillIndex(from repo: DBRepository) async throws -> [PluginSkillDisclosure.IndexEntry] { - let summaries = try await repo.listPluginFactoryReleaseSummaries() - var latestByPlugin: [String: PluginFactoryReleaseSummary] = [:] - for summary in summaries where latestByPlugin[summary.pluginID] == nil { - latestByPlugin[summary.pluginID] = summary - } - var entries: [PluginSkillDisclosure.IndexEntry] = [] - for summary in latestByPlugin.values.sorted(by: { $0.pluginID < $1.pluginID }) { - guard let release = try await repo.pluginFactoryRelease( - pluginID: summary.pluginID, - version: summary.version - ) else { continue } - entries.append(contentsOf: PluginSkillDisclosure.index(from: release)) - } - return entries + try await repo.listPluginSkillIndex() } } diff --git a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift index 50792c27..501216c9 100644 --- a/ui/SharedAgentRuntime/Conversation/ConversationModel.swift +++ b/ui/SharedAgentRuntime/Conversation/ConversationModel.swift @@ -568,26 +568,10 @@ final class ConversationModel { capabilities: AgentProfileCapabilities? ) async -> String { do { - let summaries = try await repository.listPluginFactoryReleaseSummaries() - var latestByPlugin: [String: PluginFactoryReleaseSummary] = [:] - for summary in summaries { - if latestByPlugin[summary.pluginID] == nil { - latestByPlugin[summary.pluginID] = summary - } - } - var entries: [PluginSkillDisclosure.IndexEntry] = [] - for summary in latestByPlugin.values.sorted(by: { $0.pluginID < $1.pluginID }) { - guard let release = try await repository.pluginFactoryRelease( - pluginID: summary.pluginID, - version: summary.version - ) else { continue } - let indexed = PluginSkillDisclosure.index(from: release) - if let capabilities { - entries.append(contentsOf: indexed.filter { capabilities.allowsPlugin($0.pluginID) }) - } else { - entries.append(contentsOf: indexed) - } - } + let indexed = try await repository.listPluginSkillIndex() + let entries = capabilities.map { caps in + indexed.filter { caps.allowsPlugin($0.pluginID) } + } ?? indexed return PluginSkillDisclosure.indexPromptBlock(entries: entries) } catch { return "" diff --git a/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift b/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift index 035572df..053530fe 100644 --- a/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift +++ b/ui/SharedAgentRuntime/Support/LLM/ConfigureScriptReviewer.swift @@ -68,9 +68,12 @@ actor ConfiguredScriptReviewer: ScriptReviewer { } await MainActor.run { debugLog("Default helper reviewer also failed; denying review.") - LLMFailureReporter.shared.report( - LLMFailureClassifier.classify(error, provider: selectedModel.provider) - ) + let failure = LLMFailureClassifier.classify(error, provider: selectedModel.provider) + if case .outOfCredits = failure { + ModelProviderLimitCenter.shared.report(raw: error.localizedDescription) + } else { + LLMFailureReporter.shared.report(failure) + } } throw error } diff --git a/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift b/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift index 8697e4d8..d701dc2c 100644 --- a/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift +++ b/ui/SharedAgentRuntime/Support/LLM/SummarizerConfig.swift @@ -50,9 +50,12 @@ actor ConfiguredMemorySummarizer: MemorySummarizer { debugLog( "Helper summarizer model \(selectedModel.helperDisplayName) failed: \(error.localizedDescription)" ) - LLMFailureReporter.shared.report( - LLMFailureClassifier.classify(error, provider: selectedModel.provider) - ) + let failure = LLMFailureClassifier.classify(error, provider: selectedModel.provider) + if case .outOfCredits = failure { + ModelProviderLimitCenter.shared.report(raw: error.localizedDescription) + } else { + LLMFailureReporter.shared.report(failure) + } } return try await fallback.summarize(pair) } diff --git a/ui/ui/Events/ModelProviderLimitCenter.swift b/ui/ui/Events/ModelProviderLimitCenter.swift new file mode 100644 index 00000000..ede429fd --- /dev/null +++ b/ui/ui/Events/ModelProviderLimitCenter.swift @@ -0,0 +1,21 @@ +import Foundation +import Structure + +/// Routes a model-provider spend or rate limit. +/// +/// A run that already has a modal can register and receive the failure. +/// With no run modal, the global policy failure modal is used. +@MainActor +final class ModelProviderLimitCenter { + static let shared = ModelProviderLimitCenter() + + weak var runModal: PluginCreationController? + + func report(raw: String) { + guard ModelProviderLimit.matches(raw) else { return } + if let runModal, runModal.injectProviderLimit(raw: raw) { + return + } + LLMFailureReporter.shared.report(.outOfCredits(provider: "your model provider")) + } +} diff --git a/ui/ui/Plugins/PluginCreationController.swift b/ui/ui/Plugins/PluginCreationController.swift index a4a5d7b4..2c285146 100644 --- a/ui/ui/Plugins/PluginCreationController.swift +++ b/ui/ui/Plugins/PluginCreationController.swift @@ -80,6 +80,18 @@ final class PluginCreationController: ObservableObject { } } + /// The create modal is already up. Show the spend limit there instead of a second modal. + func injectProviderLimit(raw: String) -> Bool { + guard showsFactoryChrome else { return false } + phase = .failed( + step: .build, + message: ModelProviderLimit.summary, + technicalDetail: raw + ) + cancelPolling() + return true + } + var canContinueFromGoal: Bool { !skillDraft.goal.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -687,6 +699,10 @@ final class PluginCreationController: ObservableObject { let stage = result.events.last(where: { $0.kind == "log" })?.stage markProgressFailed(fromStage: stage) let raw = result.errorMessage ?? "Plugin creation failed." + if ModelProviderLimit.matches(raw) { + _ = injectProviderLimit(raw: raw) + return + } let presentation = PluginFactoryCreateFailureMessage.presentation(raw) phase = .failed( step: PluginFactoryCreateInput.failureStep(forStage: stage), diff --git a/ui/ui/Session/ChatSessionStore.swift b/ui/ui/Session/ChatSessionStore.swift index 61b1ca35..17fb8ce8 100644 --- a/ui/ui/Session/ChatSessionStore.swift +++ b/ui/ui/Session/ChatSessionStore.swift @@ -772,7 +772,11 @@ final class ChatSessionStore: ObservableObject { if !Task.isCancelled { onError(error.localizedDescription) let failure = LLMFailureClassifier.classify(error, provider: model.provider) - LLMFailureReporter.shared.report(failure) + if case .outOfCredits = failure { + ModelProviderLimitCenter.shared.report(raw: error.localizedDescription) + } else { + LLMFailureReporter.shared.report(failure) + } } } } diff --git a/ui/ui/Views/ContentView.swift b/ui/ui/Views/ContentView.swift index d9dca020..fea19f6c 100644 --- a/ui/ui/Views/ContentView.swift +++ b/ui/ui/Views/ContentView.swift @@ -784,6 +784,7 @@ struct ContentView: View { return } policyEventPresenter.start() + ModelProviderLimitCenter.shared.runModal = pluginCreationController await bootstrapStatus.runClientBootstrap { await self.performClientBootstrap() }