From 691606df794af8f7813b649bb81e9158475c55ed Mon Sep 17 00:00:00 2001 From: Mkohler4 Date: Wed, 17 Jun 2026 14:58:33 -0400 Subject: [PATCH 1/2] feat(app): "How this works" on-demand analysis (T-10.14) A single quiet disclosure on the module page opens an AI-written walkthrough of the module's design: a plain-language summary, the data flow (input -> transform -> output), the key functions, what it touches, and its honest limits. The long form of the T-10.8 input signature. Decision (recorded in the schema v7 comment): the analysis is generated app-side and cached on first request, not at engine extraction. The engine extractor makes no LLM call and the manual-approve path is pure Swift with no engine, so an engine-only cache would leave every manually-approved and older module permanently unanalyzed. Generating in the app, where the user's provider/model/key already live, is one mechanism that covers every module uniformly and honors the hard constraints: instant on open (cache read), never blocks on a live call at view time, and never auto-summons a model on page open (the refining-loop rule -- unanalyzed modules show a quiet "Not analyzed yet" with a single on-demand "Analyze" action). - Schema v7 (app-only, additive): module_analyses, keyed per module, upsert-in-place. No mcp/src/schema/v7.sql -- the gunk-mcp/TS-engine migrators pin LATEST_VERSION = 4 and read explicit columns, so v7 is invisible to them (parity check unaffected). - ModuleAnalysis(+Content/Function), ModuleAnalysisComposer (prompt + JSON schema + parse, pure/testable), LiveModuleAnalysisGenerator (resolves provider/model/key), Store read/list/upsert, BrowseModel cache + generateAnalysis seam. - ModulePageView disclosure: closed by default; mono only for code references; honesty footer naming the model. GUNK_DEBUG_HOW_IT_WORKS= closed|open|missing stages the states for screenshot capture. - Tests: StoreTests (v6->v7 upgrade, upsert/read, replace-in-place), BrowseModelTests (cache, persistence, error path, input derivation), ModuleAnalysisComposerTests (prompt, schema, parsing). Build + tests green (257 passing, 1 sandbox-availability skip). Co-authored-by: Cursor --- CHANGELOG.md | 24 ++ app/Sources/GunkApp/Models/BrowseModel.swift | 80 +++++- .../GunkApp/Models/ModuleAnalysis.swift | 97 +++++++ .../Models/ModuleAnalysisComposer.swift | 191 ++++++++++++++ .../Models/ModuleAnalysisGenerator.swift | 87 +++++++ app/Sources/GunkApp/Store/Schema.swift | 40 ++- app/Sources/GunkApp/Store/Store.swift | 80 ++++++ .../GunkApp/Views/ModulePageView.swift | 236 ++++++++++++++++++ app/Tests/GunkAppTests/BrowseModelTests.swift | 87 +++++++ .../ModuleAnalysisComposerTests.swift | 120 +++++++++ app/Tests/GunkAppTests/StoreTests.swift | 93 ++++++- 11 files changed, 1127 insertions(+), 8 deletions(-) create mode 100644 app/Sources/GunkApp/Models/ModuleAnalysis.swift create mode 100644 app/Sources/GunkApp/Models/ModuleAnalysisComposer.swift create mode 100644 app/Sources/GunkApp/Models/ModuleAnalysisGenerator.swift create mode 100644 app/Tests/GunkAppTests/ModuleAnalysisComposerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8324f54..ad95989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `gunk.app` "How this works" on-demand analysis (T-10.14): a single quiet + disclosure on the module page opens an AI-written walkthrough of the module's + design — a plain-language summary, the data flow (input → transform → + output), the key functions, what it touches, and its honest limits. The long + form of the T-10.8 input signature. **Decision (recorded in the schema v7 + comment): generated app-side and cached on first request**, not at engine + extraction — the engine extractor makes no LLM call and the manual-approve + path is pure Swift with no engine, so an engine-only cache would leave every + manually-approved and older module permanently unanalyzed; generating in the + app (where the user's provider/model/key already live) is one mechanism that + covers every module. The analysis is cached in a new **app-only, additive + schema v7** (`module_analyses`, keyed per module, upsert-in-place); opening + reads the cache and is **instant** — a live model call never happens at view + time. Unanalyzed/older modules show a quiet "Not analyzed yet" with a single + on-demand "Analyze this module" action (the refining-loop rule — a model is + never auto-summoned on page open). Mono is used only for the code references + inside the analysis. A `GUNK_DEBUG_HOW_IT_WORKS=closed|open|missing` + screenshot hook stages the states. New `ModuleAnalysisComposer`, + `LiveModuleAnalysisGenerator`, store methods, and tests across `StoreTests`, + `BrowseModelTests`, and `ModuleAnalysisComposerTests`. v7 is app-only with + **no** `mcp/src/schema/v7.sql` — the gunk-mcp and TS-engine migrators pin + `LATEST_VERSION = 4` and early-return, and every read uses explicit column + lists, so v7 is invisible to them (parity check unaffected). Build + tests + green (257 passing, 1 sandbox-availability skip). - `gunk.app` UI-module detection (T-10.13, deferred scope): the runnability classifier now flags a module as a `ui-module` not-runnable-here class from **entrypoint shape** (a safe entrypoint ending in diff --git a/app/Sources/GunkApp/Models/BrowseModel.swift b/app/Sources/GunkApp/Models/BrowseModel.swift index f0431bb..b964664 100644 --- a/app/Sources/GunkApp/Models/BrowseModel.swift +++ b/app/Sources/GunkApp/Models/BrowseModel.swift @@ -336,6 +336,11 @@ final class BrowseModel { typealias ExtractGunk = @MainActor (Gunk) throws -> Void typealias ReclassifySource = @MainActor (Int64) throws -> Void typealias LoadRunTraces = @MainActor () -> [RunTrace] + /// The "How this works" generation seam (T-10.14). Injected so tests drive it + /// with a canned analysis; the production default resolves the user's + /// provider/model/key and calls the model. Only ever invoked by an explicit + /// developer action — never on page open. + typealias GenerateAnalysis = @MainActor (ModuleAnalysisInput) async throws -> GeneratedAnalysis private let store: Store /// The auto-accept gate the approval queue is computed from @@ -352,6 +357,7 @@ final class BrowseModel { /// so the smoke-run orchestration is testable with a canned executor; the /// production default wraps runs in `sandbox-exec` (ADR-0016). private let smokeRunner: SmokeRunner + private let generateAnalysisClosure: GenerateAnalysis private(set) var sections: [BrowseSection] = [] private(set) var approvalQueue: [BrowseItem] = [] @@ -377,6 +383,13 @@ final class BrowseModel { private var traceModuleRecords: [Int64: BrowseTraceModuleRecord] = [:] private var selfContainmentByGunkId: [Int64: BrowseSelfContainmentResult] = [:] private var buildVerificationByBundlePath: [String: BrowseBuildVerificationResult] = [:] + /// Cached "How this works" analyses (T-10.14), loaded from the store once per + /// refresh so `analysis(for:)` is a pure lookup — the disclosure opens + /// instantly and never triggers a model call. + private var analysisByGunkId: [Int64: ModuleAnalysis] = [:] + /// Modules whose analysis is generating right now, so the disclosure can show + /// a quiet "Analyzing…" state without a second concurrent request. + private(set) var analyzingGunkIds: Set = [] /// Trace-derived provenance, used only as the fallback when a module has no /// durable stored value (T-9.2). Shared resolution with `ProvenanceBackfill`. private var traceProvenance = RunTraceProvenanceIndex(traces: []) @@ -389,7 +402,8 @@ final class BrowseModel { loadRunTraces: @escaping LoadRunTraces = { RunTraceStore().recentTraces(limit: 250) }, - smokeRunner: SmokeRunner = SmokeRunner() + smokeRunner: SmokeRunner = SmokeRunner(), + generateAnalysis: GenerateAnalysis? = nil ) { self.store = store self.confidenceThreshold = confidenceThreshold @@ -402,12 +416,16 @@ final class BrowseModel { self.reclassifySource = reclassifySource self.loadRunTraces = loadRunTraces self.smokeRunner = smokeRunner + self.generateAnalysisClosure = generateAnalysis ?? { input in + try await LiveModuleAnalysisGenerator.generate(input: input) + } } func refresh() { do { let items = try loadItems() self.items = items + analysisByGunkId = try store.listModuleAnalyses() indexTraces(loadRunTraces(), items: items) approvalQueue = items .filter(isPendingApproval) @@ -568,6 +586,66 @@ final class BrowseModel { ) } + // MARK: - "How this works" analysis (T-10.14) + + /// The cached "How this works" analysis for a module, or `nil` when none has + /// been generated yet. A pure lookup over the cache loaded at refresh — the + /// disclosure opens instantly and never blocks on a live model call. + func analysis(for gunkId: Int64) -> ModuleAnalysis? { + analysisByGunkId[gunkId] + } + + /// Whether a module's analysis is being generated right now (drives the quiet + /// "Analyzing…" state). + func isAnalyzing(_ gunkId: Int64) -> Bool { + analyzingGunkIds.contains(gunkId) + } + + /// Assembles the generation input from what the detail already carries — the + /// long-form sibling of `inputSignature(for:)`, reading the same module + /// signals (no new store state). + func analysisInput(for detail: BrowseModuleDetail) -> ModuleAnalysisInput { + ModuleAnalysisInput( + name: detail.item.gunk.name, + purpose: detail.item.gunk.purpose, + language: detail.item.gunk.language, + entrypoints: detail.entrypoints, + requirements: detail.requirements, + ownedFiles: detail.ownedFiles, + callItSnippets: callItSnippets(for: detail) + ) + } + + /// Generate (or regenerate) a module's analysis on **explicit** demand, then + /// cache it (schema v7) and update the in-memory cache so the next open is + /// instant. Only ever called from a developer action — never on page open. + /// Returns `nil` and surfaces an error on failure, leaving any prior cache + /// untouched. + @discardableResult + func generateAnalysis(for detail: BrowseModuleDetail) async -> ModuleAnalysis? { + let gunkId = detail.item.gunk.id + guard !analyzingGunkIds.contains(gunkId) else { + return analysisByGunkId[gunkId] + } + + analyzingGunkIds.insert(gunkId) + defer { analyzingGunkIds.remove(gunkId) } + + do { + let generated = try await generateAnalysisClosure(analysisInput(for: detail)) + let stored = try store.upsertModuleAnalysis( + gunkId: gunkId, + content: generated.content, + model: generated.model + ) + analysisByGunkId[gunkId] = stored + return stored + } catch { + errorMessage = error.localizedDescription + return nil + } + } + /// The runnability classification (T-10.2) for a module, computed up front so /// the page offers a Run button only for `.terminalRunnable` modules and /// renders an honest, neutral "runnable here: not yet" state for everything diff --git a/app/Sources/GunkApp/Models/ModuleAnalysis.swift b/app/Sources/GunkApp/Models/ModuleAnalysis.swift new file mode 100644 index 0000000..fd25475 --- /dev/null +++ b/app/Sources/GunkApp/Models/ModuleAnalysis.swift @@ -0,0 +1,97 @@ +import Foundation + +/// The "How this works" analysis (T-10.14): the **long form** of the T-10.8 +/// input signature. Where the input signature answers "what do I feed it", +/// this answers "how is it built" — data flow in → transform → out, the key +/// functions, what it touches, and its honest limits. +/// +/// Pure and derivation-free at the value level: the text is generated once by a +/// model (`ModuleAnalysisComposer` builds the prompt + schema) and then cached +/// in the store (schema v7). Opening the disclosure reads the cache, so it is +/// instant and never blocks on a live model call at view time. + +/// The generated content alone — the part a model writes and we serialize into +/// the store's `content` column. Kept separate from the metadata +/// (`model`/`generatedAt`) so the composer's output type carries no clock or +/// provider concerns. +struct ModuleAnalysisContent: Equatable, Sendable, Codable { + /// A one- or two-sentence plain-language summary of what the module does. + let summary: String + /// The data flow as ordered steps: input → transform → output. Each step is + /// a short prose line (the view numbers them). + let dataFlow: [String] + /// The key functions/symbols a reader should know, each a code reference + /// (mono) paired with a plain-prose role. + let keyFunctions: [AnalysisFunction] + /// What the module touches — files, packages, env, network, the filesystem — + /// stated as facts, not warnings. + let touches: [String] + /// Honest limits / caveats: what it does *not* handle, assumptions it makes. + let limits: [String] + + /// Whether there is anything worth showing. A model that returns nothing + /// useful should be treated as "not analyzed" rather than rendering an empty + /// shell. + var isEmpty: Bool { + summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && dataFlow.isEmpty + && keyFunctions.isEmpty + && touches.isEmpty + && limits.isEmpty + } +} + +/// One key function in the analysis: a code reference (`name`, rendered mono) +/// and the plain-language `role` it plays. +struct AnalysisFunction: Equatable, Sendable, Codable { + /// The symbol/function name — a code reference, so the view renders it mono. + let name: String + /// What the function does, in plain prose. + let role: String +} + +/// A cached analysis as the store returns it: the generated `content` plus the +/// metadata for the honesty footer ("Analyzed with "). +struct ModuleAnalysis: Equatable, Sendable { + let content: ModuleAnalysisContent + /// The model that wrote it (`nil` for a debug/canned analysis). + let model: String? + /// When it was generated (epoch ms), matching the store's clock. + let generatedAt: Int64 +} + +extension ModuleAnalysis { + /// A canned analysis used only by the `GUNK_DEBUG_HOW_IT_WORKS=open` + /// screenshot hook, so the open state can be captured without a real model + /// call or a seeded store row. + static let sample = ModuleAnalysis( + content: ModuleAnalysisContent( + summary: "Converts an EPUB e-book into clean, chapter-split Markdown — " + + "unzipping the container, walking the spine in reading order, and " + + "flattening each XHTML document to text.", + dataFlow: [ + "Reads an .epub path from the first positional argument.", + "Unzips the container and parses content.opf for the spine order.", + "Converts each spine document from XHTML to Markdown.", + "Writes one Markdown file per chapter to the output directory.", + ], + keyFunctions: [ + AnalysisFunction(name: "convert(path:)", role: "The entrypoint — orchestrates the whole pipeline."), + AnalysisFunction(name: "readSpine(_:)", role: "Parses the OPF manifest into an ordered chapter list."), + AnalysisFunction(name: "xhtmlToMarkdown(_:)", role: "Flattens one XHTML document to Markdown."), + ], + touches: [ + "Reads the input .epub file and the output directory.", + "Depends on `ebooklib` and `beautifulsoup4`.", + "No network access.", + ], + limits: [ + "Assumes a well-formed EPUB 2/3 spine; malformed archives are skipped, not repaired.", + "Drops embedded fonts and most CSS — text and structure only.", + "Images are referenced by path, not inlined.", + ] + ), + model: "claude-sonnet-4", + generatedAt: 0 + ) +} diff --git a/app/Sources/GunkApp/Models/ModuleAnalysisComposer.swift b/app/Sources/GunkApp/Models/ModuleAnalysisComposer.swift new file mode 100644 index 0000000..e3be1a9 --- /dev/null +++ b/app/Sources/GunkApp/Models/ModuleAnalysisComposer.swift @@ -0,0 +1,191 @@ +import Foundation + +/// The signals a "How this works" analysis is generated from — everything the +/// module already exposes (no new store state). The composer turns these into +/// the model prompt; `BrowseModel.analysisInput(for:)` assembles it from a +/// `BrowseModuleDetail`. +struct ModuleAnalysisInput: Equatable, Sendable { + let name: String + let purpose: String? + let language: String? + let entrypoints: [BrowseEntrypoint] + let requirements: ModuleRequirements? + let ownedFiles: [String] + /// The "Call it" snippets (T-10.5) — the human-facing invocation shape, a + /// strong hint for the data-flow and key-function read. + let callItSnippets: [CallItSnippet] +} + +/// Builds the model request for a "How this works" analysis and parses the +/// structured response. Pure and side-effect-free: no I/O, no store, no live +/// model call — `BrowseModel` owns the actual `LLMClient.complete` so this +/// stays unit-testable (prompt shape, schema, parsing) without a network. +enum ModuleAnalysisComposer { + static let jsonSchemaName = "module_analysis" + + static func request(for input: ModuleAnalysisInput, model: String) -> LLMRequest { + LLMRequest( + model: model, + messages: [ + LLMMessage(role: .system, content: systemPrompt()), + LLMMessage(role: .user, content: userPrompt(for: input)), + ], + jsonSchemaName: jsonSchemaName, + jsonSchema: jsonSchema(), + maxTokens: 1_200, + temperature: 0.2 + ) + } + + static func systemPrompt() -> String { + """ + You explain how a small software module works to a developer who is deciding \ + whether to reuse it. Be precise, concrete, and honest. Base everything on the \ + facts provided — never invent functions, files, or dependencies that are not \ + shown. If a section has nothing to say, return an empty list for it rather \ + than padding it. + + Write: + - summary: one or two plain sentences on what the module does. + - dataFlow: the path data takes, input → transform → output, as ordered steps. + - keyFunctions: the few functions or symbols a reader must know. `name` is the \ + literal symbol (it will be shown as code); `role` is one plain sentence. + - touches: what the module reads, writes, depends on, or reaches (files, \ + packages, env vars, network) — facts, not warnings. + - limits: honest caveats — what it does not handle and the assumptions it makes. + + Do not use Markdown formatting inside any field. Keep each line short. + """ + } + + static func userPrompt(for input: ModuleAnalysisInput) -> String { + var lines: [String] = ["Module: \(input.name)"] + + if let language = input.language, !language.isEmpty { + lines.append("Language: \(language)") + } + if let purpose = input.purpose, !purpose.isEmpty { + lines.append("Purpose: \(purpose)") + } + + if !input.entrypoints.isEmpty { + lines.append("Entrypoints:") + for entrypoint in input.entrypoints { + if let symbol = entrypoint.symbol, !symbol.isEmpty { + lines.append(" - \(entrypoint.path) · \(symbol)") + } else { + lines.append(" - \(entrypoint.path)") + } + } + } + + if let requirements = input.requirements { + if let runtime = requirements.runtime, !runtime.isEmpty { + lines.append("Runtime: \(runtime)") + } + if !requirements.packages.isEmpty { + lines.append("Packages: \(requirements.packages.joined(separator: ", "))") + } + if !requirements.env.isEmpty { + lines.append("Env vars: \(requirements.env.joined(separator: ", "))") + } + } + + if !input.ownedFiles.isEmpty { + lines.append("Files:") + // Cap the file list so a large module doesn't blow the prompt; the + // entrypoints + call-it snippet carry the structural signal. + for file in input.ownedFiles.prefix(40) { + lines.append(" - \(file)") + } + } + + if !input.callItSnippets.isEmpty { + lines.append("How a developer calls it:") + for snippet in input.callItSnippets { + lines.append(snippet.code) + } + } + + return lines.joined(separator: "\n") + } + + static func jsonSchema() -> JSONValue { + .object([ + "type": .string("object"), + "additionalProperties": .bool(false), + "properties": .object([ + "summary": .object(["type": .string("string")]), + "dataFlow": stringArraySchema, + "keyFunctions": .object([ + "type": .string("array"), + "items": .object([ + "type": .string("object"), + "additionalProperties": .bool(false), + "properties": .object([ + "name": .object(["type": .string("string")]), + "role": .object(["type": .string("string")]), + ]), + "required": .array([.string("name"), .string("role")]), + ]), + ]), + "touches": stringArraySchema, + "limits": stringArraySchema, + ]), + "required": .array([ + .string("summary"), + .string("dataFlow"), + .string("keyFunctions"), + .string("touches"), + .string("limits"), + ]), + ]) + } + + private static let stringArraySchema = JSONValue.object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + ]) + + /// Maps a model's structured JSON into `ModuleAnalysisContent`. Returns `nil` + /// when the payload is unusable (no object, or nothing to show) so the caller + /// treats it as "not analyzed" rather than caching an empty shell. + static func parse(_ json: JSONValue) -> ModuleAnalysisContent? { + guard let object = json.objectValue else { + return nil + } + + let content = ModuleAnalysisContent( + summary: object["summary"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + dataFlow: stringList(object["dataFlow"]), + keyFunctions: functionList(object["keyFunctions"]), + touches: stringList(object["touches"]), + limits: stringList(object["limits"]) + ) + + return content.isEmpty ? nil : content + } + + private static func stringList(_ value: JSONValue?) -> [String] { + (value?.arrayValue ?? []) + .compactMap { $0.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private static func functionList(_ value: JSONValue?) -> [AnalysisFunction] { + (value?.arrayValue ?? []).compactMap { item in + guard let object = item.objectValue, + let name = object["name"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + else { + return nil + } + + let role = object["role"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return AnalysisFunction(name: name, role: role) + } + } +} diff --git a/app/Sources/GunkApp/Models/ModuleAnalysisGenerator.swift b/app/Sources/GunkApp/Models/ModuleAnalysisGenerator.swift new file mode 100644 index 0000000..0f9ae35 --- /dev/null +++ b/app/Sources/GunkApp/Models/ModuleAnalysisGenerator.swift @@ -0,0 +1,87 @@ +import Foundation + +/// The result of generating an analysis: the content plus the model that wrote +/// it (for the honesty footer). The generation seam (`BrowseModel`) returns +/// this so the model name is recorded alongside the cached text. +struct GeneratedAnalysis: Sendable { + let content: ModuleAnalysisContent + let model: String? +} + +enum ModuleAnalysisError: Error, Equatable { + /// The model returned nothing usable — treated as "not analyzed" so we never + /// cache an empty shell. + case emptyAnalysis +} + +extension ModuleAnalysisError: LocalizedError { + var errorDescription: String? { + switch self { + case .emptyAnalysis: + return "The analysis came back empty. Try again." + } + } +} + +/// The production "How this works" generator: resolves the user's configured +/// provider/model and key exactly as the rest of the app does (the +/// `llm.provider`/`llm.model` defaults + the keychain), builds the request via +/// `ModuleAnalysisComposer`, and parses the structured response. +/// +/// This is the default seam `BrowseModel` calls; tests inject a canned closure +/// instead so no network is touched. +enum LiveModuleAnalysisGenerator { + static func generate( + input: ModuleAnalysisInput, + userDefaults: UserDefaults = .standard, + secretStore: SecretStore = KeychainStore() + ) async throws -> GeneratedAnalysis { + let provider = selectedProvider(userDefaults) + let model = selectedModel(for: provider, userDefaults) + let client = try makeClient(provider: provider, secretStore: secretStore) + + let response = try await client.complete( + request: ModuleAnalysisComposer.request(for: input, model: model) + ) + + guard let content = ModuleAnalysisComposer.parse(response.json) else { + throw ModuleAnalysisError.emptyAnalysis + } + + return GeneratedAnalysis(content: content, model: model) + } + + private static func makeClient( + provider: LLMProvider, + secretStore: SecretStore + ) throws -> LLMClient { + switch provider { + case .ollama: + return OllamaClient() + case .openAI: + return OpenAIClient(apiKey: try requireKey(provider, secretStore)) + case .anthropic: + return AnthropicClient(apiKey: try requireKey(provider, secretStore)) + } + } + + private static func requireKey(_ provider: LLMProvider, _ secretStore: SecretStore) throws -> String { + guard let key = try secretStore.secret(for: provider.secretAccount), !key.isEmpty else { + throw LLMClientError.missingAPIKey + } + return key + } + + private static func selectedProvider(_ userDefaults: UserDefaults) -> LLMProvider { + if let raw = userDefaults.string(forKey: "llm.provider"), + let provider = LLMProvider(rawValue: raw) { + return provider + } + return .openAI + } + + private static func selectedModel(for provider: LLMProvider, _ userDefaults: UserDefaults) -> String { + let model = userDefaults.string(forKey: "llm.model") ?? "" + return model.isEmpty ? provider.defaultModel : model + } +} diff --git a/app/Sources/GunkApp/Store/Schema.swift b/app/Sources/GunkApp/Store/Schema.swift index 7dde8e0..a68f73a 100644 --- a/app/Sources/GunkApp/Store/Schema.swift +++ b/app/Sources/GunkApp/Store/Schema.swift @@ -1,5 +1,5 @@ enum Schema { - static let version = 6 + static let version = 7 static let migrations = [ (version: 0, sql: v0), @@ -8,7 +8,8 @@ enum Schema { (version: 3, sql: v3), (version: 4, sql: v4), (version: 5, sql: v5), - (version: 6, sql: v6) + (version: 6, sql: v6), + (version: 7, sql: v7) ] // Keep byte-for-byte identical to mcp/src/schema/v0.sql. See ADR-0006. @@ -287,5 +288,40 @@ CREATE TABLE smoke_runs ( ); CREATE INDEX smoke_runs_gunk_idx ON smoke_runs(gunk_id, created_at DESC); +""" + "\n" + + // The "How this works" cache (T-10.14). One row per module holds the + // long-form, AI-written design analysis (the long form of the T-10.8 input + // signature) so opening the disclosure reads the cache and is instant — a + // live model call never happens at view time. `content` is the JSON of the + // structured analysis (summary, data flow, key functions, what it touches, + // its limits); `model` records which model wrote it for the honesty footer; + // `generated_at` is when. `gunk_id` is the primary key so generating again + // upserts in place (one analysis per module). + // + // Decision (recorded here rather than as a standalone ADR — nothing below is + // hard to reverse, it is one additive table): the analysis is generated + // **app-side and cached on first request**, not at engine extraction. The + // engine extractor (`engine/src/extract/extractor.ts`) makes no LLM call, and + // the manual-approve path is pure Swift with no engine at all — so an + // "engine-only at extraction" cache would leave every manually-approved and + // every older module permanently unanalyzed. Generating in the app (where the + // user's chosen provider/model and key already live) is one mechanism that + // covers every module uniformly, satisfies "generated once + cached + instant + // on open", and lets older modules generate on demand (the refining-loop + // rule) instead of auto-summoning a model on every page open. + // + // APP-ONLY — intentionally has NO mcp/src/schema/v7.sql counterpart, exactly + // like v5/v6: both the gunk-mcp and TS-engine migrators pin + // `LATEST_VERSION = 4` and early-return on any store at/above it, so neither + // trips on a v7 store; every MCP/engine read uses an explicit column list, so + // this table is invisible to them. + static let v7 = """ +CREATE TABLE module_analyses ( + gunk_id INTEGER PRIMARY KEY REFERENCES gunks(id) ON DELETE CASCADE, + content TEXT NOT NULL, + model TEXT, + generated_at INTEGER NOT NULL +); """ + "\n" } diff --git a/app/Sources/GunkApp/Store/Store.swift b/app/Sources/GunkApp/Store/Store.swift index 0cec24e..fcec390 100644 --- a/app/Sources/GunkApp/Store/Store.swift +++ b/app/Sources/GunkApp/Store/Store.swift @@ -867,6 +867,86 @@ final class Store { ) } + // MARK: - "How this works" analysis cache (T-10.14, v7) + + /// Read the cached analysis for a module, or `nil` when none was generated + /// yet (older/just-extracted modules). The view reads this synchronously, so + /// opening the disclosure never blocks on a model call. + func moduleAnalysis(gunkId: Int64) throws -> ModuleAnalysis? { + try databaseQueue.read { db in + try Row.fetchOne( + db, + sql: """ + SELECT gunk_id, content, model, generated_at + FROM module_analyses + WHERE gunk_id = ? + """, + arguments: [gunkId] + ) + .flatMap(Store.moduleAnalysis(from:)) + } + } + + /// All cached analyses, keyed by module — the bulk read the model loads once + /// per refresh so `analysis(for:)` is a pure dictionary lookup. + func listModuleAnalyses() throws -> [Int64: ModuleAnalysis] { + try databaseQueue.read { db in + let rows = try Row.fetchAll( + db, + sql: "SELECT gunk_id, content, model, generated_at FROM module_analyses" + ) + + return rows.reduce(into: [:]) { result, row in + if let analysis = Store.moduleAnalysis(from: row) { + result[row["gunk_id"] as Int64] = analysis + } + } + } + } + + /// Cache (or replace) a module's analysis. Keyed by `gunk_id`, so generating + /// again upserts in place — one analysis per module. + @discardableResult + func upsertModuleAnalysis( + gunkId: Int64, + content: ModuleAnalysisContent, + model: String? + ) throws -> ModuleAnalysis { + let generatedAt = now() + let encoded = String(decoding: try JSONEncoder().encode(content), as: UTF8.self) + + try databaseQueue.write { db in + try db.execute( + sql: """ + INSERT INTO module_analyses (gunk_id, content, model, generated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(gunk_id) DO UPDATE + SET content = excluded.content, + model = excluded.model, + generated_at = excluded.generated_at + """, + arguments: [gunkId, encoded, model, generatedAt] + ) + } + + return ModuleAnalysis(content: content, model: model, generatedAt: generatedAt) + } + + private static func moduleAnalysis(from row: Row) -> ModuleAnalysis? { + let raw: String = row["content"] + guard let data = raw.data(using: .utf8), + let content = try? JSONDecoder().decode(ModuleAnalysisContent.self, from: data) + else { + return nil + } + + return ModuleAnalysis( + content: content, + model: row["model"], + generatedAt: row["generated_at"] + ) + } + private static let smokeRunColumns = """ SELECT id, diff --git a/app/Sources/GunkApp/Views/ModulePageView.swift b/app/Sources/GunkApp/Views/ModulePageView.swift index 9b66d39..bc33601 100644 --- a/app/Sources/GunkApp/Views/ModulePageView.swift +++ b/app/Sources/GunkApp/Views/ModulePageView.swift @@ -62,6 +62,7 @@ struct ModulePageView: View { if detail.bundlePath != nil { stage(detail) } + HowThisWorksView(model: model, detail: detail) footerActions(detail) advancedFooter(detail) } @@ -848,6 +849,241 @@ private struct CallItView: View { } } +// MARK: - How this works (T-10.14) + +/// The single quiet "How this works" disclosure: the long-form, AI-written +/// analysis of the module's design (data flow, key functions, what it touches, +/// its limits — the long form of the T-10.8 input signature). Closed by +/// default; opening reads the cached analysis (`BrowseModel.analysis(for:)`), +/// so it is instant and never triggers a live model call. Older/unanalyzed +/// modules show a quiet "not analyzed yet" with a single on-demand "Analyze" +/// action — a model is never auto-summoned on open. Lives inside the page (no +/// modal, no chatbot); mono is used **only** for the code references inside it. +private struct HowThisWorksView: View { + let model: BrowseModel + let detail: BrowseModuleDetail + + /// The screenshot hook (`GUNK_DEBUG_HOW_IT_WORKS=open|missing|closed`), read + /// once so the open/missing states can be captured without a real model call + /// or a seeded store row. Absent in normal runs. + private enum DebugState: String { + case open + case missing + case closed + } + + private let debugState: DebugState? + @State private var isExpanded: Bool + + init(model: BrowseModel, detail: BrowseModuleDetail) { + self.model = model + self.detail = detail + let debug = ProcessInfo.processInfo.environment["GUNK_DEBUG_HOW_IT_WORKS"] + .flatMap(DebugState.init(rawValue:)) + self.debugState = debug + // Stage the open/missing states expanded for the screenshot hook; closed by + // default otherwise (the disclosure is a quiet, opt-in affordance). + _isExpanded = State(initialValue: debug == .open || debug == .missing) + } + + /// The analysis to render: the debug sample when the `open` hook is set, + /// otherwise the real cache. `missing` forces the not-analyzed branch. + private var analysis: ModuleAnalysis? { + if debugState == .missing { + return nil + } + if debugState == .open { + return .sample + } + return model.analysis(for: detail.item.gunk.id) + } + + private var isAnalyzing: Bool { + model.isAnalyzing(detail.item.gunk.id) + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + Group { + if let analysis { + analysisBody(analysis) + } else { + notAnalyzed + } + } + .padding(.top, BrandMetrics.Spacing.md) + } label: { + HStack(spacing: BrandMetrics.Spacing.sm) { + Image(systemName: "wand.and.stars") + .foregroundStyle(BrandColors.textTertiary) + Text("How this works") + .font(BrandTypography.callout) + .foregroundStyle(BrandColors.textTertiary) + Spacer(minLength: 0) + } + } + .tint(BrandColors.textTertiary) + .padding(.top, BrandMetrics.Spacing.sm) + .overlay(alignment: .top) { + Rectangle().fill(BrandColors.separator).frame(height: 1) + } + } + + // MARK: Analysed + + private func analysisBody(_ analysis: ModuleAnalysis) -> some View { + VStack(alignment: .leading, spacing: BrandMetrics.Spacing.md) { + if !analysis.content.summary.isEmpty { + Text(analysis.content.summary) + .font(BrandTypography.body) + .foregroundStyle(BrandColors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + + if !analysis.content.dataFlow.isEmpty { + section("Data flow") { + VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { + ForEach(Array(analysis.content.dataFlow.enumerated()), id: \.offset) { index, step in + HStack(alignment: .firstTextBaseline, spacing: BrandMetrics.Spacing.sm) { + Text("\(index + 1).") + .font(BrandTypography.caption.weight(.semibold)) + .foregroundStyle(BrandColors.textTertiary) + Text(step) + .font(BrandTypography.callout) + .foregroundStyle(BrandColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + if !analysis.content.keyFunctions.isEmpty { + section("Key functions") { + VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { + ForEach(Array(analysis.content.keyFunctions.enumerated()), id: \.offset) { _, function in + VStack(alignment: .leading, spacing: 2) { + // Mono only here — these are code references. + Text(function.name) + .font(BrandTypography.mono) + .foregroundStyle(BrandColors.textPrimary) + .textSelection(.enabled) + if !function.role.isEmpty { + Text(function.role) + .font(BrandTypography.caption) + .foregroundStyle(BrandColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + } + + if !analysis.content.touches.isEmpty { + section("What it touches") { + bulletList(analysis.content.touches) + } + } + + if !analysis.content.limits.isEmpty { + section("Known limits") { + bulletList(analysis.content.limits) + } + } + + analyzedFooter(analysis) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// The honesty footer — which model wrote it. Quiet (tertiary); the analysis + /// is a convenience, not earned trust, so it stays off the accent vocabulary. + @ViewBuilder + private func analyzedFooter(_ analysis: ModuleAnalysis) -> some View { + HStack(spacing: BrandMetrics.Spacing.sm) { + if let model = analysis.model, !model.isEmpty { + Text("Analyzed with \(model)") + .font(BrandTypography.caption) + .foregroundStyle(BrandColors.textTertiary) + } + Spacer(minLength: 0) + Button("Re-analyze", action: analyze) + .buttonStyle(.plain) + .font(BrandTypography.caption) + .foregroundStyle(BrandColors.textTertiary) + .disabled(isAnalyzing) + .help("Generate the analysis again") + } + .padding(.top, BrandMetrics.Spacing.xs) + } + + // MARK: Not analysed + + private var notAnalyzed: some View { + VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { + if isAnalyzing { + HStack(spacing: BrandMetrics.Spacing.sm) { + ProgressView().controlSize(.small) + Text("Analyzing…") + .font(BrandTypography.callout) + .foregroundStyle(BrandColors.textSecondary) + } + } else { + Text("Not analyzed yet.") + .font(BrandTypography.callout) + .foregroundStyle(BrandColors.textSecondary) + Text("Generate a short, AI-written walkthrough of how this module is built.") + .font(BrandTypography.caption) + .foregroundStyle(BrandColors.textTertiary) + .fixedSize(horizontal: false, vertical: true) + Button(action: analyze) { + Label("Analyze this module", systemImage: "wand.and.stars") + } + .buttonStyle(.brandSecondary) + .help("Generate the \"How this works\" analysis") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func analyze() { + // Debug states are static screenshots — never fire a real model call. + guard debugState == nil else { return } + Task { await model.generateAnalysis(for: detail) } + } + + // MARK: Building blocks + + private func section( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { + Text(title) + .font(BrandTypography.caption.weight(.semibold)) + .foregroundStyle(BrandColors.textTertiary) + content() + } + } + + private func bulletList(_ values: [String]) -> some View { + VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { + ForEach(Array(values.enumerated()), id: \.offset) { _, value in + HStack(alignment: .firstTextBaseline, spacing: BrandMetrics.Spacing.sm) { + Text("•") + .font(BrandTypography.callout) + .foregroundStyle(BrandColors.textTertiary) + Text(value) + .font(BrandTypography.callout) + .foregroundStyle(BrandColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + // MARK: - Trust verdict → badge mapping private extension ModuleCellState { diff --git a/app/Tests/GunkAppTests/BrowseModelTests.swift b/app/Tests/GunkAppTests/BrowseModelTests.swift index 5f0c72d..9b19de7 100644 --- a/app/Tests/GunkAppTests/BrowseModelTests.swift +++ b/app/Tests/GunkAppTests/BrowseModelTests.swift @@ -798,6 +798,93 @@ final class BrowseModelTests: XCTestCase { XCTAssertNil(detail.requirements) } + // MARK: How this works analysis (T-10.14) + + func testAnalysisNilUntilGenerated() throws { + let store = try makeStore() + let source = try store.insertSource(name: "src", path: "/tmp/src") + let gunk = try insertGunk(store: store, source: source, name: "mod", tags: [], confidence: 0.9) + let model = BrowseModel(store: store, loadRunTraces: { [] }) + + model.refresh() + + XCTAssertNil(model.analysis(for: gunk.id)) + XCTAssertFalse(model.isAnalyzing(gunk.id)) + } + + func testAnalysisInputCarriesModuleSignals() throws { + let store = try makeStore() + let source = try store.insertSource(name: "src", path: "/tmp/src") + let gunk = try insertGunk( + store: store, + source: source, + name: "mod", + tags: [], + language: "Python", + confidence: 0.9, + files: ["src/a.py", "src/b.py"] + ) + let model = BrowseModel(store: store, loadRunTraces: { [] }) + model.refresh() + let detail = try XCTUnwrap(model.detail(for: gunk.id)) + + let input = model.analysisInput(for: detail) + XCTAssertEqual(input.name, "mod") + XCTAssertEqual(input.purpose, "mod purpose") + XCTAssertEqual(input.language, "Python") + XCTAssertEqual(input.ownedFiles, ["src/a.py", "src/b.py"]) + } + + func testGenerateAnalysisCachesAndPersists() async throws { + let store = try makeStore() + let source = try store.insertSource(name: "src", path: "/tmp/src") + let gunk = try insertGunk( + store: store, source: source, name: "mod", tags: [], confidence: 0.9, files: ["src/a.py"] + ) + let content = ModuleAnalysisContent( + summary: "Summary.", dataFlow: ["one"], keyFunctions: [], touches: [], limits: [] + ) + let model = BrowseModel( + store: store, + loadRunTraces: { [] }, + generateAnalysis: { _ in GeneratedAnalysis(content: content, model: "test-model") } + ) + model.refresh() + let detail = try XCTUnwrap(model.detail(for: gunk.id)) + XCTAssertNil(model.analysis(for: gunk.id)) + + let produced = await model.generateAnalysis(for: detail) + + XCTAssertEqual(produced?.content, content) + XCTAssertEqual(model.analysis(for: gunk.id)?.content, content) + XCTAssertEqual(model.analysis(for: gunk.id)?.model, "test-model") + XCTAssertFalse(model.isAnalyzing(gunk.id)) + + // Durable: a fresh model reads it back from the store after a refresh. + let reopened = BrowseModel(store: store, loadRunTraces: { [] }) + reopened.refresh() + XCTAssertEqual(reopened.analysis(for: gunk.id)?.content, content) + } + + func testGenerateAnalysisSurfacesErrorAndLeavesCacheEmpty() async throws { + let store = try makeStore() + let source = try store.insertSource(name: "src", path: "/tmp/src") + let gunk = try insertGunk(store: store, source: source, name: "mod", tags: [], confidence: 0.9) + let model = BrowseModel( + store: store, + loadRunTraces: { [] }, + generateAnalysis: { _ in throw ModuleAnalysisError.emptyAnalysis } + ) + model.refresh() + let detail = try XCTUnwrap(model.detail(for: gunk.id)) + + let produced = await model.generateAnalysis(for: detail) + + XCTAssertNil(produced) + XCTAssertNil(model.analysis(for: gunk.id)) + XCTAssertNotNil(model.errorMessage) + } + // MARK: Call it snippet (T-10.5) func testCallItSnippetPythonWithSymbolImportsAndCalls() { diff --git a/app/Tests/GunkAppTests/ModuleAnalysisComposerTests.swift b/app/Tests/GunkAppTests/ModuleAnalysisComposerTests.swift new file mode 100644 index 0000000..136fb7e --- /dev/null +++ b/app/Tests/GunkAppTests/ModuleAnalysisComposerTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import GunkApp + +final class ModuleAnalysisComposerTests: XCTestCase { + // MARK: Prompt + + func testUserPromptIncludesModuleSignals() { + let input = ModuleAnalysisInput( + name: "epub-to-markdown", + purpose: "Convert an EPUB into Markdown", + language: "Python", + entrypoints: [BrowseEntrypoint(path: "src/convert.py", symbol: "convert")], + requirements: ModuleRequirements(runtime: "Python ≥ 3.11", packages: ["ebooklib"], env: ["OUT_DIR"]), + ownedFiles: ["src/convert.py", "src/spine.py"], + callItSnippets: [] + ) + + let prompt = ModuleAnalysisComposer.userPrompt(for: input) + + XCTAssertTrue(prompt.contains("Module: epub-to-markdown")) + XCTAssertTrue(prompt.contains("Language: Python")) + XCTAssertTrue(prompt.contains("Convert an EPUB into Markdown")) + XCTAssertTrue(prompt.contains("src/convert.py · convert")) + XCTAssertTrue(prompt.contains("Python ≥ 3.11")) + XCTAssertTrue(prompt.contains("ebooklib")) + XCTAssertTrue(prompt.contains("OUT_DIR")) + XCTAssertTrue(prompt.contains("src/spine.py")) + } + + func testUserPromptOmitsAbsentSignals() { + let input = ModuleAnalysisInput( + name: "bare", + purpose: nil, + language: nil, + entrypoints: [], + requirements: nil, + ownedFiles: [], + callItSnippets: [] + ) + + let prompt = ModuleAnalysisComposer.userPrompt(for: input) + + XCTAssertEqual(prompt, "Module: bare") + XCTAssertFalse(prompt.contains("Language:")) + XCTAssertFalse(prompt.contains("Purpose:")) + } + + func testRequestUsesSchemaAndModel() { + let input = ModuleAnalysisInput( + name: "m", purpose: nil, language: nil, + entrypoints: [], requirements: nil, ownedFiles: [], callItSnippets: [] + ) + + let request = ModuleAnalysisComposer.request(for: input, model: "gpt-4.1-mini") + + XCTAssertEqual(request.model, "gpt-4.1-mini") + XCTAssertEqual(request.jsonSchemaName, "module_analysis") + XCTAssertEqual(request.messages.first?.role, .system) + XCTAssertEqual(request.messages.last?.role, .user) + XCTAssertNotNil(request.jsonSchema.objectValue?["properties"]) + } + + // MARK: Parsing + + func testParseValidPayload() { + let json = JSONValue.object([ + "summary": .string("Does a thing."), + "dataFlow": .array([.string("In."), .string("Out.")]), + "keyFunctions": .array([ + .object(["name": .string("run()"), "role": .string("Entry.")]), + ]), + "touches": .array([.string("No network.")]), + "limits": .array([.string("ASCII only.")]), + ]) + + let content = ModuleAnalysisComposer.parse(json) + + XCTAssertEqual(content?.summary, "Does a thing.") + XCTAssertEqual(content?.dataFlow, ["In.", "Out."]) + XCTAssertEqual(content?.keyFunctions, [AnalysisFunction(name: "run()", role: "Entry.")]) + XCTAssertEqual(content?.touches, ["No network."]) + XCTAssertEqual(content?.limits, ["ASCII only."]) + } + + func testParseTrimsAndDropsEmptyStringsAndNamelessFunctions() { + let json = JSONValue.object([ + "summary": .string(" trimmed "), + "dataFlow": .array([.string(" keep "), .string(" "), .string("")]), + "keyFunctions": .array([ + .object(["name": .string(" "), "role": .string("nameless")]), + .object(["name": .string("ok()"), "role": .string(" has role ")]), + ]), + "touches": .array([]), + "limits": .array([]), + ]) + + let content = ModuleAnalysisComposer.parse(json) + + XCTAssertEqual(content?.summary, "trimmed") + XCTAssertEqual(content?.dataFlow, ["keep"]) + XCTAssertEqual(content?.keyFunctions, [AnalysisFunction(name: "ok()", role: "has role")]) + } + + func testParseReturnsNilForNonObject() { + XCTAssertNil(ModuleAnalysisComposer.parse(.string("nope"))) + XCTAssertNil(ModuleAnalysisComposer.parse(.null)) + } + + func testParseReturnsNilForEmptyAnalysis() { + let json = JSONValue.object([ + "summary": .string(" "), + "dataFlow": .array([]), + "keyFunctions": .array([]), + "touches": .array([]), + "limits": .array([]), + ]) + + XCTAssertNil(ModuleAnalysisComposer.parse(json)) + } +} diff --git a/app/Tests/GunkAppTests/StoreTests.swift b/app/Tests/GunkAppTests/StoreTests.swift index 21df8fb..2702b2b 100644 --- a/app/Tests/GunkAppTests/StoreTests.swift +++ b/app/Tests/GunkAppTests/StoreTests.swift @@ -65,7 +65,7 @@ final class StoreTests: XCTestCase { XCTAssertEqual(try store.listSources().map(\.name), ["active"]) } - func testMigrationsAreIdempotentThroughV6() throws { + func testMigrationsAreIdempotentThroughV7() throws { let queue = try DatabaseQueue() _ = try Store(databaseQueue: queue, now: { 100 }) @@ -75,7 +75,7 @@ final class StoreTests: XCTestCase { try Int.fetchAll(db, sql: "SELECT version FROM schema_version") } - XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6]) + XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6, 7]) } func testV0ToLatestUpgradePreservesSources() throws { @@ -116,7 +116,7 @@ final class StoreTests: XCTestCase { try Row.fetchOne(db, sql: "SELECT source_id, relpath, size FROM files") } - XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6]) + XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6, 7]) XCTAssertEqual( source, Source( @@ -442,7 +442,7 @@ final class StoreTests: XCTestCase { let versions = try queue.read { db in try Int.fetchAll(db, sql: "SELECT version FROM schema_version") } - XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6]) + XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6, 7]) // The old row opens cleanly with null attribution (it renders the neutral // mark) until backfill resolves it. @@ -494,7 +494,7 @@ final class StoreTests: XCTestCase { let versions = try queue.read { db in try Int.fetchAll(db, sql: "SELECT version FROM schema_version") } - XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6]) + XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6, 7]) // The new tables exist and start empty on an upgraded store. XCTAssertEqual(try store.smokeRuns(gunkId: 1), []) @@ -502,6 +502,89 @@ final class StoreTests: XCTestCase { XCTAssertNil(try store.mostRecentSmokeRun(gunkId: 1)) } + // MARK: - T-10.14 (v7): "How this works" analysis cache + + func testV6StoreUpgradesToV7WithEmptyAnalyses() throws { + let queue = try DatabaseQueue() + + // Build a store at v6 (pre-analysis) with one module, then open it. + try queue.write { db in + for migration in Schema.migrations where migration.version <= 6 { + try db.execute(sql: migration.sql) + try db.execute( + sql: "INSERT INTO schema_version (version, applied_at) VALUES (?, ?)", + arguments: [migration.version, 100] + ) + } + + try db.execute( + sql: "INSERT INTO sources (id, name, path, dropped_at) VALUES (?, ?, ?, ?)", + arguments: [1, "legacy", "/code/legacy", 100] + ) + try db.execute( + sql: "INSERT INTO gunks (id, source_id, name) VALUES (?, ?, ?)", + arguments: [1, 1, "legacy-module"] + ) + } + + let store = try Store(databaseQueue: queue, now: { 200 }) + + let versions = try queue.read { db in + try Int.fetchAll(db, sql: "SELECT version FROM schema_version") + } + XCTAssertEqual(versions, [0, 1, 2, 3, 4, 5, 6, 7]) + + // The cache exists and starts empty on an upgraded store. + XCTAssertNil(try store.moduleAnalysis(gunkId: 1)) + XCTAssertEqual(try store.listModuleAnalyses().count, 0) + } + + func testUpsertModuleAnalysisPersistsAndReadsBack() throws { + let (store, _) = try makeStore(now: 555) + let source = try store.insertSource(name: "source", path: "/code/source") + let gunk = try store.insertGunk(sourceId: source.id, name: "module") + + let content = ModuleAnalysisContent( + summary: "Slugifies a string.", + dataFlow: ["Reads text.", "Lowercases and hyphenates."], + keyFunctions: [AnalysisFunction(name: "slugify(_:)", role: "The entrypoint.")], + touches: ["No I/O."], + limits: ["ASCII only."] + ) + + let stored = try store.upsertModuleAnalysis(gunkId: gunk.id, content: content, model: "gpt-4.1-mini") + XCTAssertEqual(stored.content, content) + XCTAssertEqual(stored.model, "gpt-4.1-mini") + XCTAssertEqual(stored.generatedAt, 555) + + let read = try XCTUnwrap(try store.moduleAnalysis(gunkId: gunk.id)) + XCTAssertEqual(read, stored) + + let all = try store.listModuleAnalyses() + XCTAssertEqual(all[gunk.id], stored) + } + + func testUpsertModuleAnalysisReplacesInPlace() throws { + let (store, _) = try makeStore(now: 100) + let source = try store.insertSource(name: "source", path: "/code/source") + let gunk = try store.insertGunk(sourceId: source.id, name: "module") + + let first = ModuleAnalysisContent( + summary: "First.", dataFlow: [], keyFunctions: [], touches: [], limits: [] + ) + let second = ModuleAnalysisContent( + summary: "Second.", dataFlow: ["Step."], keyFunctions: [], touches: [], limits: [] + ) + + try store.upsertModuleAnalysis(gunkId: gunk.id, content: first, model: "a") + try store.upsertModuleAnalysis(gunkId: gunk.id, content: second, model: "b") + + let read = try XCTUnwrap(try store.moduleAnalysis(gunkId: gunk.id)) + XCTAssertEqual(read.content, second) + XCTAssertEqual(read.model, "b") + XCTAssertEqual(try store.listModuleAnalyses().count, 1) + } + func testInsertSmokeRunRoundTrips() throws { let (store, gunk) = try makeStoreWithGunk(now: 100) From 242af9c6403852dd6c65f8c730f3243b5ce1622d Mon Sep 17 00:00:00 2001 From: Mkohler4 Date: Wed, 17 Jun 2026 15:06:07 -0400 Subject: [PATCH 2/2] chore(app): phase-10 cleanup, regression pass, retro (T-10.15) Delete the orphaned legacy RunConsoleView and close out Phase 10. - Remove the legacy `RunConsoleView` struct: superseded by `RunConsoleStageView` (the module-run-v2 presentation) when the v2 run console landed in T-10.9, yet never instantiated anywhere (rg confirmed zero references, including the screenshot hooks, which live on the still-used `RunConsoleModel`). T-10.13 had even updated its deferred label copy -- editing dead code. Renamed the file to RunConsoleModel.swift to match its sole surviving content; RunConsoleModel and GUNK_DEBUG_RUN_CONSOLE staging are intact. The inline ModuleDetailView was already gone (T-10.4); only docstrings name it. - ADR-0017 (MCP run tool) -> Accepted (implemented in T-10.12); both Phase 10 ADRs (0016 sandbox, 0017 run tool) now linked from the roadmap. - Roadmap Phase 10 items checked off (Tested-badge item recorded as superseded by the coverage ledger + sign-off per CP-F; UI-module launch left open as deferred); T-10.14 status noted in the task doc. - docs/retros/phase-10.md written; CHANGELOG close-out entry added. Regression pass at 960x600 and default width across the module page and every run state confirms the toolbox-v2 constraints (graphite surfaces, mono only for paths/code/terminal, accent green only on earned meaning, glass on the controls layer) and the two-surfaces rule hold. Schema-parity check still passes (v0-v4 only; v5/v6/v7 are app-only). Build + tests green (257 passing, 1 sandbox-availability skip). Co-authored-by: Cursor --- CHANGELOG.md | 20 + .../GunkApp/Views/RunConsoleModel.swift | 365 +++++++ .../GunkApp/Views/RunConsoleView.swift | 996 ------------------ docs/adr/0017-mcp-run-tool.md | 2 +- docs/retros/phase-10.md | 156 +++ docs/roadmap.md | 60 +- docs/tasks/phase-10-run-and-test-modules.md | 19 + 7 files changed, 595 insertions(+), 1023 deletions(-) create mode 100644 app/Sources/GunkApp/Views/RunConsoleModel.swift delete mode 100644 app/Sources/GunkApp/Views/RunConsoleView.swift create mode 100644 docs/retros/phase-10.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ad95989..07dbf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tests green (243 passing, 1 sandbox-availability skip). ### Removed +- `gunk.app` phase-10 close-out (T-10.15): deleted the orphaned legacy + `RunConsoleView` — superseded by `RunConsoleStageView` (the module-run-v2 + presentation) when the run console v2 landed in T-10.9, yet never + instantiated anywhere (`rg` confirmed zero references, including the + screenshot hooks, which live on the still-used `RunConsoleModel`). T-10.13 + had even updated its deferred-label copy — editing dead code, the same "kept + for reuse" trap Phase 9 flagged. Its file was renamed to + `RunConsoleModel.swift` to match its sole surviving content; + `RunConsoleModel` and the `GUNK_DEBUG_RUN_CONSOLE` staging are intact. The + inline `ModuleDetailView` was already gone (T-10.4 moved its capabilities + onto the page; only docstrings still name it). ADR-0017 (MCP run tool) moved + to **Accepted** (implemented in T-10.12) and both Phase 10 ADRs (0016/0017) + are now linked from the roadmap; Phase 10 roadmap items checked off and + `docs/retros/phase-10.md` written. Regression pass at 960×600 and default + width across the module page and every run state confirmed the toolbox-v2 + constraints (graphite surfaces, mono only for paths/code/terminal, accent + green only on earned meaning, glass on the controls layer) and the + two-surfaces rule (smoke run ≠ extraction inspector) hold. Schema-parity + check still passes (v0–v4 only; v5/v6/v7 are app-only). Build + tests green + (257 passing, 1 sandbox-availability skip). - `gunk.app` phase-9 close-out (T-9.7): removed the orphaned `ProviderBadge` component. T-9.2 (#165) reworked it into a brand "token" and kept it "for reuse", but the card switched to `ProviderWatermark` and the list row to diff --git a/app/Sources/GunkApp/Views/RunConsoleModel.swift b/app/Sources/GunkApp/Views/RunConsoleModel.swift new file mode 100644 index 0000000..13d6b31 --- /dev/null +++ b/app/Sources/GunkApp/Views/RunConsoleModel.swift @@ -0,0 +1,365 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// Drives the smoke-run console (T-10.7): the developer's "Try it" door on the +/// module page. Owns the per-page run state — never-tried, first-run consent, +/// running/streaming, and the resting receipt — over the `BrowseModel` +/// orchestration (which builds the runner input, executes the sandboxed run, +/// and persists the receipt). UI state only; the durable proof lives in the +/// store (T-10.3). The v2 presentation over this engine is `RunConsoleStageView`. +@MainActor +@Observable +final class RunConsoleModel { + /// The console's transient phase. The *resting* states (never-tried vs. last + /// receipt) are distinguished by `receipt` while `phase == .idle`. + enum Phase: Equatable { + case idle + case awaitingConsent + case running + } + + private let model: BrowseModel + let detail: BrowseModuleDetail + + private(set) var phase: Phase = .idle + /// The most recent receipt — loaded from the store on appear, replaced by a + /// just-finished run. Drives the resting receipt line + demoted disclosure. + private(set) var receipt: SmokeRunRecord? + /// Incremental stdout/stderr for the live terminal; retained after a run so + /// the demoted disclosure can show the raw log of the run just completed. + private(set) var liveLog: String = "" + /// When the active run started, for the elapsed indicator. + private(set) var runStartedAt: Date? + + /// The typed-input surface (T-10.8): controls derived from the entrypoint + /// signature, prefilled with the staged demo input and swappable. Empty + + /// `reliable == false` → the page falls back to the bare zero-touch run. + let signature: InputSignature + /// The developer's current value per field id (prefilled from each field's + /// demo value). Mutated as they swap inputs; composed into the run arguments. + var fieldValues: [String: String] + /// File sizes (bytes) for dropped file inputs, so the sandbox file-size cap + /// can be enforced as quiet guidance before a run. + private(set) var fileSizes: [String: Int] = [:] + /// Set after a run the developer launched with *their own* input, so the + /// "save as example" affordance appears (the end of the effort spectrum). + private(set) var lastRunWasSwapped = false + /// A just-saved example's name, for the brief "Saved" confirmation. + private(set) var savedExampleName: String? + /// Increments each time a run finishes (consent → run → done). The v2 run + /// console (`RunConsoleStageView`) observes this to transition into its + /// result + verdict state without polling the async run task. + private(set) var runsCompleted: Int = 0 + + /// Dev-only screenshot override (see `applyDebugOverride`): forces the + /// classification so the neutral "not runnable here" state can be staged. + private var runnabilityOverride: Runnability? + /// Dev-only screenshot override: forces the typed-input signature so the + /// prefilled/swapped/invalid/missing states can be staged deterministically. + private var signatureOverride: InputSignature? + + private var runTask: Task? + + init(model: BrowseModel, detail: BrowseModuleDetail) { + self.model = model + self.detail = detail + self.receipt = model.lastSmokeRun(for: detail.item.gunk.id) + let signature = model.inputSignature(for: detail) + self.signature = signature + self.fieldValues = Dictionary( + uniqueKeysWithValues: signature.fields.map { ($0.id, $0.demoValue) } + ) + applyDebugOverride() + } + + // MARK: Derived state + + var runnability: Runnability { + runnabilityOverride ?? model.runnability(for: detail) + } + + var isRunnableHere: Bool { + runnability == .terminalRunnable + } + + var isRunning: Bool { + phase == .running + } + + var awaitingConsent: Bool { + phase == .awaitingConsent + } + + /// The signature actually shown — a dev override when staging screenshots, + /// otherwise the model's inference. + var activeSignature: InputSignature { + signatureOverride ?? signature + } + + /// Whether to render the typed-input surface: a reliable, non-empty signature + /// on a runnable module. Otherwise the console is the bare zero-touch run. + var showsInputSurface: Bool { + isRunnableHere && activeSignature.reliable && !activeSignature.isEmpty + } + + /// The developer's current values composed into positional run arguments. + var composedArguments: [String] { + activeSignature.arguments(from: fieldValues) + } + + /// Whether any field carries a value the developer brought (differs from the + /// staged demo and is non-empty) — gates the "save as example" affordance and + /// the `yours` coverage class. + var hasSwappedInput: Bool { + activeSignature.fields.contains { field in + let value = (fieldValues[field.id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return !value.isEmpty && value != field.demoValue + } + } + + /// The command the run will (or did) execute, reflecting the composed input, + /// for the consent treatment and the console header. + var command: String? { + model.resolvedRunCommand(for: detail, arguments: composedArguments) + } + + /// The validation state of a field's current value, for quiet guidance. + func validation(for field: InputField) -> InputFieldValidation { + InputValidator.validate( + field: field, + value: fieldValues[field.id] ?? "", + fileSizeBytes: fileSizes[field.id] + ) + } + + /// Whether any field has a blocking problem (missing requirement, wrong file + /// type, too large, not a number) — Run is disabled until it's resolved. + var hasBlockingValidation: Bool { + activeSignature.fields.contains { validation(for: $0) != .ok } + } + + /// The working directory promise: a *throwaway copy* of the bundle, never the + /// developer's source (ADR-0016). We show the bundle's name, not its path. + var bundleName: String? { + detail.bundlePath.map { URL(fileURLWithPath: $0).lastPathComponent } + } + + // MARK: Intents + + /// The single "Try it" entry point. Asks for first-run consent once per + /// module (inferred from any prior receipt), then runs. Always works at the + /// prefilled demo values (the zero-touch floor); blocked only when a swapped + /// input is invalid (quiet guidance, not a failure). + func tryIt() { + guard isRunnableHere, !isRunning, !hasBlockingValidation else { + return + } + + if model.hasRunBefore(gunkId: detail.item.gunk.id) { + run() + } else { + phase = .awaitingConsent + } + } + + func confirmConsent() { + guard phase == .awaitingConsent else { + return + } + run() + } + + func cancelConsent() { + phase = .idle + } + + /// Swap a text/number/choice value. Clears the just-saved confirmation so the + /// "save as example" affordance re-arms for the new input. + func setValue(_ value: String, for field: InputField) { + fieldValues[field.id] = value + savedExampleName = nil + } + + /// Swap a file input from a dropped/picked URL: stores its path (read inside + /// the sandbox — reads are allowed; writes/network are not) and its size so + /// the cap can be enforced as quiet guidance. + func setFile(_ url: URL, for field: InputField) { + fieldValues[field.id] = url.path + fileSizes[field.id] = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize + savedExampleName = nil + } + + /// Reset a field to its staged-demo value (the one-gesture "back to demo"). + func resetToDemo(_ field: InputField) { + fieldValues[field.id] = field.demoValue + fileSizes[field.id] = nil + savedExampleName = nil + } + + /// Persist the current input as a named example (T-10.3). The developer's own + /// input is the `yours` coverage class; the untouched demo is `happy`. The + /// saved-example list + re-run are T-10.10 — this wires the save action only. + func saveAsExample(name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return + } + let saved = model.saveExample( + for: detail, + name: trimmed, + input: composedArguments.joined(separator: " "), + inputClass: hasSwappedInput ? .yours : .happy + ) + if saved != nil { + savedExampleName = trimmed + } + } + + private func run() { + guard !isRunning else { + return + } + + liveLog = "" + runStartedAt = Date() + lastRunWasSwapped = hasSwappedInput + savedExampleName = nil + phase = .running + + let arguments = composedArguments + runTask = Task { [weak self] in + guard let self else { + return + } + + let record = await self.model.runSmokeTest(for: self.detail, arguments: arguments) { [weak self] event in + guard let self else { + return + } + switch event { + case .started: + break + case .stdout(let text): + self.liveLog += text + case .stderr(let text): + self.liveLog += text + case .finished: + break + } + } + + if let record { + self.receipt = record + } + self.runStartedAt = nil + self.phase = .idle + self.runsCompleted += 1 + } + } + + // MARK: Debug staging (screenshots) + + /// Dev-only screenshot hook (same family as `GUNK_DEBUG_MODULE_PAGE`): stages + /// a console state at launch so every CP-F state can be captured without live + /// execution. Pair with `GUNK_DEBUG_MODULE_PAGE=first|` to open the page. + /// Values: `nevertried`, `consent`, `running`, `passed`, `failed`, + /// `unrunnable`, `uimodule` (the T-10.13 deferred UI-module state), and the + /// typed-input surface (T-10.8): `prefilled`, `swapped`, `invalid`, + /// `missing`, `dropwell`. + private func applyDebugOverride() { + guard let value = ProcessInfo.processInfo.environment["GUNK_DEBUG_RUN_CONSOLE"] else { + return + } + + let gunkId = detail.item.gunk.id + let command = command ?? "python3 parser.py --in sample.epub" + + switch value { + case "nevertried": + runnabilityOverride = .terminalRunnable + receipt = nil + case "consent": + runnabilityOverride = .terminalRunnable + phase = .awaitingConsent + case "running": + runnabilityOverride = .terminalRunnable + phase = .running + runStartedAt = Date() + liveLog = "$ \(command)\nParsing EPUB…\nparsed 12 chapters\n" + case "passed": + runnabilityOverride = .terminalRunnable + liveLog = "parsed 12 chapters\nwrote chapters.json\n" + receipt = Self.stagedReceipt( + gunkId: gunkId, command: command, runnability: .terminalRunnable, + exitCode: 0, passed: true, log: liveLog, durationMs: 1800 + ) + case "failed": + runnabilityOverride = .terminalRunnable + liveLog = "Traceback (most recent call last):\n File \"parser.py\", line 42\nValueError: corrupt header\n" + receipt = Self.stagedReceipt( + gunkId: gunkId, command: command, runnability: .terminalRunnable, + exitCode: 3, passed: false, log: liveLog, durationMs: 300 + ) + case "unrunnable": + runnabilityOverride = .needsNetwork + case "uimodule": + runnabilityOverride = .uiModule + case "prefilled", "dropwell": + stageInputSurface(values: [:]) + case "swapped": + stageInputSurface(values: ["input-file": "/Users/you/Documents/my-book.epub"]) + fileSizes["input-file"] = 2_400_000 + case "invalid": + stageInputSurface(values: ["input-file": "/Users/you/Documents/notes.txt"]) + case "missing": + stageInputSurface(values: ["input-file": ""], required: true) + default: + break + } + } + + /// Stages the typed-input surface with a canned `.epub` file field so the + /// prefilled/swapped/invalid/missing/drop-well states are screenshot-able + /// without a module whose real signature happens to infer a file input. + private func stageInputSurface(values: [String: String], required: Bool = false) { + runnabilityOverride = .terminalRunnable + receipt = nil + let field = InputField( + id: "input-file", + label: "Input file", + kind: .file(extensions: ["epub"]), + hint: "This entrypoint takes a .epub file. Drop your own to run it on your data.", + required: required + ) + signatureOverride = InputSignature(fields: [field], reliable: true) + fieldValues = ["input-file": values["input-file"] ?? ""] + } + + private static func stagedReceipt( + gunkId: Int64, + command: String, + runnability: Runnability, + exitCode: Int32?, + passed: Bool?, + log: String, + durationMs: Int + ) -> SmokeRunRecord { + SmokeRunRecord( + id: -1, + gunkId: gunkId, + exampleId: nil, + command: command, + runnability: runnability, + origin: .human, + exitCode: exitCode, + passed: passed, + timedOut: false, + durationMs: durationMs, + outputArtifactPath: nil, + log: log, + verdict: nil, + createdAt: 1_700_000_000 + ) + } +} diff --git a/app/Sources/GunkApp/Views/RunConsoleView.swift b/app/Sources/GunkApp/Views/RunConsoleView.swift deleted file mode 100644 index 9673e5e..0000000 --- a/app/Sources/GunkApp/Views/RunConsoleView.swift +++ /dev/null @@ -1,996 +0,0 @@ -import AppKit -import SwiftUI -import UniformTypeIdentifiers - -/// Drives the smoke-run console (T-10.7): the developer's "Try it" door on the -/// module page. Owns the per-page run state — never-tried, first-run consent, -/// running/streaming, and the resting receipt — over the `BrowseModel` -/// orchestration (which builds the runner input, executes the sandboxed run, -/// and persists the receipt). UI state only; the durable proof lives in the -/// store (T-10.3). -@MainActor -@Observable -final class RunConsoleModel { - /// The console's transient phase. The *resting* states (never-tried vs. last - /// receipt) are distinguished by `receipt` while `phase == .idle`. - enum Phase: Equatable { - case idle - case awaitingConsent - case running - } - - private let model: BrowseModel - let detail: BrowseModuleDetail - - private(set) var phase: Phase = .idle - /// The most recent receipt — loaded from the store on appear, replaced by a - /// just-finished run. Drives the resting receipt line + demoted disclosure. - private(set) var receipt: SmokeRunRecord? - /// Incremental stdout/stderr for the live terminal; retained after a run so - /// the demoted disclosure can show the raw log of the run just completed. - private(set) var liveLog: String = "" - /// When the active run started, for the elapsed indicator. - private(set) var runStartedAt: Date? - - /// The typed-input surface (T-10.8): controls derived from the entrypoint - /// signature, prefilled with the staged demo input and swappable. Empty + - /// `reliable == false` → the page falls back to the bare zero-touch run. - let signature: InputSignature - /// The developer's current value per field id (prefilled from each field's - /// demo value). Mutated as they swap inputs; composed into the run arguments. - var fieldValues: [String: String] - /// File sizes (bytes) for dropped file inputs, so the sandbox file-size cap - /// can be enforced as quiet guidance before a run. - private(set) var fileSizes: [String: Int] = [:] - /// Set after a run the developer launched with *their own* input, so the - /// "save as example" affordance appears (the end of the effort spectrum). - private(set) var lastRunWasSwapped = false - /// A just-saved example's name, for the brief "Saved" confirmation. - private(set) var savedExampleName: String? - /// Increments each time a run finishes (consent → run → done). The v2 run - /// console (`RunConsoleStageView`) observes this to transition into its - /// result + verdict state without polling the async run task. - private(set) var runsCompleted: Int = 0 - - /// Dev-only screenshot override (see `applyDebugOverride`): forces the - /// classification so the neutral "not runnable here" state can be staged. - private var runnabilityOverride: Runnability? - /// Dev-only screenshot override: forces the typed-input signature so the - /// prefilled/swapped/invalid/missing states can be staged deterministically. - private var signatureOverride: InputSignature? - - private var runTask: Task? - - init(model: BrowseModel, detail: BrowseModuleDetail) { - self.model = model - self.detail = detail - self.receipt = model.lastSmokeRun(for: detail.item.gunk.id) - let signature = model.inputSignature(for: detail) - self.signature = signature - self.fieldValues = Dictionary( - uniqueKeysWithValues: signature.fields.map { ($0.id, $0.demoValue) } - ) - applyDebugOverride() - } - - // MARK: Derived state - - var runnability: Runnability { - runnabilityOverride ?? model.runnability(for: detail) - } - - var isRunnableHere: Bool { - runnability == .terminalRunnable - } - - var isRunning: Bool { - phase == .running - } - - var awaitingConsent: Bool { - phase == .awaitingConsent - } - - /// The signature actually shown — a dev override when staging screenshots, - /// otherwise the model's inference. - var activeSignature: InputSignature { - signatureOverride ?? signature - } - - /// Whether to render the typed-input surface: a reliable, non-empty signature - /// on a runnable module. Otherwise the console is the bare zero-touch run. - var showsInputSurface: Bool { - isRunnableHere && activeSignature.reliable && !activeSignature.isEmpty - } - - /// The developer's current values composed into positional run arguments. - var composedArguments: [String] { - activeSignature.arguments(from: fieldValues) - } - - /// Whether any field carries a value the developer brought (differs from the - /// staged demo and is non-empty) — gates the "save as example" affordance and - /// the `yours` coverage class. - var hasSwappedInput: Bool { - activeSignature.fields.contains { field in - let value = (fieldValues[field.id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return !value.isEmpty && value != field.demoValue - } - } - - /// The command the run will (or did) execute, reflecting the composed input, - /// for the consent treatment and the console header. - var command: String? { - model.resolvedRunCommand(for: detail, arguments: composedArguments) - } - - /// The validation state of a field's current value, for quiet guidance. - func validation(for field: InputField) -> InputFieldValidation { - InputValidator.validate( - field: field, - value: fieldValues[field.id] ?? "", - fileSizeBytes: fileSizes[field.id] - ) - } - - /// Whether any field has a blocking problem (missing requirement, wrong file - /// type, too large, not a number) — Run is disabled until it's resolved. - var hasBlockingValidation: Bool { - activeSignature.fields.contains { validation(for: $0) != .ok } - } - - /// The working directory promise: a *throwaway copy* of the bundle, never the - /// developer's source (ADR-0016). We show the bundle's name, not its path. - var bundleName: String? { - detail.bundlePath.map { URL(fileURLWithPath: $0).lastPathComponent } - } - - // MARK: Intents - - /// The single "Try it" entry point. Asks for first-run consent once per - /// module (inferred from any prior receipt), then runs. Always works at the - /// prefilled demo values (the zero-touch floor); blocked only when a swapped - /// input is invalid (quiet guidance, not a failure). - func tryIt() { - guard isRunnableHere, !isRunning, !hasBlockingValidation else { - return - } - - if model.hasRunBefore(gunkId: detail.item.gunk.id) { - run() - } else { - phase = .awaitingConsent - } - } - - func confirmConsent() { - guard phase == .awaitingConsent else { - return - } - run() - } - - func cancelConsent() { - phase = .idle - } - - /// Swap a text/number/choice value. Clears the just-saved confirmation so the - /// "save as example" affordance re-arms for the new input. - func setValue(_ value: String, for field: InputField) { - fieldValues[field.id] = value - savedExampleName = nil - } - - /// Swap a file input from a dropped/picked URL: stores its path (read inside - /// the sandbox — reads are allowed; writes/network are not) and its size so - /// the cap can be enforced as quiet guidance. - func setFile(_ url: URL, for field: InputField) { - fieldValues[field.id] = url.path - fileSizes[field.id] = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize - savedExampleName = nil - } - - /// Reset a field to its staged-demo value (the one-gesture "back to demo"). - func resetToDemo(_ field: InputField) { - fieldValues[field.id] = field.demoValue - fileSizes[field.id] = nil - savedExampleName = nil - } - - /// Persist the current input as a named example (T-10.3). The developer's own - /// input is the `yours` coverage class; the untouched demo is `happy`. The - /// saved-example list + re-run are T-10.10 — this wires the save action only. - func saveAsExample(name: String) { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return - } - let saved = model.saveExample( - for: detail, - name: trimmed, - input: composedArguments.joined(separator: " "), - inputClass: hasSwappedInput ? .yours : .happy - ) - if saved != nil { - savedExampleName = trimmed - } - } - - private func run() { - guard !isRunning else { - return - } - - liveLog = "" - runStartedAt = Date() - lastRunWasSwapped = hasSwappedInput - savedExampleName = nil - phase = .running - - let arguments = composedArguments - runTask = Task { [weak self] in - guard let self else { - return - } - - let record = await self.model.runSmokeTest(for: self.detail, arguments: arguments) { [weak self] event in - guard let self else { - return - } - switch event { - case .started: - break - case .stdout(let text): - self.liveLog += text - case .stderr(let text): - self.liveLog += text - case .finished: - break - } - } - - if let record { - self.receipt = record - } - self.runStartedAt = nil - self.phase = .idle - self.runsCompleted += 1 - } - } - - // MARK: Debug staging (screenshots) - - /// Dev-only screenshot hook (same family as `GUNK_DEBUG_MODULE_PAGE`): stages - /// a console state at launch so every CP-F state can be captured without live - /// execution. Pair with `GUNK_DEBUG_MODULE_PAGE=first|` to open the page. - /// Values: `nevertried`, `consent`, `running`, `passed`, `failed`, - /// `unrunnable`, `uimodule` (the T-10.13 deferred UI-module state), and the - /// typed-input surface (T-10.8): `prefilled`, `swapped`, `invalid`, - /// `missing`, `dropwell`. - private func applyDebugOverride() { - guard let value = ProcessInfo.processInfo.environment["GUNK_DEBUG_RUN_CONSOLE"] else { - return - } - - let gunkId = detail.item.gunk.id - let command = command ?? "python3 parser.py --in sample.epub" - - switch value { - case "nevertried": - runnabilityOverride = .terminalRunnable - receipt = nil - case "consent": - runnabilityOverride = .terminalRunnable - phase = .awaitingConsent - case "running": - runnabilityOverride = .terminalRunnable - phase = .running - runStartedAt = Date() - liveLog = "$ \(command)\nParsing EPUB…\nparsed 12 chapters\n" - case "passed": - runnabilityOverride = .terminalRunnable - liveLog = "parsed 12 chapters\nwrote chapters.json\n" - receipt = Self.stagedReceipt( - gunkId: gunkId, command: command, runnability: .terminalRunnable, - exitCode: 0, passed: true, log: liveLog, durationMs: 1800 - ) - case "failed": - runnabilityOverride = .terminalRunnable - liveLog = "Traceback (most recent call last):\n File \"parser.py\", line 42\nValueError: corrupt header\n" - receipt = Self.stagedReceipt( - gunkId: gunkId, command: command, runnability: .terminalRunnable, - exitCode: 3, passed: false, log: liveLog, durationMs: 300 - ) - case "unrunnable": - runnabilityOverride = .needsNetwork - case "uimodule": - runnabilityOverride = .uiModule - case "prefilled", "dropwell": - stageInputSurface(values: [:]) - case "swapped": - stageInputSurface(values: ["input-file": "/Users/you/Documents/my-book.epub"]) - fileSizes["input-file"] = 2_400_000 - case "invalid": - stageInputSurface(values: ["input-file": "/Users/you/Documents/notes.txt"]) - case "missing": - stageInputSurface(values: ["input-file": ""], required: true) - default: - break - } - } - - /// Stages the typed-input surface with a canned `.epub` file field so the - /// prefilled/swapped/invalid/missing/drop-well states are screenshot-able - /// without a module whose real signature happens to infer a file input. - private func stageInputSurface(values: [String: String], required: Bool = false) { - runnabilityOverride = .terminalRunnable - receipt = nil - let field = InputField( - id: "input-file", - label: "Input file", - kind: .file(extensions: ["epub"]), - hint: "This entrypoint takes a .epub file. Drop your own to run it on your data.", - required: required - ) - signatureOverride = InputSignature(fields: [field], reliable: true) - fieldValues = ["input-file": values["input-file"] ?? ""] - } - - private static func stagedReceipt( - gunkId: Int64, - command: String, - runnability: Runnability, - exitCode: Int32?, - passed: Bool?, - log: String, - durationMs: Int - ) -> SmokeRunRecord { - SmokeRunRecord( - id: -1, - gunkId: gunkId, - exampleId: nil, - command: command, - runnability: runnability, - origin: .human, - exitCode: exitCode, - passed: passed, - timedOut: false, - durationMs: durationMs, - outputArtifactPath: nil, - log: log, - verdict: nil, - createdAt: 1_700_000_000 - ) - } -} - -/// The smoke-run console section on the module page (T-10.7). It renders the -/// CP-F run states — never-tried, first-run consent, running/streaming, passed -/// (earned green), failed (red), and the resting receipt with the terminal + -/// raw command **demoted** to a collapsed disclosure — plus the neutral -/// "runnable here: not yet" treatment for modules the sandbox can't fairly run. -/// This is the *smoke run* only; it never merges with the `view run →` -/// extraction inspector (the two-surfaces rule). -struct RunConsoleView: View { - @State var console: RunConsoleModel - /// Expansion of the `>_ Command & raw log` disclosure. Collapsed by default - /// per the receipt-first rule. - @State private var showRawLog = false - /// The in-progress "save as example" name; seeded once the save row appears. - @State private var exampleName = "" - - init(model: BrowseModel, detail: BrowseModuleDetail) { - _console = State(initialValue: RunConsoleModel(model: model, detail: detail)) - } - - var body: some View { - DetailSection(title: "Try it", systemImage: "play.circle") { - if !console.isRunning, !console.isRunnableHere { - notRunnableHere(console.runnability) - } else { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.md) { - consoleHeader - - if console.awaitingConsent { - consentTreatment - } else if console.isRunning { - liveTerminal - } else { - if console.showsInputSurface { - inputSurface - } - - if let receipt = console.receipt { - restingReceipt(receipt) - } else { - neverTried - } - - if console.lastRunWasSwapped, console.receipt != nil { - saveAsExampleRow - } - } - } - } - } - } - - // MARK: Typed input surface (T-10.8 — bring your own input) - - /// The signature-derived input controls, prefilled with the staged demo and - /// swappable in one gesture. Part of the *one composition* — it sits inside - /// the same "Try it" console as the Run action, never a competing CTA. - private var inputSurface: some View { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { - Text("Bring your own input") - .font(BrandTypography.caption.weight(.semibold)) - .foregroundStyle(BrandColors.textTertiary) - - ForEach(console.activeSignature.fields) { field in - fieldControl(field) - } - } - } - - @ViewBuilder - private func fieldControl(_ field: InputField) -> some View { - let value = console.fieldValues[field.id] ?? "" - let validation = console.validation(for: field) - - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { - HStack(spacing: BrandMetrics.Spacing.xs) { - Text(field.label) - .font(BrandTypography.caption.weight(.semibold)) - .foregroundStyle(BrandColors.textSecondary) - if !value.isEmpty, value != field.demoValue { - Button("Reset to demo") { console.resetToDemo(field) } - .buttonStyle(.plain) - .font(BrandTypography.caption) - .foregroundStyle(BrandColors.accent) - } - } - - switch field.kind { - case .file(let extensions): - fileDropWell(field, value: value, extensions: extensions) - case .text: - textControl(field, value: value) - case .number: - textControl(field, value: value) - case .choice(let options): - choiceControl(field, value: value, options: options) - } - - guidance(for: validation, field: field) - } - } - - /// A compact file drop well: accepts a dropped file of the typed kind, or a - /// click-to-choose. Shows the demo prefill (none until T-10.9) or the - /// developer's file by name — never the full path, which would crowd it. - private func fileDropWell(_ field: InputField, value: String, extensions: [String]) -> some View { - let fileName = value.isEmpty ? nil : (value as NSString).lastPathComponent - return Button(action: { chooseFile(for: field, extensions: extensions) }) { - HStack(spacing: BrandMetrics.Spacing.sm) { - Image(systemName: fileName == nil ? "arrow.down.doc" : "doc.fill") - .foregroundStyle(fileName == nil ? BrandColors.textTertiary : BrandColors.accent) - Text(fileName ?? "Drop a file here, or click to choose") - .font(BrandTypography.callout) - .foregroundStyle(fileName == nil ? BrandColors.textTertiary : BrandColors.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) - } - .padding(BrandMetrics.Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: BrandMetrics.Radius.small, style: .continuous) - .fill(BrandColors.backgroundSecondary) - ) - .overlay( - RoundedRectangle(cornerRadius: BrandMetrics.Radius.small, style: .continuous) - .strokeBorder(BrandColors.separator, style: StrokeStyle(lineWidth: 1, dash: [4, 3])) - ) - } - .buttonStyle(.plain) - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - loadDroppedFile(providers, for: field) - } - .help("Drop or choose a file to run the module on your own input") - } - - private func textControl(_ field: InputField, value: String) -> some View { - TextField( - field.demoValue.isEmpty ? "Type your input" : field.demoValue, - text: Binding( - get: { console.fieldValues[field.id] ?? "" }, - set: { console.setValue($0, for: field) } - ) - ) - .textFieldStyle(.roundedBorder) - .font(BrandTypography.callout) - } - - private func choiceControl(_ field: InputField, value: String, options: [String]) -> some View { - Menu { - ForEach(options, id: \.self) { option in - Button(option) { console.setValue(option, for: field) } - } - } label: { - HStack(spacing: BrandMetrics.Spacing.xs) { - Text(value.isEmpty ? "Choose…" : value) - .foregroundStyle(value.isEmpty ? BrandColors.textTertiary : BrandColors.textPrimary) - Image(systemName: "chevron.down") - .foregroundStyle(BrandColors.textTertiary) - } - .font(BrandTypography.callout) - } - .menuStyle(.borderlessButton) - .fixedSize() - } - - /// Quiet guidance under a control (CP-F: guidance, not a system warning). - @ViewBuilder - private func guidance(for validation: InputFieldValidation, field: InputField) -> some View { - switch validation { - case .ok: - if let hint = field.hint, (console.fieldValues[field.id] ?? "").isEmpty { - guidanceText(hint) - } - case .missing: - guidanceText("\(field.label) is needed to run — drop one in, or reset to the demo.") - case .wrongFileType(let expected): - let pretty = expected.map { ".\($0)" }.joined(separator: " / ") - guidanceText("This entrypoint takes a \(pretty) file.") - case .tooLarge(let limit): - guidanceText("That file is over the \(byteLimitLabel(limit)) sandbox input cap — try a smaller one.") - case .notANumber: - guidanceText("\(field.label) takes a number.") - } - } - - private func guidanceText(_ text: String) -> some View { - Text(text) - .font(BrandTypography.caption) - .foregroundStyle(BrandColors.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - - // MARK: Save as example (the end of the effort spectrum) - - /// After a run the developer launched with their own input, a quiet way to - /// persist it as a named, re-runnable case (the list + re-run are T-10.10). - private var saveAsExampleRow: some View { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { - if let saved = console.savedExampleName { - HStack(spacing: BrandMetrics.Spacing.xs) { - Image(systemName: "checkmark.circle") - .foregroundStyle(BrandColors.accent) - Text("Saved “\(saved)” as an example") - .font(BrandTypography.caption) - .foregroundStyle(BrandColors.textSecondary) - } - } else { - HStack(spacing: BrandMetrics.Spacing.sm) { - TextField("Name this example", text: $exampleName) - .textFieldStyle(.roundedBorder) - .font(BrandTypography.callout) - .onSubmit { saveExample() } - - Button(action: saveExample) { - Label("Save as example", systemImage: "bookmark") - } - .buttonStyle(.brandSecondary) - .disabled(exampleName.trimmingCharacters(in: .whitespaces).isEmpty) - .help("Keep this input as a named, re-runnable example") - } - } - } - .padding(.top, BrandMetrics.Spacing.xs) - } - - private func saveExample() { - console.saveAsExample(name: exampleName) - exampleName = "" - } - - // MARK: File picking - - private func chooseFile(for field: InputField, extensions: [String]) { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - if !extensions.isEmpty { - panel.allowedContentTypes = extensions.compactMap { UTType(filenameExtension: $0) } - } - if panel.runModal() == .OK, let url = panel.url { - console.setFile(url, for: field) - } - } - - private func loadDroppedFile(_ providers: [NSItemProvider], for field: InputField) -> Bool { - guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) }) else { - return false - } - provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in - guard - let data, - let url = URL(dataRepresentation: data, relativeTo: nil) - else { - return - } - Task { @MainActor in - console.setFile(url, for: field) - } - } - return true - } - - private func byteLimitLabel(_ bytes: Int) -> String { - let mb = Double(bytes) / (1024 * 1024) - return "\(mb.formatted(.number.precision(.fractionLength(0)))) MB" - } - - // MARK: Header - - private var consoleHeader: some View { - HStack(spacing: BrandMetrics.Spacing.sm) { - Text(">_ run console") - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textTertiary) - - if let command = console.command { - Text(command) - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textSecondary) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer(minLength: BrandMetrics.Spacing.sm) - - statusChip - } - } - - @ViewBuilder - private var statusChip: some View { - if console.isRunning { - HStack(spacing: BrandMetrics.Spacing.xs) { - ProgressView() - .controlSize(.small) - Text("Running") - .font(BrandTypography.callout) - .foregroundStyle(BrandColors.textSecondary) - } - } else if console.awaitingConsent { - StatusBadge("Ready", variant: .neutral, systemImage: "hand.raised") - } else if let receipt = console.receipt { - switch outcome(of: receipt) { - case .passed: - StatusBadge("Passed", variant: .success, systemImage: "checkmark.circle") - case .failed: - StatusBadge("Failed", variant: .danger, systemImage: "xmark.circle") - case .couldNotRun, .notRunnable: - StatusBadge("Not run", variant: .neutral, systemImage: "minus.circle") - } - } else { - StatusBadge("Idle", variant: .neutral, systemImage: "circle.dashed") - } - } - - // MARK: Never tried - - private var neverTried: some View { - HStack(spacing: BrandMetrics.Spacing.md) { - Text("Never tried. Run the entrypoint once in a sandbox to see what it does.") - .font(BrandTypography.callout) - .foregroundStyle(BrandColors.textSecondary) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 0) - - Button(action: console.tryIt) { - Label("Try it", systemImage: "play.fill") - } - .buttonStyle(.brandPrimary) - .disabled(console.hasBlockingValidation) - .help("Run the module's entrypoint in a sandbox") - } - } - - // MARK: First-run consent - - private var consentTreatment: some View { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { - Text("First run — here's exactly what will happen") - .font(BrandTypography.callout.weight(.semibold)) - .foregroundStyle(BrandColors.textPrimary) - - if let command = console.command { - terminalBlock { - Text("$ \(command)") - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textPrimary) - .textSelection(.enabled) - } - } - - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { - consentRow("Working directory", "a throwaway copy of \(console.bundleName ?? "the bundle") — your source is never run in place") - consentRow("Network", "off — the run cannot reach the internet") - consentRow("Writes", "confined to the run directory") - consentRow("Timeout", "the run is hard-stopped after 30s") - consentRow("Secrets", "your environment is never passed in") - } - - HStack(spacing: BrandMetrics.Spacing.sm) { - Button(action: console.confirmConsent) { - Label("Run", systemImage: "play.fill") - } - .buttonStyle(.brandPrimary) - .help("Run the entrypoint in the sandbox") - - Button(action: console.cancelConsent) { - Text("Cancel") - } - .buttonStyle(.brandSecondary) - } - .padding(.top, BrandMetrics.Spacing.xs) - } - } - - private func consentRow(_ label: String, _ value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: BrandMetrics.Spacing.sm) { - Text(label) - .font(BrandTypography.caption.weight(.semibold)) - .foregroundStyle(BrandColors.textTertiary) - .frame(width: 120, alignment: .leading) - - Text(value) - .font(BrandTypography.caption) - .foregroundStyle(BrandColors.textSecondary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - // MARK: Running / streaming - - private var liveTerminal: some View { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { - terminalBlock { - ScrollViewReader { proxy in - ScrollView { - Text(console.liveLog.isEmpty ? "…" : console.liveLog) - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - .id(Self.terminalTailID) - } - .frame(height: Self.terminalHeight) - .onChange(of: console.liveLog) { _, _ in - withAnimation(BrandMotion.quick) { - proxy.scrollTo(Self.terminalTailID, anchor: .bottom) - } - } - } - } - - TimelineView(.periodic(from: .now, by: 0.1)) { context in - let elapsed = console.runStartedAt.map { context.date.timeIntervalSince($0) } ?? 0 - HStack(spacing: BrandMetrics.Spacing.xs) { - ProgressView() - .controlSize(.small) - Text("Running… \(elapsed.formatted(.number.precision(.fractionLength(1))))s") - .font(BrandTypography.caption) - .foregroundStyle(BrandColors.textSecondary) - } - } - } - } - - // MARK: Resting receipt - - @ViewBuilder - private func restingReceipt(_ receipt: SmokeRunRecord) -> some View { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { - HStack(spacing: BrandMetrics.Spacing.md) { - Text(receiptLine(receipt)) - .font(BrandTypography.callout.weight(.semibold)) - .foregroundStyle(receiptColor(receipt)) - - Spacer(minLength: 0) - - Button(action: console.tryIt) { - Label("Run again", systemImage: "arrow.clockwise") - } - .buttonStyle(.brandSecondary) - .disabled(console.hasBlockingValidation) - .help("Run the entrypoint again in a sandbox") - } - - rawLogDisclosure(receipt) - } - } - - /// The demoted disclosure (`>_ Command & raw log`), collapsed by default per - /// the receipt-first rule. Holds the raw command and the captured terminal - /// output — evidence, not the headline. - private func rawLogDisclosure(_ receipt: SmokeRunRecord) -> some View { - DisclosureGroup(isExpanded: $showRawLog) { - VStack(alignment: .leading, spacing: BrandMetrics.Spacing.sm) { - if let command = receipt.command { - Text("$ \(command)") - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textSecondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - - let log = console.liveLog.isEmpty ? receipt.log : console.liveLog - terminalBlock { - ScrollView { - Text(log.isEmpty ? "(no output)" : log) - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - .frame(height: Self.terminalHeight) - } - } - .padding(.top, BrandMetrics.Spacing.xs) - } label: { - Text(">_ Command & raw log") - .font(BrandTypography.mono) - .foregroundStyle(BrandColors.textTertiary) - } - .tint(BrandColors.textTertiary) - } - - // MARK: Not runnable here (neutral, never red) - - private func notRunnableHere(_ runnability: Runnability) -> some View { - let copy = Self.notRunnableCopy(runnability) - return VStack(alignment: .leading, spacing: BrandMetrics.Spacing.xs) { - StatusBadge(copy.title, variant: .neutral, systemImage: copy.systemImage) - - Text(copy.detail) - .font(BrandTypography.callout) - .foregroundStyle(BrandColors.textSecondary) - .fixedSize(horizontal: false, vertical: true) - - Text("The sandbox is the wrong room for this proof — use Call it above to run it where it belongs.") - .font(BrandTypography.caption) - .foregroundStyle(BrandColors.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - - // MARK: Shared terminal chrome - - private func terminalBlock(@ViewBuilder _ content: () -> Content) -> some View { - content() - .padding(BrandMetrics.Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: BrandMetrics.Radius.small, style: .continuous) - .fill(BrandColors.backgroundPrimary) - ) - .overlay( - RoundedRectangle(cornerRadius: BrandMetrics.Radius.small, style: .continuous) - .strokeBorder(BrandColors.separator) - ) - } - - // MARK: Derivations - - /// The honest outcome of a persisted receipt. A run that was classified - /// runnable but never actually executed (staging/sandbox refusal — - /// `exitCode == nil`, not timed out) is **not** a red failure; it reads as a - /// neutral "couldn't run here". - private enum Outcome { - case passed - case failed - case couldNotRun - case notRunnable(Runnability) - } - - private func outcome(of receipt: SmokeRunRecord) -> Outcome { - guard receipt.runnability == .terminalRunnable else { - return .notRunnable(receipt.runnability) - } - if receipt.passed == true { - return .passed - } - if receipt.timedOut || receipt.exitCode != nil { - return .failed - } - return .couldNotRun - } - - private func receiptLine(_ receipt: SmokeRunRecord) -> String { - switch outcome(of: receipt) { - case .passed: - return "Last tried: passed · \(durationLabel(receipt.durationMs))" - case .failed: - if receipt.timedOut { - return "Last tried: timed out · \(durationLabel(receipt.durationMs))" - } - return "Last tried: failed · \(durationLabel(receipt.durationMs))" - case .couldNotRun: - return "Last tried: couldn't run here" - case .notRunnable(let runnability): - return Self.notRunnableCopy(runnability).title - } - } - - private func receiptColor(_ receipt: SmokeRunRecord) -> Color { - switch outcome(of: receipt) { - case .passed: - return BrandColors.accent - case .failed: - return BrandColors.danger - case .couldNotRun, .notRunnable: - return BrandColors.textSecondary - } - } - - private func durationLabel(_ ms: Int) -> String { - let seconds = Double(ms) / 1000 - return "\(seconds.formatted(.number.precision(.fractionLength(1))))s" - } - - private static func notRunnableCopy(_ runnability: Runnability) -> (title: String, detail: String, systemImage: String) { - switch runnability { - case .needsNetwork: - return ( - "Runnable here: not yet — needs the network", - "This module's job is to call out to a live service, and the sandbox runs offline.", - "network.slash" - ) - case .needsSecrets: - return ( - "Runnable here: not yet — needs secrets", - "This module needs credentials that aren't present in the sandbox.", - "key" - ) - case .interactiveStdin: - return ( - "Runnable here: not yet — wants interactive input", - "This is a prompt-driven CLI; the one-shot runner can't answer its prompts.", - "keyboard" - ) - case .longRunning: - return ( - "Runnable here: not yet — long-running", - "This looks like a server, watcher, or TUI that doesn't terminate, so the timeout isn't a fair test.", - "infinity" - ) - case .uiModule: - return ( - "Runnable here: not yet — UI module", - "Output is a UI surface. In-browser launch is coming in a later phase.", - "macwindow" - ) - case .cannotDetermine: - return ( - "Runnable here: not yet — can't tell how to run this", - "gunk couldn't confidently derive a command to run, so it won't guess.", - "questionmark.circle" - ) - case .terminalRunnable: - return ( - "Runnable", - "This module runs as a one-shot terminal entrypoint.", - "terminal" - ) - } - } - - private static let terminalHeight: CGFloat = 200 - private static let terminalTailID = "run-console-terminal-tail" -} diff --git a/docs/adr/0017-mcp-run-tool.md b/docs/adr/0017-mcp-run-tool.md index bf855f8..9272369 100644 --- a/docs/adr/0017-mcp-run-tool.md +++ b/docs/adr/0017-mcp-run-tool.md @@ -1,6 +1,6 @@ # ADR-0017: MCP `run_gunk` tool — the agent's execute door -- **Status:** Proposed +- **Status:** Accepted (2026-06-17) — implemented in T-10.12 (#180) - **Date:** 2026-06-16 - **Deciders:** Mark Kohler - **Phase:** 10 (Run & test modules) — gate **CP-K** diff --git a/docs/retros/phase-10.md b/docs/retros/phase-10.md new file mode 100644 index 0000000..8ebeb95 --- /dev/null +++ b/docs/retros/phase-10.md @@ -0,0 +1,156 @@ +# Phase 10 retro: Run & test modules (the proof loop) + +Phase 10 built **the developer's door** into a module — and, for the first +time, **the agent's door beyond read-only**. Clicking a module stopped opening +an inline pane and now navigates to a full **module page** whose hero is a +sandbox-bounded **run console** paired with a **coverage ledger** that states — +plainly, never as a score — which classes of input have actually been proven +(*happy path · your own inputs · edge cases · adversarial*). An MCP `run_gunk` +tool lets the agent earn the same evidence the human does, and a quiet "How +this works" disclosure explains a module's design on demand. Trust is coverage +across classes plus an honest sign-off, not a badge earned in one click. + +Task breakdown: +[docs/tasks/phase-10-run-and-test-modules.md](../tasks/phase-10-run-and-test-modules.md) +(T-10.1–T-10.15, checkpoints CP-F…CP-K). Design source of truth: +[module-run-v2](../design/explorations/module-run-v2.md) (+ the landed +`module-run-v2.html`, which wins over v1). Architecture: +[ADR-0016](../adr/0016-sandbox-execution-model.md) (sandbox, Accepted) and +[ADR-0017](../adr/0017-mcp-run-tool.md) (MCP run tool, Accepted). + +## What shipped + +- **Design gate** (T-10.1, CP-F): the module-run-v2 HTML export landed and + reframed the phase — "Proven by you" is dead; the page is a run console + + honest coverage ledger, runtime scope is **terminal-only**, and the improve + loop is **capture-and-queue** (re-extraction deferred). All ten open + questions resolved. +- **Sandbox & execution runner** (T-10.2, CP-G, ADR-0016): an app-side Swift + `SmokeRunner` that copies a bundle into a throwaway run dir and wraps the + interpreter in a deny-by-default `sandbox-exec` (Seatbelt) profile — network + off, writes confined. Backs both run doors. A **runnability classifier** + produces the honest "runnable here: not yet" categories (needs network / + secrets / interactive / long-running / UI module / can't-determine) as a + distinct class from a failed run. +- **Proof-loop store** (T-10.3, CP-H, Schema **v6**, #/178-era): two additive, + app-only tables — `smoke_runs` (the receipt: runnability, origin, exit/pass, + duration, log) and `module_examples` (the fixture library, folding pinned + failing cases and known limits into one table via `input_class` + + `expected_output`/`note`). Coverage/Tested state stays **derived**, not + denormalized. +- **Full module page** (T-10.4): clicking a module navigates to a breadcrumbed + page (`‹ Library › `) that carries every former inline + capability, hosting the run console + ledger. +- **Call it snippet** (T-10.5, #174): a copyable, generated invocation snippet + from the stored entrypoints + symbols. +- **Requirements readout** (T-10.6, #175): "to run this elsewhere, you need" — + runtime, packages, env vars, persisted into the bundle's `gunk.yml` and read + back by the app. +- **Run console** (T-10.7, CP-I, #176): consent → run → streaming terminal → + receipt, with the raw command + log demoted to a disclosure and the receipt + as the durable resting state. +- **Typed input surface** (T-10.8, #177): native controls inferred from the + entrypoint signature so the developer can bring **their own** input; an + unreliable inference falls back to the zero-touch terminal run. +- **Run console v2 + coverage ledger** (T-10.9, #178): the input-class spine, + in-console diff receipt, developer verdict, and known limits — the heart of + the page. Plus the **passing-checks** named-case list (T-10.10). +- **Coverage sign-off** (T-10.11, CP-J, #179): the pure derivation that gates + `Ready to connect` — earned only when happy-path **and** the developer's own + inputs are proven, never by one AI-staged pass. +- **MCP `run_gunk` tool** (T-10.12, CP-K, #180, ADR-0017): the agent's execute + door, sharing the evidence pile but stated separately ("N agent runs · M you + checked") — agent volume never reads as human-checked. +- **UI-module detection** (T-10.13, #181): keys on declared UI framework **or** + entrypoint shape (`.jsx`/`.tsx`/`.vue`/`.svelte`/`.astro`/`.html`/`.htm`), + rendering the neutral "UI module — in-browser launch coming later" label. The + actual launch is **deferred** (see below). +- **"How this works" analysis** (T-10.14): one quiet disclosure opens a cached + AI walkthrough of a module's design — instant on open, generated once and + cached, never auto-summoned. **Decision: generated app-side and cached** in a + new app-only, additive **Schema v7** (`module_analyses`), because the engine + extractor makes no LLM call and the manual-approve path is pure Swift with no + engine — so app-side generation is the one mechanism that covers every module + (including older + manually-approved ones). +- **Close-out** (T-10.15): deleted the orphaned legacy `RunConsoleView`, + updated the roadmap + ADRs, and wrote this retro (below). + +## Cleanup (T-10.15) + +`rg` confirmed the inline `ModuleDetailView` was already gone (its capabilities +moved onto the page in T-10.4; only docstrings still name it). The one piece of +genuinely orphaned scaffolding was the **legacy `RunConsoleView`** struct: it +was superseded by `RunConsoleStageView` (the v2 presentation) when the run +console v2 landed in T-10.9, yet `RunConsoleView` was never instantiated +anywhere — including by the screenshot hooks, which live on the still-used +`RunConsoleModel`. T-10.13 even updated its deferred-label copy, which was +editing dead code (the same "kept for reuse" trap Phase 9 called out). It was +removed and its file renamed to `RunConsoleModel.swift` to match its sole +surviving content; `RunConsoleModel` and the `GUNK_DEBUG_RUN_CONSOLE` staging +stay intact. Schema-parity check still passes (it only asserts v0–v4, and v5/v6/ +v7 are all app-only with no `mcp/` counterpart). Build + tests green (257 +passing, 1 sandbox-availability skip). + +## Regression pass (T-10.15) + +Reviewed at the 960×600 minimum and default window size: the module page and +its run states — consent, streaming, passed, failed, resting receipt, the +intent toolbar + typed inputs, the in-console diff receipt, the coverage ledger ++ known limits, the passing-checks list, the sign-off locked vs. +`Ready to connect`, the UI-module not-runnable label, and the new "How this +works" disclosure (closed / open / not-analyzed) — all staged via the +`GUNK_DEBUG_RUN_CONSOLE`, `GUNK_DEBUG_MODULE_PAGE`, and `GUNK_DEBUG_HOW_IT_WORKS` +hooks. The **toolbox-v2 constraints hold**: solid graphite content, glass on the +controls layer only (the breadcrumb header), `mono` confined to paths / code / +terminal (the Call-it snippet, bundle path, entrypoints, and the analysis's code +references), and accent green only on earned meaning (the `Ready to connect` +sign-off and the live-run pulse). The **two-surfaces rule** holds: the smoke run +("what does the module do") never merges with the `view run →` extraction +inspector ("what did gunk do"). The live visual confirmation of the transient +run states and a real "How this works" open remain Mark's `[HOLD FOR ME]` gate. + +## What slipped + +- **In-browser UI-module launch (T-10.13)** is deferred to a later phase, as + the CP-F revision planned. This phase ships **detection + an honest deferred + label**, not a working launch button; `NSWorkspace.open` at a served surface + is the eventual target. +- **Guided re-extraction** stayed **capture-and-queue** (CP-F open question + #10): a wrong verdict pins the expected output + a note as a failing case; + the re-extraction trigger that flips it green is a follow-up. +- **The "How this works" analysis is generated app-side, not at extraction.** + The faithful "engine at extraction" place can't reach manually-approved or + older modules (no engine on that path, no LLM call in the extractor), so the + honest, uniform choice was app-side on-demand generation. Generating at + extraction for auto-accepted modules — so the first open is pre-warmed — is a + reasonable future optimization on top of the same cache. + +## What we learned + +- **"Kept for reuse" is still how dead code is born — and gets *maintained*.** + Phase 9 deleted `ProviderBadge` for this reason; Phase 10 found + `RunConsoleView` had not only survived but been *edited* in T-10.13. The + close-out lesson compounds: delete the unreferenced view at the phase exit, + and don't update copy on a view nothing renders. +- **"The faithful place" and "the place that covers every case" can differ.** + The engine at extraction is the natural home for module prose, but it doesn't + run for manually-approved modules and makes no LLM call. Choosing the app-side + cache made the feature uniform and let the schema comment record *why* the + obvious-looking choice was wrong. +- **A derived flag beats new store state until it can't.** Runnability and + coverage stayed derived (no persisted `isUIModule`, no denormalized Tested + tier); only the genuinely expensive-to-recompute artifacts earned a table + (receipts, examples, the cached analysis). +- **`GUNK_DEBUG_*` hooks keep paying for themselves.** Every run state, the + UI-module label, and now the analysis disclosure are screenshot-stageable + without a live model, a real sandbox run, or a seeded store. + +## What we're deferring / cutting + +- **UI-module in-browser launch** → a later phase (detection landed now). +- **Guided re-extraction** → follow-up (capture-and-queue shipped now). +- **Pre-warming "How this works" at extraction** for auto-accepted modules → + optional future optimization over the existing cache. +- Still explicitly **out**: dependency-graph visualizations, run-history charts, + in-app editing, metrics dashboards — the proof loop is receipts + coverage, + not a dashboard. diff --git a/docs/roadmap.md b/docs/roadmap.md index befdf19..28dcb12 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -377,35 +377,43 @@ badge. > ladder), and all ten open questions are resolved there. Runtime scope is > **terminal-only** this phase; the improve loop is **capture-and-queue** > (re-extraction deferred). - -- [ ] **Smoke run ("Try it")**: execute a module's entrypoint against its - extracted bundle in a sandbox, persist the receipt (when, pass/fail, - duration, output). Receipt-first per module-run-v1: the primary - evidence is the before/after Proof card **on the full module page** - (the "detail sheet" is superseded); the raw command + log demote to a +> +> **Architecture ADRs (both Accepted):** +> [ADR-0016](adr/0016-sandbox-execution-model.md) (sandbox execution model — +> the Seatbelt-wrapped `SmokeRunner` behind both run doors) and +> [ADR-0017](adr/0017-mcp-run-tool.md) (the MCP `run_gunk` execute tool, the +> agent's door, shipped in T-10.12). + +- [x] **Smoke run ("Try it")** (T-10.7): execute a module's entrypoint against + its extracted bundle in a sandbox, persist the receipt (when, pass/fail, + duration, output). Receipt-first; the raw command + log demote to a disclosure. First-run consent treatment (it executes extracted code); states: never-tried / consent / running / passed / failed / resting - receipt. Build verification already stores a command + log, so the - store pattern exists -- [ ] **Copyable invocation snippet** per module, generated from the stored - entrypoints + symbols ("how do I use this" in one glance) -- [ ] **Requirements readout**: reshape shared-dependency *paths* into "to - run this elsewhere you need" — runtime, packages, env vars (parsed - from bundle manifests; absorbs the old "dependencies + versions - panel" item from Phase 9) -- [ ] Tested badge: new store field + leveling rule (badge tier scales with - how much the module was tested) — this becomes the marketplace ranking - signal in Phase 12, and smoke-run receipts are the first *honest* - usage signal for the Library's `heroRank` `FUTURE` seam (never - fabricate usage numbers) -- [ ] Runs stay **receipts, not a dashboard**: the extraction-run inspector - (T-8.6) answers "what did gunk do"; the smoke run answers "what does - the module do" — two surfaces, linked from the module, never merged + receipt. (The v2 run console + coverage ledger superseded the v1 Proof + card.) +- [x] **Copyable invocation snippet** (T-10.5) per module, generated from the + stored entrypoints + symbols ("how do I use this" in one glance) +- [x] **Requirements readout** (T-10.6): reshape shared-dependency *paths* into + "to run this elsewhere you need" — runtime, packages, env vars (parsed + from bundle manifests; absorbs the old "dependencies + versions panel" + item from Phase 9) +- [x] Tested/coverage metric (T-10.9/T-10.11): **superseded the "Tested badge" + leveling idea per CP-F** — shipped as the honest **coverage ledger** + (happy path · your own inputs · edge cases · adversarial) + the + `Ready to connect` sign-off, not a tier badge. Smoke-run receipts remain + the first honest usage signal for the Library's `heroRank` `FUTURE` seam + (never fabricate usage numbers) +- [x] Runs stay **receipts, not a dashboard** (T-10.7): the extraction-run + inspector (T-8.6) answers "what did gunk do"; the smoke run answers "what + does the module do" — two surfaces, linked from the module, never merged - [ ] UI-module runner: detect UI modules and **launch the browser** at the - module's served surface — in-app preview is explicitly out for now - (module-run-v1 revision of the old "launch/preview them from the app") -- [ ] Explicitly out: dependency-graph visualizations, run-history charts, - in-app editing, metrics dashboards + module's served surface — in-app preview is explicitly out for now. + **Detection + the deferred "not yet" label landed (T-10.13); the actual + in-browser launch is deferred to a later phase** (CP-F descope) +- [x] **"How this works" on-demand analysis** (T-10.14): one quiet disclosure + opens a cached AI walkthrough of a module's design (added at CP-F) +- [x] Explicitly out (held): dependency-graph visualizations, run-history + charts, in-app editing, metrics dashboards --- diff --git a/docs/tasks/phase-10-run-and-test-modules.md b/docs/tasks/phase-10-run-and-test-modules.md index 19b78a4..d7f4d47 100644 --- a/docs/tasks/phase-10-run-and-test-modules.md +++ b/docs/tasks/phase-10-run-and-test-modules.md @@ -1481,6 +1481,25 @@ browser** at the module's served surface. In-app preview is explicitly out. **Owner:** agent **Checkpoint:** none +**Status: landed (2026-06-17).** One quiet `How this works` disclosure on the +module page opens a cached AI walkthrough (summary, data flow in → transform → +out, key functions, what it touches, its limits — the long form of the T-10.8 +input signature). **Decision (the task's "decide and report"): generated +app-side and cached on first request, not at engine extraction.** The engine +extractor makes no LLM call and the manual-approve path is pure Swift with no +engine, so an engine-only cache would leave every manually-approved and older +module permanently unanalyzed; generating in the app (where the user's +provider/model/key already live) is one mechanism that covers every module and +honors the constraints — instant on open (cache read in schema v7 +`module_analyses`, app-only/additive), never a live call at view time, never +auto-summoned on open (unanalyzed modules show a quiet "Not analyzed yet" with a +single on-demand "Analyze" action — the refining-loop rule). Mono only for the +code references. Lives in `ModuleAnalysis`/`ModuleAnalysisComposer`/ +`LiveModuleAnalysisGenerator` + `BrowseModel` + the `ModulePageView` disclosure; +a `GUNK_DEBUG_HOW_IT_WORKS=closed|open|missing` hook stages the states. Build + +tests green (257 passing, 1 sandbox-availability skip). The human-in-the-loop +confirmation (opening it on a real module, instant + accurate-enough) is Mark's +gate. ### Goal A quiet, single disclosure on the module page that opens an AI-written